Files
navi/internal/appui/index.html
T

626 lines
22 KiB
HTML
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.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Navis</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@600;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<style>
:root {
--bg0: #dff3ea;
--bg1: #f3faf7;
--ink: #10241c;
--muted: #567064;
--line: rgba(16, 36, 28, 0.12);
--accent: #0c7a55;
--accent-2: #10956a;
--danger: #b42318;
--surface: rgba(255, 255, 255, 0.78);
--shadow: 0 22px 60px rgba(16, 36, 28, 0.10);
}
* { box-sizing: border-box; }
html, body {
margin: 0; min-height: 100%;
font-family: Manrope, sans-serif;
color: var(--ink);
background:
radial-gradient(900px 520px at 0% 0%, #bfe8d4 0%, transparent 55%),
radial-gradient(700px 420px at 100% 10%, #c9dcf0 0%, transparent 50%),
linear-gradient(160deg, var(--bg0), var(--bg1));
}
body { display: grid; place-items: center; padding: 22px 14px; }
.shell {
width: min(460px, 100%);
background: var(--surface);
backdrop-filter: blur(16px);
border: 1px solid var(--line);
box-shadow: var(--shadow);
border-radius: 28px;
padding: 24px 22px 20px;
animation: rise .5s cubic-bezier(.2,.8,.2,1) both;
}
@keyframes rise {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: none; }
}
.top {
display: flex; align-items: center; gap: 14px; margin-bottom: 18px;
}
.logo {
width: 48px; height: 48px; border-radius: 14px;
background: linear-gradient(145deg, #0c7a55, #1aa876);
display: grid; place-items: center;
box-shadow: 0 10px 24px rgba(12, 122, 85, 0.28);
flex: 0 0 auto;
}
.logo svg { width: 26px; height: 26px; }
.brand-wrap { min-width: 0; }
.brand {
font-family: Syne, sans-serif;
font-size: 1.85rem; font-weight: 800;
letter-spacing: -0.04em; line-height: 1; margin: 0;
}
.tagline { margin: 4px 0 0; color: var(--muted); font-size: .86rem; }
.status {
display: flex; align-items: center; gap: 10px;
margin-bottom: 16px; font-weight: 600;
}
.dot {
width: 10px; height: 10px; border-radius: 50%; background: #9aa89f;
}
.dot.on {
background: var(--accent);
animation: pulse 1.8s ease-out infinite;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(12,122,85,.35); }
70% { box-shadow: 0 0 0 10px rgba(12,122,85,0); }
100% { box-shadow: 0 0 0 0 rgba(12,122,85,0); }
}
label.field {
display: block; font-size: .72rem; font-weight: 700;
letter-spacing: .06em; text-transform: uppercase;
color: var(--muted); margin: 0 0 8px;
}
.profile-bar {
display: grid; grid-template-columns: 1fr auto auto; gap: 8px;
margin-bottom: 12px;
}
select, input[type="text"] {
width: 100%; border: 1px solid var(--line);
background: rgba(255,255,255,.92);
border-radius: 14px; padding: 12px 14px;
font: inherit; color: var(--ink); outline: none;
transition: border-color .15s, box-shadow .15s;
}
select:focus, input[type="text"]:focus {
border-color: rgba(12,122,85,.55);
box-shadow: 0 0 0 4px rgba(12,122,85,.12);
}
.icon-btn {
width: 46px; padding: 0; display: grid; place-items: center;
border-radius: 14px; border: 1px solid var(--line);
background: rgba(255,255,255,.7); color: var(--ink);
font-size: 1.2rem; font-weight: 700; cursor: pointer;
}
.icon-btn:hover { background: #fff; }
.icon-btn.danger { color: var(--danger); }
.stack { display: grid; gap: 10px; margin-bottom: 14px; }
.row {
display: flex; align-items: center; justify-content: space-between;
gap: 12px; padding: 12px 14px;
border: 1px solid var(--line); border-radius: 14px;
background: rgba(255,255,255,.55); margin-bottom: 16px;
}
.switch { position: relative; width: 44px; height: 26px; flex: 0 0 auto; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider {
position: absolute; inset: 0; background: #c5d2cb;
border-radius: 999px; cursor: pointer; transition: background .15s;
}
.slider::before {
content: ""; position: absolute; width: 20px; height: 20px;
left: 3px; top: 3px; background: #fff; border-radius: 50%;
transition: transform .15s;
}
.switch input:checked + .slider { background: var(--accent); }
.switch input:checked + .slider::before { transform: translateX(18px); }
.actions { display: grid; gap: 8px; }
button.action {
appearance: none; border: 0; border-radius: 14px;
padding: 13px 16px; font: inherit; font-weight: 700; cursor: pointer;
transition: transform .12s, background .15s, opacity .15s;
}
button.action:active { transform: translateY(1px); }
button.action:disabled { opacity: .55; cursor: not-allowed; transform: none; }
.primary { background: var(--accent); color: #f4fff9; }
.primary:hover:not(:disabled) { background: var(--accent-2); }
.primary.danger { background: var(--danger); }
.ghost {
background: transparent; color: var(--muted);
border: 1px solid var(--line);
}
.ghost:hover:not(:disabled) { background: rgba(255,255,255,.75); color: var(--ink); }
.meta {
margin-top: 14px; font-size: .82rem; color: var(--muted);
line-height: 1.45; min-height: 2.6em;
}
.shop {
margin-top: 16px;
padding: 14px 14px 12px;
border-radius: 18px;
border: 1px solid rgba(12, 122, 85, 0.18);
background:
linear-gradient(135deg, rgba(12, 122, 85, 0.10), rgba(255,255,255,0.55));
}
.shop h3 {
font-family: Syne, sans-serif;
font-size: 1.05rem;
letter-spacing: -0.02em;
margin: 0 0 6px;
}
.shop p {
margin: 0 0 12px;
color: var(--muted);
font-size: .84rem;
line-height: 1.45;
}
.shop .action {
width: 100%;
}
.shop-link {
display: block;
margin-top: 8px;
text-align: center;
font-size: .78rem;
color: var(--accent);
text-decoration: none;
font-weight: 600;
}
.shop-link:hover { text-decoration: underline; }
.tools {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 10px;
}
.ping-list {
margin-top: 10px;
display: grid;
gap: 6px;
}
.ping-item {
display: flex;
justify-content: space-between;
gap: 10px;
padding: 8px 12px;
border-radius: 12px;
border: 1px solid var(--line);
background: rgba(255,255,255,.55);
font-size: .84rem;
}
.ping-item .ms { font-weight: 700; }
.ping-item .ms.ok { color: var(--accent); }
.ping-item .ms.bad { color: var(--danger); }
.update-banner {
display: none;
margin-bottom: 14px;
padding: 12px 14px;
border-radius: 16px;
border: 1px solid rgba(12,122,85,.25);
background: linear-gradient(135deg, rgba(12,122,85,.14), rgba(255,255,255,.7));
}
.update-banner.show { display: block; }
.update-banner strong {
font-family: Syne, sans-serif;
display: block;
margin-bottom: 4px;
}
.update-banner p {
margin: 0 0 10px;
color: var(--muted);
font-size: .84rem;
}
.ver {
margin-top: 10px;
font-size: .75rem;
color: var(--muted);
text-align: center;
}
.modal-backdrop {
position: fixed; inset: 0; background: rgba(16,36,28,.35);
display: none; place-items: center; padding: 18px; z-index: 20;
}
.modal-backdrop.open { display: grid; }
.modal {
width: min(400px, 100%); background: #f7fcf9;
border-radius: 22px; border: 1px solid var(--line);
box-shadow: var(--shadow); padding: 20px;
animation: rise .35s ease both;
}
.modal h2 {
font-family: Syne, sans-serif; margin: 0 0 14px;
font-size: 1.25rem; letter-spacing: -.02em;
}
.modal .actions { margin-top: 14px; grid-template-columns: 1fr 1fr; }
</style>
</head>
<body>
<main class="shell">
<div class="update-banner" id="updateBanner">
<strong id="updateTitle">Доступно обновление</strong>
<p id="updateNotes"></p>
<button class="action primary" id="updateBtn" type="button">Обновить сейчас</button>
</div>
<div class="top">
<div class="logo" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none">
<path d="M12 3l6.5 17H16l-2.2-5.8H10.2L8 20H5.5L12 3z" fill="#f4fff9"/>
<circle cx="12" cy="14.2" r="2.2" fill="#0c7a55"/>
</svg>
</div>
<div class="brand-wrap">
<h1 class="brand">Navis</h1>
<p class="tagline">NaiveProxy · Hysteria 2 · несколько серверов</p>
</div>
</div>
<div class="status">
<span class="dot" id="dot"></span>
<span id="statusText">Отключено</span>
</div>
<label class="field" for="profile">Профиль</label>
<div class="profile-bar">
<select id="profile"></select>
<button class="icon-btn" id="addBtn" type="button" title="Новый профиль">+</button>
<button class="icon-btn danger" id="delBtn" type="button" title="Удалить профиль">×</button>
</div>
<div class="stack">
<div>
<label class="field" for="name">Название</label>
<input id="name" type="text" placeholder="Например: DE-1 / Home" spellcheck="false" />
</div>
<div>
<label class="field" for="proxy">Ссылка сервера</label>
<input id="proxy" type="text" placeholder="naive+https://… или hysteria2://pass@host:443/" spellcheck="false" />
</div>
</div>
<div class="row">
<span>Системный прокси Windows</span>
<label class="switch">
<input id="sysproxy" type="checkbox" checked />
<span class="slider"></span>
</label>
</div>
<div class="actions">
<button class="action primary" id="toggleBtn" type="button">Подключить</button>
<button class="action ghost" id="saveBtn" type="button">Сохранить профиль</button>
<div class="tools">
<button class="action ghost" id="pingBtn" type="button">Пинг серверов</button>
<button class="action ghost" id="updCheckBtn" type="button">Проверить обновление</button>
</div>
<button class="action ghost" id="coreBtn" type="button">Установить cores (naive + hysteria2)</button>
</div>
<div class="ping-list" id="pingList"></div>
<p class="meta" id="meta">Загрузка…</p>
<section class="shop" aria-label="Купить подключение">
<h3>Безопасное подключение</h3>
<p>На любом устройстве — защита данных, стабильность и приватность. Демо-ключ от 30₽.</p>
<button class="action primary" id="shopBtn" type="button">Купить на evilfox.win</button>
<a class="shop-link" id="shopLink" href="https://evilfox.win/" rel="noopener">https://evilfox.win/</a>
</section>
<p class="ver" id="verLabel">Navis</p>
</main>
<div class="modal-backdrop" id="modal">
<div class="modal">
<h2>Новый профиль</h2>
<div class="stack">
<div>
<label class="field" for="newName">Название</label>
<input id="newName" type="text" placeholder="Server EU" />
</div>
<div>
<label class="field" for="newProxy">Ссылка</label>
<input id="newProxy" type="text" placeholder="hysteria2://pass@host:443/ или naive+https://…" />
</div>
</div>
<div class="actions">
<button class="action ghost" id="cancelNew" type="button">Отмена</button>
<button class="action primary" id="createNew" type="button">Создать</button>
</div>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
const meta = $("meta");
const btn = $("toggleBtn");
const coreBtn = $("coreBtn");
const saveBtn = $("saveBtn");
const addBtn = $("addBtn");
const delBtn = $("delBtn");
const proxy = $("proxy");
const nameInput = $("name");
const sysproxy = $("sysproxy");
const profile = $("profile");
const dot = $("dot");
const statusText = $("statusText");
const modal = $("modal");
const shopBtn = $("shopBtn");
const shopLink = $("shopLink");
const pingBtn = $("pingBtn");
const updCheckBtn = $("updCheckBtn");
const updateBanner = $("updateBanner");
const updateBtn = $("updateBtn");
const pingList = $("pingList");
const verLabel = $("verLabel");
const SHOP_URL = "https://evilfox.win/";
let busy = false;
let connected = false;
let formHydrated = false;
let dirty = false;
let profiles = [];
function markDirty() { dirty = true; }
[proxy, nameInput, sysproxy].forEach((el) => {
el.addEventListener("input", markDirty);
el.addEventListener("change", markDirty);
el.addEventListener("focus", markDirty);
});
function setMeta(text, kind) {
meta.textContent = text || "";
meta.style.color = kind === "err" ? "var(--danger)" : kind === "ok" ? "var(--accent)" : "var(--muted)";
}
function fillProfiles(list, active) {
profiles = list || [];
const cur = profile.value;
profile.innerHTML = "";
profiles.forEach((p) => {
const opt = document.createElement("option");
opt.value = p.name;
opt.textContent = (p.protocol ? ("[" + p.protocol + "] ") : "") + (p.host ? (p.name + " · " + p.host) : p.name);
profile.appendChild(opt);
});
const want = active || cur || (profiles[0] && profiles[0].name);
if (want) profile.value = want;
}
function renderPings(pings) {
pingList.innerHTML = "";
(pings || []).forEach((p) => {
const row = document.createElement("div");
row.className = "ping-item";
const left = document.createElement("span");
left.textContent = p.host ? (p.name + " · " + p.host) : p.name;
const right = document.createElement("span");
right.className = "ms " + (p.ok ? "ok" : "bad");
right.textContent = p.ok ? (p.ms + " ms") : (p.error || "ошибка");
row.appendChild(left);
row.appendChild(right);
pingList.appendChild(row);
});
}
function renderUpdate(u, version) {
verLabel.textContent = "Navis v" + (version || "?");
if (!u) {
updateBanner.classList.remove("show");
return;
}
if (u.available) {
updateBanner.classList.add("show");
$("updateTitle").textContent = "Обновление " + u.latest;
$("updateNotes").textContent = u.notes || ("Текущая версия " + u.current);
} else {
updateBanner.classList.remove("show");
}
}
function paint(state, opts) {
const syncForm = opts && opts.syncForm;
connected = !!state.connected;
dot.classList.toggle("on", connected);
statusText.textContent = connected
? ("Подключено" + (state.profile ? " · " + state.profile : ""))
: "Отключено";
btn.textContent = connected ? "Отключить" : "Подключить";
btn.classList.toggle("danger", connected);
const lock = connected || busy;
proxy.disabled = lock;
nameInput.disabled = lock;
sysproxy.disabled = lock;
profile.disabled = lock;
addBtn.disabled = lock;
delBtn.disabled = lock || ((state.profiles || profiles).length <= 1);
saveBtn.disabled = lock;
coreBtn.disabled = busy;
pingBtn.disabled = busy;
updCheckBtn.disabled = busy;
updateBtn.disabled = busy;
btn.disabled = busy;
fillProfiles(state.profiles || [], state.active_profile || state.profile);
renderPings(state.pings || []);
renderUpdate(state.update, state.version);
if (syncForm || (!formHydrated && !dirty)) {
const active = (state.profiles || []).find((p) => p.name === profile.value) || {};
nameInput.value = active.name || state.active_profile || "";
proxy.value = typeof state.proxy === "string" ? state.proxy : (active.proxy || "");
if (typeof state.system_proxy === "boolean") sysproxy.checked = state.system_proxy;
formHydrated = true;
if (syncForm) dirty = false;
}
let detail = "";
if (connected) {
const parts = [];
if (state.http_proxy) parts.push("HTTP " + state.http_proxy);
if (state.socks_proxy) parts.push("SOCKS " + state.socks_proxy);
detail = parts.join(" · ");
} else if (state.core_ready === false) {
detail = "Сначала установите cores (naive / hysteria2)";
} else {
detail = "Вставьте ссылку naive или hysteria2://";
}
if (!busy) setMeta(detail, state.core_ready === false ? "err" : "");
}
async function refresh(opts) {
const state = await getState();
paint(state, opts);
return state;
}
async function withBusy(fn) {
if (busy) return;
busy = true;
[btn, coreBtn, saveBtn, addBtn, delBtn, profile, pingBtn, updCheckBtn, updateBtn].forEach((b) => { if (b) b.disabled = true; });
try {
await fn();
} finally {
busy = false;
try {
await refresh({ syncForm: true });
} catch (e) {
setMeta(String(e), "err");
[btn, coreBtn, saveBtn, addBtn, delBtn, profile, pingBtn, updCheckBtn, updateBtn].forEach((b) => { if (b) b.disabled = false; });
}
}
}
profile.addEventListener("change", () => withBusy(async () => {
await selectProfile(profile.value);
dirty = false;
formHydrated = false;
}));
btn.addEventListener("click", () => withBusy(async () => {
try {
if (connected) {
setMeta("Отключение…");
await disconnect();
setMeta("Отключено", "ok");
} else {
setMeta("Подключение…");
await saveProfile(nameInput.value.trim() || profile.value, proxy.value.trim(), !!sysproxy.checked);
await connect();
setMeta("Туннель активен", "ok");
}
} catch (e) { setMeta(String(e), "err"); }
}));
saveBtn.addEventListener("click", () => withBusy(async () => {
try {
await saveProfile(nameInput.value.trim() || profile.value, proxy.value.trim(), !!sysproxy.checked);
setMeta("Профиль сохранён", "ok");
} catch (e) { setMeta(String(e), "err"); }
}));
coreBtn.addEventListener("click", () => withBusy(async () => {
try {
setMeta("Скачивание official cores…");
const path = await installCore();
setMeta("Cores: " + path, "ok");
} catch (e) { setMeta(String(e), "err"); }
}));
delBtn.addEventListener("click", () => withBusy(async () => {
try {
if (!confirm("Удалить профиль «" + profile.value + "»?")) return;
await deleteProfile(profile.value);
formHydrated = false;
dirty = false;
setMeta("Профиль удалён", "ok");
} catch (e) { setMeta(String(e), "err"); }
}));
addBtn.addEventListener("click", () => {
$("newName").value = "";
$("newProxy").value = "";
modal.classList.add("open");
$("newName").focus();
});
$("cancelNew").addEventListener("click", () => modal.classList.remove("open"));
$("createNew").addEventListener("click", () => withBusy(async () => {
try {
const n = $("newName").value.trim();
const p = $("newProxy").value.trim();
if (!n) throw "Укажите название профиля";
await createProfile(n, p, !!sysproxy.checked);
modal.classList.remove("open");
formHydrated = false;
dirty = false;
setMeta("Профиль создан", "ok");
} catch (e) { setMeta(String(e), "err"); }
}));
async function openShop(e) {
if (e) e.preventDefault();
try {
await openURL(SHOP_URL);
} catch (err) {
setMeta(String(err), "err");
}
}
shopBtn.addEventListener("click", openShop);
shopLink.addEventListener("click", openShop);
pingBtn.addEventListener("click", () => withBusy(async () => {
try {
setMeta("Проверка серверов…");
const rows = await pingServers();
renderPings(rows);
const ok = (rows || []).filter((r) => r.ok).length;
setMeta("Пинг: " + ok + "/" + (rows || []).length + " доступны", ok ? "ok" : "err");
} catch (e) { setMeta(String(e), "err"); }
}));
updCheckBtn.addEventListener("click", () => withBusy(async () => {
try {
setMeta("Проверка обновлений…");
const st = await checkUpdate();
renderUpdate(st, st.current);
if (st.available) setMeta("Доступна версия " + st.latest, "ok");
else if (st.error) setMeta("Обновление: " + st.error, "err");
else setMeta("У вас актуальная версия " + st.current, "ok");
} catch (e) { setMeta(String(e), "err"); }
}));
updateBtn.addEventListener("click", () => withBusy(async () => {
try {
setMeta("Скачивание обновления…");
await applyUpdate();
setMeta("Обновление установлено, перезапуск…", "ok");
} catch (e) { setMeta(String(e), "err"); }
}));
refresh({ syncForm: true }).catch((e) => setMeta(String(e), "err"));
setInterval(() => { if (!busy && !modal.classList.contains("open")) refresh().catch(() => {}); }, 2500);
</script>
</body>
</html>