import React, { useEffect, useRef, useState } from 'react'; import { api } from '../api.js'; import { connectionAction, copyText, formatConnectionDuration, localProxyUrls, subscriptionDomain, subscriptionDaysLeft, subscriptionUsage, } from '../utils/clientControls.js'; import { formatBytes } from '../utils/format.js'; export function ClientOverviewPage({ state, busy, error, subscriptionUrl, setSubscriptionUrl, servers, pendingTag, setPendingTag, onFetchSubscription, onRefreshSubscriptionInfo, onForgetSubscription, onApply, onRestart, onStop, }) { const isGateway = state?.mode === 'gateway'; const connected = Boolean(state?.singboxRunning); const hasSubscription = Boolean(state?.hasSubscription); const selectedTag = pendingTag || state?.selectedTag || ''; const showPower = hasSubscription && Boolean(selectedTag); const canStart = Boolean(selectedTag || state?.configExists); const [now, setNow] = useState(Date.now()); const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription); const [pings, setPings] = useState({}); const [accessTab, setAccessTab] = useState('gateway'); const [copyFeedback, setCopyFeedback] = useState(null); const [refreshingInfo, setRefreshingInfo] = useState(false); const [usageUpdated, setUsageUpdated] = useState(false); const [serverRevealVersion, setServerRevealVersion] = useState(0); const [serversLeaving, setServersLeaving] = useState(false); const subscriptionInputRef = useRef(null); const subscriptionRef = useRef(null); const copyTimerRef = useRef(null); const serverKey = servers.map((server) => `${server.tag}:${server.server}:${server.server_port}`).join('|'); const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1'; const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress); const usage = subscriptionUsage(state?.userInfo); const [displayedUsed, setDisplayedUsed] = useState(usage.used); const hasUsage = Boolean( state?.userInfo && ['upload', 'download', 'total', 'expire'].some((key) => key in state.userInfo), ); useEffect(() => { setNow(Date.now()); if (!connected || !state?.singboxStartedAt) return undefined; const timer = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(timer); }, [connected, state?.singboxStartedAt]); useEffect(() => { if (!servers.length) { setPings({}); return undefined; } let cancelled = false; setPings(Object.fromEntries(servers.map((server) => [server.tag, { checking: true }]))); api.servers.pingAll() .then((data) => { if (cancelled) return; setPings(Object.fromEntries((data.results || []).map((ping) => [ String(ping.tag || '').trim(), ping, ]))); }) .catch(() => { if (!cancelled) { setPings(Object.fromEntries(servers.map((server) => [server.tag, { ok: false }]))); } }); return () => { cancelled = true; }; }, [serverKey]); useEffect(() => { if (editingSubscription) subscriptionInputRef.current?.focus(); }, [editingSubscription]); useEffect(() => { if (!hasSubscription) setEditingSubscription(true); }, [hasSubscription]); useEffect(() => { if (!editingSubscription || !state?.hasSubscription || subscriptionUrl) return undefined; const timer = setTimeout(() => setEditingSubscription(false), 5000); return () => clearTimeout(timer); }, [editingSubscription, state?.hasSubscription, subscriptionUrl]); useEffect(() => { if (!editingSubscription || !hasSubscription) return undefined; const closeOnOutsideClick = (event) => { if (subscriptionRef.current?.contains(event.target)) return; setSubscriptionUrl(''); setEditingSubscription(false); }; document.addEventListener('pointerdown', closeOnOutsideClick); return () => document.removeEventListener('pointerdown', closeOnOutsideClick); }, [editingSubscription, hasSubscription, setSubscriptionUrl]); useEffect(() => { if (!state?.hasSubscription) return undefined; const refresh = () => onRefreshSubscriptionInfo().catch(() => {}); refresh(); const timer = setInterval(refresh, 60_000); return () => clearInterval(timer); }, [state?.hasSubscription]); useEffect(() => { const from = displayedUsed; const to = usage.used; if (from === to) return undefined; const startedAt = performance.now(); let frame; const tick = (now) => { const progress = Math.min(1, (now - startedAt) / 900); const eased = 1 - Math.pow(1 - progress, 4); setDisplayedUsed(from + (to - from) * eased); if (progress < 1) frame = requestAnimationFrame(tick); }; frame = requestAnimationFrame(tick); return () => cancelAnimationFrame(frame); }, [usage.used]); useEffect(() => () => clearTimeout(copyTimerRef.current), []); async function toggleConnection() { const action = connectionAction({ connected, selectedTag, configExists: state?.configExists }); if (action?.type === 'stop') return onStop(); if (action?.type === 'apply') return onApply(action.selectedTag); if (action?.type === 'restart') return onRestart(); } function selectServer(tag) { setPendingTag(tag); if (connected && tag) onApply(tag); } async function submitSubscription(event) { event.preventDefault(); if (!subscriptionUrl.trim()) return; await onFetchSubscription(); setSubscriptionUrl(''); setEditingSubscription(false); } async function copyProxy(kind) { const value = kind === 'gateway' ? gatewayAddress : proxyUrls[kind]; clearTimeout(copyTimerRef.current); setCopyFeedback({ kind, failed: false }); copyTimerRef.current = setTimeout(() => setCopyFeedback(null), 800); try { await copyText(value); } catch { setCopyFeedback({ kind, failed: true }); } } async function refreshInfo() { const startedAt = performance.now(); setRefreshingInfo(true); try { await onRefreshSubscriptionInfo(); setUsageUpdated(false); requestAnimationFrame(() => setUsageUpdated(true)); setTimeout(() => setUsageUpdated(false), 900); setServersLeaving(true); await new Promise((resolve) => setTimeout(resolve, 420 + Math.max(0, servers.length - 1) * 90)); setServerRevealVersion((version) => version + 1); setServersLeaving(false); } finally { const elapsed = performance.now() - startedAt; const completeCyclesAt = Math.max(900, Math.ceil(elapsed / 900) * 900); await new Promise((resolve) => setTimeout(resolve, completeCyclesAt - elapsed)); setRefreshingInfo(false); } } return (
{showPower && (

{connected ? 'VPN включён' : 'VPN выключен'}

{connected ? ( ) : (

{canStart ? 'Нажмите, чтобы включить' : 'Добавьте ссылку и выберите сервер'}

)}
{isGateway && (
{[ ['gateway', 'Gateway'], ['proxy', 'Local Proxy'], ].map(([tab, label]) => ( ))}
)} {(isGateway ? accessTab === 'gateway' : false) ? (
{gatewayAddress}
) : (
{!isGateway && Адрес} {proxyUrls.http.replace(/^https?:\/\//, '')}
{[ ['socks5', 'SOCKS5'], ['http', 'HTTP'], ].map(([kind, label]) => ( ))}
)}
)} {error &&

{error}

}
Ваша подписка
setSubscriptionUrl(event.target.value)} onKeyDown={(event) => { if (event.key === 'Escape' && state?.hasSubscription) { setSubscriptionUrl(''); setEditingSubscription(false); } }} /> {subscriptionUrl && ( {subscriptionDomain(subscriptionUrl)} )} {subscriptionUrl.trim() && ( )}
{hasSubscription && hasUsage && (
Использовано {formatBytes(displayedUsed)} / {usage.total ? formatBytes(usage.total) : 'без лимита'} {usage.percent !== null && (
)}
{usage.expiresAt && !Number.isNaN(usage.expiresAt.getTime()) && ( до {usage.expiresAt.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' })} {' · '}{subscriptionDaysLeft(usage.expiresAt)} )}
)} {hasSubscription && (
{!showPower && Выберите сервер}
{servers.map((server, index) => { const ping = pings[server.tag]; const selected = server.tag === selectedTag; const pingText = ping?.checking ? 'Проверка…' : ping?.ok ? `${ping.latency} ms` : 'Недоступен'; const pingClass = ping?.ok ? ping.latency < 100 ? 'good' : ping.latency < 250 ? 'medium' : 'slow' : ''; return ( ); })}
)}
); }