Simplify subscription profile management UI
Build and Deploy Gateway / build-and-push (push) Successful in 19s
Build and Deploy Gateway / deploy (push) Successful in 7s

This commit is contained in:
2026-08-11 07:36:44 +03:00
parent af1c45e424
commit 5cad3e9061
12 changed files with 148 additions and 251 deletions
+2 -2
View File
@@ -93,7 +93,7 @@ function ServerCheckButton({
onClick: () => void;
}) {
return <button
className={`client-server-check client-tooltip-anchor${checking ? ' is-checking' : ''}`}
className={`client-server-check${checking ? ' is-checking' : ''}`}
type="button"
aria-label={checking ? 'Проверяем пинг серверов' : 'Проверить пинг серверов'}
disabled={checking || disabled}
@@ -102,7 +102,7 @@ function ServerCheckButton({
<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>
<span className="client-tooltip" role="tooltip">{checking ? 'Проверяем пинг…' : 'Проверить пинг'}</span>
<span className="client-server-check-label">{checking ? 'Проверяем пинг…' : 'Проверить пинг'}</span>
</button>;
}
@@ -40,15 +40,12 @@ interface SubscriptionFeatureOptions {
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: 'Добавляем подписку…',
@@ -74,7 +71,6 @@ export function useSubscriptionFeature({
operations,
error,
onAdd,
onRename,
onRefresh,
onForget,
onActivate,
@@ -84,11 +80,7 @@ export function useSubscriptionFeature({
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>>({});
@@ -101,17 +93,10 @@ export function useSubscriptionFeature({
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<[
@@ -154,7 +139,6 @@ export function useSubscriptionFeature({
panelRef.current?.contains(target) || toggleRef.current?.contains(target)
)) return;
setOpen(false);
setMenuId('');
};
document.addEventListener('pointerdown', closeDrawer);
document.addEventListener('keydown', closeDrawer);
@@ -169,7 +153,6 @@ export function useSubscriptionFeature({
}, [open]);
function resetAdd() {
setLabel('');
setUrl('');
if (profiles.length) setAdding(false);
if (profiles.length) requestAnimationFrame(() => (
@@ -178,31 +161,17 @@ export function useSubscriptionFeature({
}
async function addProfile() {
if (!normalizedLabel || duplicateLabel || validationStatus !== 'valid') return;
if (!await onAdd(normalizedLabel, normalizedUrl)) return;
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();
}
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]);
@@ -225,7 +194,6 @@ export function useSubscriptionFeature({
if (!deleteProfile) return;
if (!await onForget(deleteProfile.id, deleteStopsVpn ? 'stop-and-delete' : 'delete')) return;
setDeleteId('');
setMenuId('');
}
function toggleProfile(profileId: string) {
@@ -247,15 +215,9 @@ export function useSubscriptionFeature({
expanded,
visited,
adding,
label,
url,
validationStatus,
duplicateLabel,
addError,
renamingId,
renameLabel,
renameDuplicate,
menuId,
deleteProfile,
deleteStopsVpn,
activeOperation,
@@ -266,30 +228,22 @@ export function useSubscriptionFeature({
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(''); },
close: () => setOpen(false),
showAdd: () => {
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,
@@ -324,10 +278,8 @@ export function SubscriptionToggle({
}
function AddProfileForm({ feature }: { feature: SubscriptionFeatureController }) {
const message = feature.duplicateLabel
? 'Такое имя уже используется.'
: feature.validationStatus === 'invalid'
? ERROR_DEFINITIONS.SUBSCRIPTION_INVALID.message
const message = feature.validationStatus === 'invalid'
? ERROR_DEFINITIONS.SUBSCRIPTION_INVALID.message
: feature.addError?.message || '';
return <form
className="client-profile-add"
@@ -337,20 +289,10 @@ function AddProfileForm({ feature }: { feature: SubscriptionFeatureController })
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
ref={feature.labelRef}
type="url"
inputMode="url"
value={feature.url}
@@ -367,7 +309,7 @@ function AddProfileForm({ feature }: { feature: SubscriptionFeatureController })
<button
className="is-primary"
type="submit"
disabled={feature.addBlocked || !feature.label.trim() || feature.duplicateLabel || feature.validationStatus !== 'valid'}
disabled={feature.addBlocked || feature.validationStatus !== 'valid'}
>Добавить</button>
</div>
</form>;
@@ -420,6 +362,10 @@ function ProfileGroup({
? 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' : ''}`}>
<div className="client-profile-header">
<button
@@ -432,10 +378,10 @@ function ProfileGroup({
<span className="client-profile-chevron" aria-hidden="true"></span>
<span className="client-profile-title">
<span>
<strong>{profile.label}</strong>
<strong>{profileDomain}</strong>
{(applied || desired) && <em>{applied ? 'АКТИВНА' : 'ВЫБРАНА'}</em>}
</span>
<small>{subscriptionDomain(profile.subscription.host)}</small>
<small>{profileDetails}</small>
</span>
</button>
{canActivate ? <button
@@ -447,48 +393,28 @@ function ProfileGroup({
<button
className={`client-profile-refresh${refreshing ? ' is-refreshing' : ''}`}
type="button"
aria-label={`Обновить подписку ${profile.label}`}
aria-label={`Обновить подписку ${profileDomain}`}
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>
>
<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>
{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>}
{!expanded && visibleServer && <button
@@ -586,7 +512,7 @@ export function SubscriptionDeleteDialog({ feature }: { feature: SubscriptionFea
open={Boolean(feature.deleteProfile)}
id="delete-subscription"
kicker="Необратимое действие"
title={`Удалить «${feature.deleteProfile?.label || ''}»?`}
title={`Удалить «${subscriptionDomain(feature.deleteProfile?.subscription.host)}»?`}
description={feature.deleteStopsVpn
? 'Эта подписка сейчас работает. Harbor остановит VPN и удалит её одной операцией.'
: 'Подписка и её локальные настройки будут удалены. Работающий VPN другой подписки не изменится.'}