Add subscription info refresh endpoint and UI stats
This commit is contained in:
@@ -4,7 +4,7 @@ import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { settings } from "./config.js";
|
||||
import { fetchSubscription } from "./subscription.js";
|
||||
import { fetchSubscription, fetchSubscriptionInfo } from "./subscription.js";
|
||||
import os from "node:os";
|
||||
import {
|
||||
buildGatewayConfig,
|
||||
@@ -1424,6 +1424,19 @@ async function handleApi(req, res) {
|
||||
return sendJson(res, 200, { success: true, ...parsed });
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/api/subscription/refresh-info") {
|
||||
const prevState = readJson(settings.statePath, {});
|
||||
if (!prevState.subscriptionUrl) {
|
||||
return sendJson(res, 400, { success: false, error: "Подписка не настроена" });
|
||||
}
|
||||
|
||||
const info = await fetchSubscriptionInfo(prevState.subscriptionUrl);
|
||||
writeJson(settings.statePath, { ...prevState, ...info });
|
||||
const cached = readJson(settings.subscriptionCachePath, null);
|
||||
if (cached) writeJson(settings.subscriptionCachePath, { ...cached, ...info });
|
||||
return sendJson(res, 200, { success: true, ...info });
|
||||
}
|
||||
|
||||
if (req.method === "DELETE" && req.url === "/api/subscription") {
|
||||
if (fs.existsSync(settings.subscriptionCachePath))
|
||||
fs.rmSync(settings.subscriptionCachePath);
|
||||
|
||||
@@ -136,7 +136,7 @@ export function parseSubscriptionBody(body) {
|
||||
return { config: parsedConfig, servers };
|
||||
}
|
||||
|
||||
export async function fetchSubscription(url) {
|
||||
async function requestSubscription(url) {
|
||||
let parsedUrl;
|
||||
try {
|
||||
parsedUrl = new URL(url);
|
||||
@@ -157,6 +157,21 @@ export async function fetchSubscription(url) {
|
||||
throw new Error(`Subscription request failed: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function fetchSubscriptionInfo(url) {
|
||||
const response = await requestSubscription(url);
|
||||
await response.body?.cancel();
|
||||
return {
|
||||
userInfo: parseUserInfo(response.headers.get('subscription-userinfo')),
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchSubscription(url) {
|
||||
const response = await requestSubscription(url);
|
||||
|
||||
const body = await response.text();
|
||||
const userInfo = parseUserInfo(response.headers.get('subscription-userinfo'));
|
||||
const parsed = parseSubscriptionBody(body);
|
||||
|
||||
@@ -133,6 +133,12 @@ function App() {
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshSubscriptionInfo() {
|
||||
const data = await api.subscription.refreshInfo();
|
||||
setState((prev) => prev ? { ...prev, userInfo: data.userInfo, fetchedAt: data.fetchedAt } : prev);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function forgetSubscription() {
|
||||
if (!confirm('Удалить подписку и остановить sing-box?')) return;
|
||||
return withBusy('Подписка удалена', async () => {
|
||||
@@ -426,6 +432,7 @@ function App() {
|
||||
pendingTag={pendingTag}
|
||||
setPendingTag={setPendingTag}
|
||||
onFetchSubscription={fetchSubscription}
|
||||
onRefreshSubscriptionInfo={refreshSubscriptionInfo}
|
||||
onApply={applyServer}
|
||||
onRestart={restartSingbox}
|
||||
onStop={() => stopSingbox(false)}
|
||||
|
||||
@@ -65,6 +65,7 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url }),
|
||||
}),
|
||||
refreshInfo: () => request("/api/subscription/refresh-info", { method: "POST" }),
|
||||
forget: () => request("/api/subscription", { method: "DELETE" }),
|
||||
},
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@ import {
|
||||
formatConnectionDuration,
|
||||
localProxyUrls,
|
||||
subscriptionDomain,
|
||||
subscriptionDaysLeft,
|
||||
subscriptionUsage,
|
||||
} from '../utils/clientControls.js';
|
||||
import { formatBytes } from '../utils/format.js';
|
||||
|
||||
export function ClientOverviewPage({
|
||||
state,
|
||||
@@ -16,6 +19,7 @@ export function ClientOverviewPage({
|
||||
pendingTag,
|
||||
setPendingTag,
|
||||
onFetchSubscription,
|
||||
onRefreshSubscriptionInfo,
|
||||
onApply,
|
||||
onRestart,
|
||||
onStop,
|
||||
@@ -27,9 +31,19 @@ export function ClientOverviewPage({
|
||||
const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription);
|
||||
const [pings, setPings] = useState({});
|
||||
const [copiedProxy, setCopiedProxy] = useState('');
|
||||
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 serverKey = servers.map((server) => `${server.tag}:${server.server}:${server.server_port}`).join('|');
|
||||
const proxyUrls = localProxyUrls(state?.proxyPort);
|
||||
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());
|
||||
@@ -74,6 +88,41 @@ export function ClientOverviewPage({
|
||||
return () => clearTimeout(timer);
|
||||
}, [editingSubscription, state?.hasSubscription, subscriptionUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingSubscription) 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, 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]);
|
||||
|
||||
async function toggleConnection() {
|
||||
const action = connectionAction({ connected, selectedTag, configExists: state?.configExists });
|
||||
if (action?.type === 'stop') return onStop();
|
||||
@@ -98,10 +147,30 @@ export function ClientOverviewPage({
|
||||
try {
|
||||
await navigator.clipboard.writeText(proxyUrls[kind]);
|
||||
setCopiedProxy(kind);
|
||||
setTimeout(() => setCopiedProxy(''), 1400);
|
||||
setTimeout(() => setCopiedProxy(''), 800);
|
||||
} catch {
|
||||
setCopiedProxy('error');
|
||||
setTimeout(() => setCopiedProxy(''), 1400);
|
||||
setTimeout(() => setCopiedProxy(''), 800);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,16 +191,21 @@ export function ClientOverviewPage({
|
||||
<path d="M12 2v10M5.6 5.6a9 9 0 1 0 12.8 0" />
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<h2 id="connection-title">{connected ? 'VPN включён' : 'VPN выключен'}</h2>
|
||||
{connected && (
|
||||
<time className="client-duration">
|
||||
{formatConnectionDuration(state?.singboxStartedAt, now)}
|
||||
</time>
|
||||
)}
|
||||
{!connected && (
|
||||
<p>{canStart ? 'Нажмите, чтобы подключиться' : 'Добавьте ссылку и выберите сервер'}</p>
|
||||
)}
|
||||
<div className="client-state-copy" aria-live="polite">
|
||||
<h2 key={connected ? 'connected' : 'disconnected'} id="connection-title">
|
||||
{connected ? 'VPN включён' : 'VPN выключен'}
|
||||
</h2>
|
||||
<div className="client-state-detail">
|
||||
{connected ? (
|
||||
<time key="duration" className="client-duration">
|
||||
{formatConnectionDuration(state?.singboxStartedAt, now)}
|
||||
</time>
|
||||
) : (
|
||||
<p key="hint">
|
||||
{canStart ? 'Нажмите, чтобы включить' : 'Добавьте ссылку и выберите сервер'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="client-proxies" aria-label="Локальный прокси">
|
||||
@@ -151,8 +225,8 @@ export function ClientOverviewPage({
|
||||
aria-label={`Скопировать ${label}: ${proxyUrls[kind]}`}
|
||||
onClick={() => copyProxy(kind)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{copiedProxy === kind && <span className="client-copy-check">✓</span>}
|
||||
<span className="client-copy-label">{label}</span>
|
||||
{copiedProxy === kind && <span className="client-copy-feedback">Copied</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -160,18 +234,39 @@ export function ClientOverviewPage({
|
||||
</section>
|
||||
|
||||
<div className="client-form">
|
||||
<div className={`client-subscription ${editingSubscription ? 'is-editing' : ''}`}>
|
||||
<button
|
||||
<div
|
||||
ref={subscriptionRef}
|
||||
className={`client-subscription ${editingSubscription ? 'is-editing' : ''}${editingSubscription && state?.hasSubscription && !subscriptionUrl ? ' is-timing-out' : ''}`}
|
||||
>
|
||||
<div
|
||||
className="client-subscription-summary"
|
||||
type="button"
|
||||
aria-hidden={editingSubscription}
|
||||
inert={editingSubscription ? true : undefined}
|
||||
tabIndex={editingSubscription ? -1 : 0}
|
||||
onClick={() => setEditingSubscription(true)}
|
||||
>
|
||||
<span>Ваша подписка</span>
|
||||
<strong>{subscriptionDomain(state?.subscriptionHost)}</strong>
|
||||
</button>
|
||||
<div className="client-subscription-heading">
|
||||
<span>Ваша подписка</span>
|
||||
<button
|
||||
className="client-subscription-refresh"
|
||||
type="button"
|
||||
aria-label="Обновить статистику подписки"
|
||||
title="Обновить статистику"
|
||||
disabled={refreshingInfo}
|
||||
onClick={refreshInfo}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<button
|
||||
className="client-subscription-domain-button"
|
||||
type="button"
|
||||
tabIndex={editingSubscription ? -1 : 0}
|
||||
onClick={() => setEditingSubscription(true)}
|
||||
>
|
||||
<strong>{subscriptionDomain(state?.subscriptionHost)}</strong>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="client-subscription-edit"
|
||||
@@ -210,9 +305,42 @@ export function ClientOverviewPage({
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{hasUsage && (
|
||||
<section className={`client-usage${usageUpdated ? ' is-updated' : ''}`} aria-label="Статистика подписки">
|
||||
<span>Использовано</span>
|
||||
<strong>
|
||||
{formatBytes(displayedUsed)}
|
||||
<small> / {usage.total ? formatBytes(usage.total) : 'без лимита'}</small>
|
||||
</strong>
|
||||
{usage.percent !== null && (
|
||||
<div
|
||||
className="client-usage-bar"
|
||||
role="progressbar"
|
||||
aria-label="Использованный трафик"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-valuenow={Math.round(usage.percent)}
|
||||
>
|
||||
<i style={{ width: `${usage.percent}%` }} />
|
||||
</div>
|
||||
)}
|
||||
<div className="client-usage-details">
|
||||
{usage.expiresAt && !Number.isNaN(usage.expiresAt.getTime()) && (
|
||||
<span>
|
||||
до {usage.expiresAt.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' })}
|
||||
{' · '}{subscriptionDaysLeft(usage.expiresAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="client-servers" aria-label="Серверы">
|
||||
<div className="client-server-grid">
|
||||
{servers.map((server) => {
|
||||
<div
|
||||
className={`client-server-grid${serversLeaving ? ' is-leaving' : ''}`}
|
||||
key={`${serverKey}:${serverRevealVersion}`}
|
||||
>
|
||||
{servers.map((server, index) => {
|
||||
const ping = pings[server.tag];
|
||||
const selected = server.tag === selectedTag;
|
||||
const pingText = ping?.checking
|
||||
@@ -229,6 +357,7 @@ export function ClientOverviewPage({
|
||||
key={server.tag}
|
||||
disabled={busy}
|
||||
aria-pressed={selected}
|
||||
style={{ '--server-index': index }}
|
||||
onClick={() => selectServer(server.tag)}
|
||||
>
|
||||
<strong>{server.tag}</strong>
|
||||
|
||||
@@ -893,11 +893,12 @@ code, .mono {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.client-power-section > div {
|
||||
.client-state-copy {
|
||||
min-height: 54px;
|
||||
}
|
||||
|
||||
.client-power {
|
||||
position: relative;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
display: grid;
|
||||
@@ -907,12 +908,30 @@ code, .mono {
|
||||
background: transparent;
|
||||
color: var(--client-muted);
|
||||
cursor: pointer;
|
||||
transition: color 600ms cubic-bezier(0.16, 1, 0.3, 1), transform 600ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
transition: color 800ms cubic-bezier(0.16, 1, 0.3, 1), opacity 600ms ease, transform 600ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.client-power::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, color-mix(in oklch, var(--client-accent) 42%, transparent), transparent 70%);
|
||||
opacity: 0;
|
||||
transform: scale(0.55);
|
||||
transition: opacity 900ms cubic-bezier(0.16, 1, 0.3, 1), transform 900ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.client-power[aria-checked='true']::before {
|
||||
opacity: 0.75;
|
||||
transform: scale(1.75);
|
||||
}
|
||||
|
||||
.client-power:hover:not(:disabled) {
|
||||
transform: translateY(-3px) scale(1.08);
|
||||
color: var(--client-accent);
|
||||
color: var(--client-text);
|
||||
}
|
||||
|
||||
.client-power[aria-checked='true'] {
|
||||
@@ -930,6 +949,7 @@ code, .mono {
|
||||
}
|
||||
|
||||
.client-power svg {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
fill: none;
|
||||
@@ -941,15 +961,27 @@ code, .mono {
|
||||
}
|
||||
|
||||
.client-power:hover:not(:disabled) svg {
|
||||
filter: drop-shadow(0 0 6px color-mix(in oklch, var(--client-muted) 55%, transparent)) drop-shadow(0 0 18px color-mix(in oklch, var(--client-muted) 22%, transparent));
|
||||
}
|
||||
|
||||
.client-power[aria-checked='true']:hover:not(:disabled) {
|
||||
color: var(--client-accent);
|
||||
}
|
||||
|
||||
.client-power[aria-checked='true']:hover:not(:disabled) svg {
|
||||
filter: drop-shadow(0 0 6px color-mix(in oklch, var(--client-accent) 75%, transparent)) drop-shadow(0 0 18px color-mix(in oklch, var(--client-accent) 35%, transparent));
|
||||
}
|
||||
|
||||
.client-power:active:not(:disabled) svg {
|
||||
transform: scale(0.88);
|
||||
filter: drop-shadow(0 0 10px var(--client-accent)) drop-shadow(0 0 26px color-mix(in oklch, var(--client-accent) 55%, transparent));
|
||||
filter: drop-shadow(0 0 10px var(--client-muted)) drop-shadow(0 0 26px color-mix(in oklch, var(--client-muted) 45%, transparent));
|
||||
transition-duration: 220ms;
|
||||
}
|
||||
|
||||
.client-power[aria-checked='true']:active:not(:disabled) svg {
|
||||
filter: drop-shadow(0 0 10px var(--client-accent)) drop-shadow(0 0 26px color-mix(in oklch, var(--client-accent) 55%, transparent));
|
||||
}
|
||||
|
||||
.client-power[aria-checked='true'] svg {
|
||||
filter: drop-shadow(0 0 5px color-mix(in oklch, var(--client-accent) 65%, transparent)) drop-shadow(0 0 14px color-mix(in oklch, var(--client-accent) 28%, transparent));
|
||||
}
|
||||
@@ -957,11 +989,24 @@ code, .mono {
|
||||
.client-power-section h2 {
|
||||
font: 700 18px/1.3 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||
letter-spacing: -0.04em;
|
||||
animation: client-state-reveal 700ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.client-state-detail {
|
||||
min-height: 20px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.client-state-detail > * {
|
||||
grid-area: 1 / 1;
|
||||
margin: 0;
|
||||
animation: client-state-reveal 850ms 80ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.client-duration {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: var(--client-text);
|
||||
font-size: 14px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
@@ -969,8 +1014,13 @@ code, .mono {
|
||||
}
|
||||
|
||||
.client-power-section p {
|
||||
min-height: 17px;
|
||||
margin-top: 5px;
|
||||
min-height: 0;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
@keyframes client-state-reveal {
|
||||
0% { opacity: 0; filter: blur(5px); }
|
||||
100% { opacity: 1; filter: blur(0); }
|
||||
}
|
||||
|
||||
.client-form {
|
||||
@@ -982,12 +1032,59 @@ code, .mono {
|
||||
}
|
||||
|
||||
.client-subscription {
|
||||
position: relative;
|
||||
min-height: 88px;
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.client-subscription-refresh {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--client-muted);
|
||||
cursor: pointer;
|
||||
transition: color 250ms ease, filter 500ms ease, opacity 300ms ease, transform 600ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.client-subscription-refresh svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.7;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.client-subscription-refresh:hover:not(:disabled) {
|
||||
color: var(--client-accent);
|
||||
filter: drop-shadow(0 0 6px color-mix(in oklch, var(--client-accent) 55%, transparent));
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.client-subscription-refresh:disabled {
|
||||
color: var(--client-accent);
|
||||
cursor: wait;
|
||||
animation: client-spin 900ms linear infinite;
|
||||
}
|
||||
|
||||
.client-subscription-refresh:focus-visible {
|
||||
outline: 2px solid var(--client-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.client-subscription.is-editing .client-subscription-refresh {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.client-subscription-edit,
|
||||
.client-subscription-summary {
|
||||
grid-area: 1 / 1;
|
||||
@@ -1030,6 +1127,21 @@ code, .mono {
|
||||
box-shadow: 0 0 8px var(--client-accent), 0 0 18px var(--client-accent-soft);
|
||||
}
|
||||
|
||||
.client-subscription.is-timing-out .client-subscription-edit::after {
|
||||
animation: client-subscription-timeout 5s linear forwards;
|
||||
}
|
||||
|
||||
@keyframes client-subscription-timeout {
|
||||
0% {
|
||||
opacity: 0.9;
|
||||
box-shadow: 0 0 8px var(--client-accent), 0 0 18px var(--client-accent-soft);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.08;
|
||||
box-shadow: 0 0 0 transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.client-subscription.is-editing .client-subscription-edit {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
@@ -1046,7 +1158,7 @@ code, .mono {
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--client-text);
|
||||
caret-color: var(--client-accent);
|
||||
caret-color: transparent;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -1094,6 +1206,20 @@ code, .mono {
|
||||
color: var(--client-accent);
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.client-subscription-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.client-subscription-domain-button {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -1117,13 +1243,13 @@ code, .mono {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.client-subscription-summary span {
|
||||
.client-subscription-heading > span {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.client-subscription-summary strong {
|
||||
.client-subscription-domain-button strong {
|
||||
color: var(--client-text);
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
@@ -1133,12 +1259,89 @@ code, .mono {
|
||||
transition: opacity 300ms cubic-bezier(0.16, 1, 0.3, 1), text-shadow 300ms cubic-bezier(0.16, 1, 0.3, 1), transform 300ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.client-subscription-summary:hover strong {
|
||||
.client-subscription-domain-button:hover strong {
|
||||
opacity: 1;
|
||||
transform: translateY(-2px);
|
||||
text-shadow: 0 0 28px oklch(0.7 0.08 151 / 0.42);
|
||||
}
|
||||
|
||||
.client-usage {
|
||||
width: min(100%, 250px);
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin: -10px auto 0;
|
||||
color: var(--client-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.client-usage > span {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@keyframes client-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.client-usage > strong {
|
||||
color: var(--client-text);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: color 700ms ease, filter 700ms ease, text-shadow 700ms ease;
|
||||
}
|
||||
|
||||
.client-usage.is-updated {
|
||||
animation: client-usage-glow 1100ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.client-usage.is-updated > strong {
|
||||
color: var(--client-accent);
|
||||
filter: brightness(1.25);
|
||||
text-shadow: 0 0 8px var(--client-accent), 0 0 22px color-mix(in oklch, var(--client-accent) 70%, transparent);
|
||||
}
|
||||
|
||||
.client-usage.is-updated .client-usage-bar i {
|
||||
animation: client-bar-flare 1100ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes client-usage-glow {
|
||||
30% { filter: drop-shadow(0 0 18px color-mix(in oklch, var(--client-accent) 78%, transparent)); }
|
||||
}
|
||||
|
||||
@keyframes client-bar-flare {
|
||||
30% { box-shadow: 0 0 6px var(--client-accent), 0 0 20px var(--client-accent); filter: brightness(1.45); }
|
||||
}
|
||||
|
||||
.client-usage > strong small {
|
||||
color: var(--client-muted);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.client-usage-bar {
|
||||
height: 2px;
|
||||
overflow: hidden;
|
||||
background: var(--client-border);
|
||||
}
|
||||
|
||||
.client-usage-bar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--client-accent);
|
||||
box-shadow: 0 0 8px var(--client-accent);
|
||||
transition: width 600ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.client-usage-details {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
font-size: 9px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.client-servers {
|
||||
display: block;
|
||||
}
|
||||
@@ -1166,9 +1369,44 @@ code, .mono {
|
||||
color: var(--client-text);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
animation: client-server-enter 760ms calc(var(--server-index) * 110ms) cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.client-server-grid.is-leaving {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.client-server-grid.is-leaving .client-server {
|
||||
animation: client-server-leave 420ms calc(var(--server-index) * 90ms) cubic-bezier(0.4, 0, 1, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes client-server-leave {
|
||||
0% {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transform: translateY(0);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
filter: blur(3px);
|
||||
transform: translateY(8px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes client-server-enter {
|
||||
0% {
|
||||
opacity: 0;
|
||||
filter: blur(3px);
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.client-server:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
background: transparent;
|
||||
@@ -1209,7 +1447,7 @@ code, .mono {
|
||||
}
|
||||
|
||||
.client-subscription-edit button:focus-visible,
|
||||
.client-subscription-summary:focus-visible,
|
||||
.client-subscription-domain-button:focus-visible,
|
||||
.client-server:focus-visible,
|
||||
.client-power:focus-visible {
|
||||
outline: 2px solid var(--client-accent);
|
||||
@@ -1259,9 +1497,26 @@ code, .mono {
|
||||
transition: color 220ms ease, opacity 220ms ease, transform 220ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.client-copy-check {
|
||||
.client-copy-label {
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.client-proxies button.is-copied .client-copy-label {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.client-copy-feedback {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--client-accent);
|
||||
animation: client-copy-fade 800ms cubic-bezier(0.4, 0, 1, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes client-copy-fade {
|
||||
0%, 18% { opacity: 1; filter: drop-shadow(0 0 5px var(--client-accent)); }
|
||||
100% { opacity: 0; filter: drop-shadow(0 0 0 transparent); }
|
||||
}
|
||||
|
||||
.client-proxies button:hover {
|
||||
@@ -1320,13 +1575,26 @@ code, .mono {
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.client-power,
|
||||
.client-power::before,
|
||||
.client-power svg,
|
||||
.client-usage-bar i,
|
||||
.client-subscription-refresh,
|
||||
.client-usage,
|
||||
.client-usage > strong,
|
||||
.client-server,
|
||||
.client-proxies button,
|
||||
.client-copy-feedback,
|
||||
.client-subscription-edit,
|
||||
.client-subscription-edit::after,
|
||||
.client-subscription-summary,
|
||||
.client-subscription-summary strong {
|
||||
transition: none;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.client-state-copy h2,
|
||||
.client-state-detail > * {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,3 +34,31 @@ export function localProxyUrls(port = 8082) {
|
||||
http: `http://127.0.0.1:${port}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function subscriptionUsage(userInfo = {}) {
|
||||
const upload = Math.max(0, Number(userInfo.upload) || 0);
|
||||
const download = Math.max(0, Number(userInfo.download) || 0);
|
||||
const total = Math.max(0, Number(userInfo.total) || 0);
|
||||
const used = upload + download;
|
||||
|
||||
return {
|
||||
upload,
|
||||
download,
|
||||
total,
|
||||
used,
|
||||
percent: total ? Math.min(100, (used / total) * 100) : null,
|
||||
expiresAt: userInfo.expire ? new Date(Number(userInfo.expire) * 1000) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function subscriptionDaysLeft(expiresAt, now = Date.now()) {
|
||||
const days = Math.ceil((expiresAt?.getTime() - now) / 86_400_000);
|
||||
if (!Number.isFinite(days)) return '';
|
||||
if (days <= 0) return 'срок истёк';
|
||||
const mod10 = days % 10;
|
||||
const mod100 = days % 100;
|
||||
const unit = mod10 === 1 && mod100 !== 11
|
||||
? 'день'
|
||||
: mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14) ? 'дня' : 'дней';
|
||||
return `${days === 1 ? 'остался' : 'осталось'} ${days} ${unit}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user