Release 4.1.1+1: UI polish по чек-листу ревью — трафик «Доступно X ГБ» + процент использования (баг «Осталось 100 из 100 · 0%»), оффер «3 дня бесплатно, затем от 99 ₽/мес», бейдж «Рекомендуем» на карточке EvilFox + подпись «Сторонний сервис» у конкурентов, пилюля «АКТИВЕН» и протокол мелким текстом в списке серверов, «—» вместо пустого пинга, счётчик серверов только на «Мои серверы», 🦊 вместо буквы N (старый логотип Navis), флаги во всех строках, geoflag-алиасы опечаток («Румьиня» → RO), фикс [hidden] vs display:grid. Windows 4.1.1.1.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Navis
2026-08-01 21:39:37 +03:00
co-authored by Cursor
parent 329209fa88
commit 46e28aa73f
16 changed files with 176 additions and 72 deletions
+141 -48
View File
@@ -136,6 +136,8 @@
--log-bg: rgba(2, 6, 14, 0.6);
}
* { box-sizing: border-box; }
/* Атрибут hidden должен побеждать классовые display:grid/flex (иначе пустые карточки видны). */
[hidden] { display: none !important; }
html, body {
margin: 0;
height: 100%;
@@ -180,6 +182,7 @@
place-items: center;
}
.logo svg { width: 26px; height: 26px; }
.logo .fox { font-size: 1.35rem; line-height: 1; }
.brand {
font-family: Outfit, sans-serif;
font-size: 1.35rem;
@@ -529,6 +532,13 @@
text-transform: uppercase;
color: var(--muted);
}
.section-title .count-note {
font-size: .68rem;
font-weight: 600;
text-transform: none;
letter-spacing: 0;
opacity: .75;
}
label.field {
display: block;
@@ -839,22 +849,39 @@
text-overflow: ellipsis;
}
.server-row .right { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; flex: 0 0 auto; }
.server-row .proto {
font-size: .62rem;
.server-row .name-row { display: flex; align-items: center; gap: 6px; min-width: 0; }
/* Пилюля «АКТИВЕН» вместо строчного текста «активный сервер» */
.badge-active {
flex: 0 0 auto;
font-size: .58rem;
font-weight: 800;
letter-spacing: .04em;
text-transform: uppercase;
letter-spacing: .06em;
color: var(--accent-deep);
background: var(--accent-soft);
border: 1px solid rgba(8,145,178,.35);
border-radius: 999px;
padding: 2px 6px;
padding: 2px 7px;
line-height: 1.2;
}
html[data-theme="dark"] .badge-active { color: var(--accent); }
.server-row .ms {
font-size: .78rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: var(--muted);
}
.server-row .ms::before {
content: "";
display: inline-block;
width: 7px; height: 7px;
border-radius: 50%;
margin-right: 5px;
vertical-align: 1px;
background: var(--dot-off);
}
.server-row .ms.good::before, .server-row .ms.ok::before { background: var(--ms-good); }
.server-row .ms.mid::before { background: var(--ms-mid); }
.server-row .ms.bad::before { background: var(--danger); }
.server-row .ms.ok { color: var(--ok); }
.server-row .ms.good { color: var(--ms-good); }
.server-row .ms.mid { color: var(--ms-mid); }
@@ -1017,6 +1044,7 @@
transition: width .4s var(--ease);
}
.traffic-fill.warn { background: linear-gradient(90deg, #d97706, #f59e0b); }
.traffic-sub { font-size: .74rem; color: var(--muted); }
/* ==== Провайдеры (вкладка «Защита») ==== */
.prov-grid {
@@ -1050,9 +1078,10 @@
html[data-theme="dark"] .prov-card.ours {
background: linear-gradient(150deg, rgba(34,211,238,.16), var(--surface-2) 70%);
}
/* Бейдж «Рекомендуем» лежит НА карточке EvilFox (верхний угол), а не между карточками. */
.prov-badge {
position: absolute;
top: -9px;
top: 10px;
right: 10px;
font-size: .64rem;
font-weight: 800;
@@ -1064,6 +1093,16 @@
padding: 3px 9px;
box-shadow: 0 4px 10px rgba(251, 191, 36, .35);
}
.prov-card.ours .prov-name { padding-right: 96px; }
/* Приглушённая подпись «Сторонний сервис» на карточках конкурентов */
.prov-tag {
font-size: .62rem;
font-weight: 700;
letter-spacing: .05em;
text-transform: uppercase;
color: var(--muted);
opacity: .8;
}
.prov-gift { font-size: .72rem; font-weight: 700; color: var(--accent-deep); }
html[data-theme="dark"] .prov-gift { color: var(--accent); }
.prov-card .action { padding: 10px 12px; font-size: .84rem; }
@@ -1150,6 +1189,7 @@
}
.about-card .logo { margin: 0 auto 10px; width: 56px; height: 56px; border-radius: 16px; }
.about-card .logo svg { width: 34px; height: 34px; }
.about-card .logo .fox { font-size: 1.9rem; }
.about-card h2 { font-family: Outfit, sans-serif; margin: 0 0 4px; letter-spacing: -.03em; }
.about-card p { margin: 0; color: var(--muted); font-size: .84rem; line-height: 1.45; }
@@ -1191,14 +1231,9 @@
<!-- ===== Sidebar ===== -->
<nav class="side">
<div class="brand-row">
<div class="logo" aria-hidden="true">
<svg viewBox="0 0 32 32" fill="none">
<path d="M8 26V6h4.2L20 18.4V6H24v20h-4.2L12 13.6V26H8z" fill="#f4fff9"/>
<path d="M22.5 8.2l3.2-1.1-1.1 3.2-5.6 5.6 1.6 1.6 5.6-5.6 3.2-1.1-1.1 3.2" fill="#b8f0d8" opacity=".9"/>
</svg>
</div>
<div class="logo" aria-hidden="true"><span class="fox">🦊</span></div>
<h1 class="brand">EvilFox</h1>
<span class="badge-ver" id="badgeVer">4.1.0</span>
<span class="badge-ver" id="badgeVer">4.1.1</span>
</div>
<button class="nav-btn active" type="button" data-page="guard">
@@ -1262,8 +1297,8 @@
<span class="dot" id="dot"></span>
<span id="statusText">Отключено</span>
</div>
<!-- Протокол показываем на карточке активного сервера ниже — в статусной строке не дублируем. -->
<div class="chips-line">
<span class="proto-chip" id="protoChip">протокол</span>
<span class="mode-chip" id="vpnChip" hidden>VPN · весь трафик</span>
</div>
<p class="hero-hint" id="heroHint">Нажмите на кнопку, чтобы включить защиту</p>
@@ -1287,6 +1322,7 @@
<span class="pct" id="trafficPct"></span>
</div>
<div class="traffic-bar"><div class="traffic-fill" id="trafficFill"></div></div>
<div class="traffic-sub" id="trafficExp" hidden></div>
</section>
<div class="section">
@@ -1296,25 +1332,28 @@
<span class="prov-badge">⭐ Рекомендуем</span>
<div class="prov-name">🦊 EvilFox VPN</div>
<div class="prov-sub">Оптимальные серверы рядом с вами · защита без настройки</div>
<span class="prov-gift">🎁 Демо-доступ от 30₽</span>
<span class="prov-gift" id="provGift">🚀 3 дня бесплатно. Затем — от 99 ₽/мес</span>
<button class="action primary" id="provConnectBtn" type="button">🔥 Подключиться</button>
<button class="prov-ping" id="provPingOurs" type="button">📊 <span class="val">Проверить скорость</span></button>
</div>
<div class="prov-card">
<span class="prov-tag">Сторонний сервис</span>
<div class="prov-name">NordVPN</div>
<div class="prov-sub">Сторонний сервис · оплата и настройка на сайте провайдера</div>
<div class="prov-sub">Оплата и настройка на сайте провайдера</div>
<button class="prov-go" type="button" data-url="https://nordvpn.com/" data-name="NordVPN">Перейти на сайт</button>
<button class="prov-ping" type="button" data-base="45">📊 <span class="val">Проверить скорость</span></button>
</div>
<div class="prov-card">
<span class="prov-tag">Сторонний сервис</span>
<div class="prov-name">ExpressVPN</div>
<div class="prov-sub">Сторонний сервис · оплата и настройка на сайте провайдера</div>
<div class="prov-sub">Оплата и настройка на сайте провайдера</div>
<button class="prov-go" type="button" data-url="https://expressvpn.com/" data-name="ExpressVPN">Перейти на сайт</button>
<button class="prov-ping" type="button" data-base="38">📊 <span class="val">Проверить скорость</span></button>
</div>
<div class="prov-card">
<span class="prov-tag">Сторонний сервис</span>
<div class="prov-name">Surfshark</div>
<div class="prov-sub">Сторонний сервис · оплата и настройка на сайте провайдера</div>
<div class="prov-sub">Оплата и настройка на сайте провайдера</div>
<button class="prov-go" type="button" data-url="https://surfshark.com/" data-name="Surfshark">Перейти на сайт</button>
<button class="prov-ping" type="button" data-base="55">📊 <span class="val">Проверить скорость</span></button>
</div>
@@ -1335,7 +1374,7 @@
</section>
<div class="server-toolbar">
<div class="section-title"><span>Серверы</span></div>
<div class="section-title"><span>Серверы</span> <span class="count-note" id="srvCount"></span></div>
<div class="server-actions">
<button class="mini" id="pingBtn" type="button">Пинг</button>
<button class="mini accent" id="bestBtn" type="button">Лучший</button>
@@ -1507,6 +1546,7 @@
<span class="pct" id="trafficPct2"></span>
</div>
<div class="traffic-bar"><div class="traffic-fill" id="trafficFill2"></div></div>
<div class="traffic-sub" id="trafficExp2" hidden></div>
</section>
<div class="section-title" style="margin-top:4px"><span>Журнал подключения</span></div>
@@ -1536,12 +1576,7 @@
</details>
<div class="about-card">
<div class="logo" aria-hidden="true">
<svg viewBox="0 0 32 32" fill="none">
<path d="M8 26V6h4.2L20 18.4V6H24v20h-4.2L12 13.6V26H8z" fill="#f4fff9"/>
<path d="M22.5 8.2l3.2-1.1-1.1 3.2-5.6 5.6 1.6 1.6 5.6-5.6 3.2-1.1-1.1 3.2" fill="#b8f0d8" opacity=".9"/>
</svg>
</div>
<div class="logo" aria-hidden="true"><span class="fox">🦊</span></div>
<h2>EvilFox</h2>
<p id="aboutVer">версия…</p>
<p style="margin-top:6px">VPN/прокси-клиент: Naive · Hysteria 2 · AWG · Xray</p>
@@ -1551,7 +1586,7 @@
</div>
<section class="shop" aria-label="Купить ключ">
<h3>Нужен ключ?</h3>
<p>Ключи и тарифы на evilfox.win — демо от 30₽. Техподдержка — в Telegram.</p>
<p>Ключи и тарифы на evilfox.win — 3 дня бесплатно, затем от 99 ₽/мес. Техподдержка — в Telegram.</p>
<button class="action primary" id="shopBtn" type="button">Открыть магазин</button>
<button class="shop-link" id="shopLink" type="button">https://evilfox.win/</button>
</section>
@@ -1676,6 +1711,18 @@
const c = String(cc || "").toUpperCase();
return COUNTRY_RU[c] || "";
}
// "RO" → "🇷🇴" — запасной вариант, если бэкенд не прислал флаг.
function isoFlag(cc) {
const c = String(cc || "").toUpperCase();
if (!/^[A-Z]{2}$/.test(c)) return "";
return String.fromCodePoint(0x1f1e6 + c.charCodeAt(0) - 65, 0x1f1e6 + c.charCodeAt(1) - 65);
}
function pluralRu(n, one, few, many) {
const m10 = n % 10, m100 = n % 100;
if (m10 === 1 && m100 !== 11) return one;
if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return few;
return many;
}
// ==== Navigation ====
document.querySelectorAll(".nav-btn").forEach((b) => {
@@ -1748,6 +1795,8 @@
const protoChip = $("protoChip");
const heroHint = $("heroHint");
const SHOP_URL = "https://evilfox.win/";
// Текст оффера — правьте здесь (показывается на карточке EvilFox на вкладке «Защита»).
const OFFER_TEXT = "🚀 3 дня бесплатно. Затем — от 99 ₽/мес";
// Список рекомендуемых сервисов — замените ссылки/названия здесь.
// Хост каждой ссылки должен быть разрешён в allowlist openURL
// (internal/apphost/app.go → allowedLinkHosts), иначе ссылка не откроется.
@@ -1758,6 +1807,8 @@
{ icon: "🦊", name: "Git", url: "https://git.de4ima.uk/Evilfox/navi" }
];
const AUTO_BEST_KEY = "navis.autoBest";
// Оффер на карточке EvilFox рендерим из константы, чтобы правка была в одном месте.
if ($("provGift")) $("provGift").textContent = OFFER_TEXT;
try {
const saved = localStorage.getItem(AUTO_BEST_KEY);
@@ -1940,7 +1991,7 @@
$("curPing").className = "ms";
return;
}
$("curFlag").textContent = p.flag || (p.country || "·");
$("curFlag").textContent = p.flag || isoFlag(p.country) || "·";
$("curName").textContent = friendlyTitle(p.name);
$("curHost").textContent = "нажмите, чтобы выбрать другой сервер";
$("curProto").textContent = (p.protocol || "?").toString();
@@ -1949,13 +2000,16 @@
if (pr && pr.ok) {
el.className = "ms " + msClass(pr.ms, true);
el.textContent = pr.ms + " ms";
el.title = "";
} else if (pr && pr.error) {
el.className = "ms bad";
el.textContent = "—";
el.title = pr.error;
} else {
// Пинг ещё не измерялся — показываем «—», а не пустоту.
el.className = "ms";
el.textContent = "";
el.textContent = "";
el.title = "пинг не измерен";
}
}
@@ -1963,6 +2017,13 @@
const active = activeName || profile.value;
serverList.innerHTML = "";
const list = orderedProfiles();
// Количество серверов — только здесь, мелкой приглушённой подписью.
const srvCount = $("srvCount");
if (srvCount) {
srvCount.textContent = list.length
? "· " + list.length + " " + pluralRu(list.length, "сервер", "сервера", "серверов")
: "";
}
if (!list.length) {
const empty = document.createElement("div");
empty.className = "server-empty";
@@ -1971,35 +2032,44 @@
return;
}
list.forEach((p) => {
const isActive = p.name === active;
const row = document.createElement("button");
row.type = "button";
row.className = "server-row" + (p.name === active ? " active" : "");
row.className = "server-row" + (isActive ? " active" : "");
row.dataset.name = p.name;
const left = document.createElement("div");
left.className = "left";
const flagEmoji = p.flag || isoFlag(p.country);
const flag = document.createElement("span");
flag.className = "flag" + (p.flag ? "" : " unknown");
flag.textContent = p.flag || (p.country || "·");
flag.className = "flag" + (flagEmoji ? "" : " unknown");
flag.textContent = flagEmoji || "·";
flag.title = p.country || "регион неизвестен";
const metaCol = document.createElement("div");
metaCol.className = "meta-col";
const nameRow = document.createElement("div");
nameRow.className = "name-row";
const nameEl = document.createElement("div");
nameEl.className = "name";
nameEl.textContent = friendlyTitle(p.name);
nameRow.appendChild(nameEl);
if (isActive) {
const badge = document.createElement("span");
badge.className = "badge-active";
badge.textContent = "АКТИВЕН";
nameRow.appendChild(badge);
}
// Протокол в списке — мелкой приглушённой подписью (чип есть на карточке активного сервера).
const sub = document.createElement("div");
sub.className = "sub";
sub.textContent = p.name === active ? "активный сервер" : "защищённое соединение";
metaCol.appendChild(nameEl);
sub.textContent = (p.protocol || "").toString() || "защищённое соединение";
metaCol.appendChild(nameRow);
metaCol.appendChild(sub);
left.appendChild(flag);
left.appendChild(metaCol);
const right = document.createElement("div");
right.className = "right";
const proto = document.createElement("span");
proto.className = "proto";
proto.textContent = (p.protocol || "?").toString();
const ms = document.createElement("span");
const pr = pingMap[p.name];
if (pr && pr.ok) {
@@ -2011,9 +2081,9 @@
ms.title = pr.error;
} else {
ms.className = "ms";
ms.textContent = "";
ms.textContent = "";
ms.title = "пинг не измерен";
}
right.appendChild(proto);
right.appendChild(ms);
row.appendChild(left);
@@ -2079,22 +2149,27 @@
if (vpnChip) vpnChip.hidden = !state.vpn_active;
}
// Прогресс-бар трафика на «Защите» и «Статистике» (📊 Осталось X из Y ГБ).
// Прогресс-бар трафика на «Защите» и «Статистике».
// Заголовок — «📊 Доступно X ГБ», полоса и подпись — процент ИЗРАСХОДОВАННОГО
// («Z% использовано»), чтобы цифры и полоса были в одной системе координат.
function renderTraffic(info) {
const hasTraffic = !!(info && (info.total || info.download || info.upload));
[["trafficCard", "trafficLabel", "trafficPct", "trafficFill"],
["trafficCard2", "trafficLabel2", "trafficPct2", "trafficFill2"]].forEach((ids) => {
[["trafficCard", "trafficLabel", "trafficPct", "trafficFill", "trafficExp"],
["trafficCard2", "trafficLabel2", "trafficPct2", "trafficFill2", "trafficExp2"]].forEach((ids) => {
const card = $(ids[0]);
if (!card) return;
if (!hasTraffic) { card.hidden = true; return; }
card.hidden = false;
const used = (info.upload || 0) + (info.download || 0);
const label = $(ids[1]), pct = $(ids[2]), fill = $(ids[3]);
const label = $(ids[1]), pct = $(ids[2]), fill = $(ids[3]), exp = $(ids[4]);
if (info.total > 0) {
const left = Math.max(0, info.total - used);
const usedPct = Math.min(100, Math.round(used / info.total * 100));
label.textContent = "Осталось " + fmtTraffic(left) + " из " + fmtTraffic(info.total);
pct.textContent = usedPct + "%";
// «из Y ГБ» показываем только когда лимит уже начали расходовать.
label.textContent = used > 0
? "Доступно " + fmtTraffic(left) + " из " + fmtTraffic(info.total)
: "Доступно " + fmtTraffic(left);
pct.textContent = usedPct + "% использовано";
fill.style.width = usedPct + "%";
fill.classList.toggle("warn", usedPct >= 85);
} else {
@@ -2103,6 +2178,21 @@
fill.style.width = "100%";
fill.classList.remove("warn");
}
if (exp) {
let expText = "";
if (info.expire) {
const daysLeft = Math.floor((info.expire * 1000 - Date.now()) / 86400000);
if (daysLeft === 0) {
expText = "🔥 Сгорает сегодня";
} else if (daysLeft > 0) {
expText = "🔥 Сгорает через " + daysLeft + " " + pluralRu(daysLeft, "день", "дня", "дней");
} else {
expText = "Срок доступа истёк";
}
}
exp.hidden = !expText;
exp.textContent = expText;
}
});
}
@@ -2240,8 +2330,11 @@
const active = (state.profiles || []).find((p) => p.name === profile.value) || {};
const label = detectProtoLabel(state, active);
protoChip.textContent = label;
protoChip.classList.toggle("active", !!state.connected || label !== "протокол");
// Чип протокола в hero убран (дублировал карточку активного сервера) — guard на случай возврата.
if (protoChip) {
protoChip.textContent = label;
protoChip.classList.toggle("active", !!state.connected || label !== "протокол");
}
const hy2 = (label === "hysteria2" || label === "hy2");
if ($("hy2Box")) $("hy2Box").style.display = hy2 ? "" : "none";
@@ -2269,8 +2362,8 @@
detail = "Сначала установите cores в Настройках («Сервис»)";
heroHint.textContent = "Нужны cores для выбранного протокола";
} else {
const n = (state.profiles || []).length;
detail = n > 1 ? ("Серверов: " + n + " · «Мои серверы» → Лучший") : "Готово к подключению";
// Количество серверов на главном экране не показываем — оно есть на «Мои серверы».
detail = "Готово к подключению";
heroHint.textContent = currentMode === "vpn"
? "Режим VPN: защита всего трафика, включая игры и приложения"
: "Нажмите на кнопку, чтобы включить защиту";
+3
View File
@@ -140,6 +140,9 @@ var countryNames = []nameCode{
{"исландия", "IS"}, {"люксембург", "LU"}, {"англия", "GB"},
{"сша", "US"}, {"америка", "US"},
// Частые опечатки в именах серверов на панелях (наблюдались в проде).
{"румьиня", "RO"}, {"румыня", "RO"}, {"руминия", "RO"},
// City → country hints (EN + RU).
{"frankfurt", "DE"}, {"amsterdam", "NL"}, {"london", "GB"},
{"paris", "FR"}, {"helsinki", "FI"}, {"stockholm", "SE"},
+3
View File
@@ -25,6 +25,9 @@ func TestFromText(t *testing.T) {
{"🇨🇿 Czech Republic Vless", "CZ"},
{"🇳🇴 Norway TR+WS", "NO"},
{"🇩🇪 Germany 3 HY", "DE"},
// Panel-side typos seen in production.
{"Румьиня", "RO"},
{"Румыня-2", "RO"},
}
for _, c := range cases {
cc, emoji := FromText(c.in)
+1 -1
View File
@@ -18,7 +18,7 @@ import (
// CurrentVersion is the product/semver used for update eligibility (feed "version").
// Keep major.minor.patch only — no build suffix here.
const CurrentVersion = "4.1.0"
const CurrentVersion = "4.1.1"
// BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part,
// macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build.