Files
harbor-net/src/web/features/subscription/SubscriptionFeature.tsx
T
dokril 7e4da4bdcf
Build and Deploy Gateway / build-and-push (push) Successful in 20s
Build and Deploy Gateway / deploy (push) Successful in 7s
Fix profile server selection activation state
2026-08-11 10:11:13 +03:00

562 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 expanded = 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 = 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 = `Серверов: ${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
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>{profileDomain}</strong>
{(applied || desired) && <em>{applied ? 'АКТИВНА' : 'ВЫБРАНА'}</em>}
</span>
<small>{profileDetails}</small>
</span>
</button>
{(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>}
{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>}
{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">
{!visibleServerId && <p className="client-profile-server-hint">Выберите сервер этой подписки</p>}
{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}
/>;
}