925 lines
29 KiB
HTML
925 lines
29 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>VPN Proxy Control</title>
|
||
<style>
|
||
:root {
|
||
--bg-primary: #0a0a0f;
|
||
--bg-secondary: #12121a;
|
||
--bg-glass: rgba(255, 255, 255, 0.03);
|
||
--border-color: rgba(255, 255, 255, 0.08);
|
||
--text-primary: #e8e8ec;
|
||
--text-secondary: #8b8b9e;
|
||
--accent: #6366f1;
|
||
--accent-glow: rgba(99, 102, 241, 0.4);
|
||
--success: #22c55e;
|
||
--error: #ef4444;
|
||
--warning: #f59e0b;
|
||
}
|
||
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
background: var(--bg-primary);
|
||
color: var(--text-primary);
|
||
min-height: 100vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 2rem;
|
||
background-image:
|
||
radial-gradient(ellipse at top, rgba(99, 102, 241, 0.1) 0%, transparent 50%),
|
||
radial-gradient(ellipse at bottom right, rgba(168, 85, 247, 0.08) 0%, transparent 50%);
|
||
}
|
||
|
||
.container {
|
||
width: 100%;
|
||
max-width: 520px;
|
||
}
|
||
|
||
.card {
|
||
background: var(--bg-glass);
|
||
backdrop-filter: blur(20px);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 24px;
|
||
padding: 2.5rem;
|
||
box-shadow:
|
||
0 4px 6px -1px rgba(0, 0, 0, 0.3),
|
||
0 2px 4px -2px rgba(0, 0, 0, 0.2),
|
||
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||
}
|
||
|
||
.header {
|
||
text-align: center;
|
||
margin-bottom: 2rem;
|
||
}
|
||
|
||
.logo {
|
||
width: 64px;
|
||
height: 64px;
|
||
background: linear-gradient(135deg, var(--accent), #a855f7);
|
||
border-radius: 16px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin: 0 auto 1.25rem;
|
||
font-size: 1.75rem;
|
||
box-shadow: 0 8px 32px var(--accent-glow);
|
||
}
|
||
|
||
h1 {
|
||
font-size: 1.5rem;
|
||
font-weight: 600;
|
||
letter-spacing: -0.02em;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.subtitle {
|
||
color: var(--text-secondary);
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.status-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
padding: 1rem 1.25rem;
|
||
background: var(--bg-secondary);
|
||
border-radius: 12px;
|
||
margin-bottom: 1.5rem;
|
||
border: 1px solid var(--border-color);
|
||
}
|
||
|
||
.status-indicator {
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 50%;
|
||
background: var(--text-secondary);
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.status-indicator.active {
|
||
background: var(--success);
|
||
box-shadow: 0 0 12px rgba(34, 197, 94, 0.5);
|
||
animation: pulse 2s infinite;
|
||
}
|
||
|
||
@keyframes pulse {
|
||
|
||
0%,
|
||
100% {
|
||
opacity: 1;
|
||
}
|
||
|
||
50% {
|
||
opacity: 0.6;
|
||
}
|
||
}
|
||
|
||
.status-text {
|
||
flex: 1;
|
||
}
|
||
|
||
.status-label {
|
||
font-size: 0.75rem;
|
||
color: var(--text-secondary);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
margin-bottom: 0.125rem;
|
||
}
|
||
|
||
.status-value {
|
||
font-size: 0.9rem;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.form-group {
|
||
margin-bottom: 1.25rem;
|
||
}
|
||
|
||
label {
|
||
display: block;
|
||
font-size: 0.875rem;
|
||
font-weight: 500;
|
||
margin-bottom: 0.5rem;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.input-wrapper {
|
||
position: relative;
|
||
}
|
||
|
||
input[type="text"] {
|
||
width: 100%;
|
||
padding: 1rem 1.25rem;
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 12px;
|
||
color: var(--text-primary);
|
||
font-size: 0.9rem;
|
||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
input[type="text"]::placeholder {
|
||
color: var(--text-secondary);
|
||
opacity: 0.6;
|
||
}
|
||
|
||
input[type="text"]:focus {
|
||
outline: none;
|
||
border-color: var(--accent);
|
||
box-shadow: 0 0 0 3px var(--accent-glow);
|
||
}
|
||
|
||
.btn {
|
||
width: 100%;
|
||
padding: 1rem;
|
||
border: none;
|
||
border-radius: 12px;
|
||
font-size: 0.95rem;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.btn-primary {
|
||
background: linear-gradient(135deg, var(--accent), #8b5cf6);
|
||
color: white;
|
||
box-shadow: 0 4px 20px var(--accent-glow);
|
||
}
|
||
|
||
.btn-primary:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 8px 30px var(--accent-glow);
|
||
}
|
||
|
||
.btn-primary:active {
|
||
transform: translateY(0);
|
||
}
|
||
|
||
.btn-primary:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
transform: none;
|
||
}
|
||
|
||
.btn-icon {
|
||
font-size: 1.1rem;
|
||
}
|
||
|
||
.spinner {
|
||
width: 18px;
|
||
height: 18px;
|
||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||
border-top-color: white;
|
||
border-radius: 50%;
|
||
animation: spin 0.8s linear infinite;
|
||
}
|
||
|
||
@keyframes spin {
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
.message {
|
||
margin-top: 1.25rem;
|
||
padding: 1rem;
|
||
border-radius: 12px;
|
||
font-size: 0.875rem;
|
||
display: none;
|
||
align-items: flex-start;
|
||
gap: 0.75rem;
|
||
animation: slideIn 0.3s ease;
|
||
}
|
||
|
||
@keyframes slideIn {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(-8px);
|
||
}
|
||
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
}
|
||
|
||
.message.show {
|
||
display: flex;
|
||
}
|
||
|
||
.message.success {
|
||
background: rgba(34, 197, 94, 0.1);
|
||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||
color: var(--success);
|
||
}
|
||
|
||
.message.error {
|
||
background: rgba(239, 68, 68, 0.1);
|
||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||
color: var(--error);
|
||
}
|
||
|
||
.message-icon {
|
||
font-size: 1.25rem;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.footer {
|
||
text-align: center;
|
||
margin-top: 1.5rem;
|
||
color: var(--text-secondary);
|
||
font-size: 0.8rem;
|
||
}
|
||
|
||
.footer a {
|
||
color: var(--accent);
|
||
text-decoration: none;
|
||
}
|
||
|
||
.footer a:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.hint {
|
||
margin-top: 0.5rem;
|
||
font-size: 0.75rem;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.connection-info {
|
||
margin-top: 2rem;
|
||
padding-top: 1.5rem;
|
||
border-top: 1px solid var(--border-color);
|
||
}
|
||
|
||
.connection-info h3 {
|
||
font-size: 0.9rem;
|
||
color: var(--text-secondary);
|
||
margin-bottom: 1rem;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.info-item {
|
||
background: var(--bg-secondary);
|
||
padding: 0.75rem 1rem;
|
||
border-radius: 12px;
|
||
margin-bottom: 0.75rem;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.info-item .label {
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.info-item .value {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
color: var(--accent);
|
||
background: rgba(99, 102, 241, 0.1);
|
||
padding: 0.25rem 0.5rem;
|
||
border-radius: 6px;
|
||
user-select: all;
|
||
cursor: pointer;
|
||
}
|
||
|
||
/* Tabs */
|
||
.tabs {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
margin-bottom: 1.5rem;
|
||
}
|
||
|
||
.tab {
|
||
flex: 1;
|
||
padding: 0.75rem 1rem;
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 10px;
|
||
color: var(--text-secondary);
|
||
font-size: 0.85rem;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
text-align: center;
|
||
}
|
||
|
||
.tab:hover {
|
||
border-color: var(--accent);
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.tab.active {
|
||
background: var(--accent);
|
||
border-color: var(--accent);
|
||
color: white;
|
||
box-shadow: 0 4px 12px var(--accent-glow);
|
||
}
|
||
|
||
.tab-content {
|
||
display: none;
|
||
}
|
||
|
||
.tab-content.active {
|
||
display: block;
|
||
}
|
||
|
||
/* Server List */
|
||
.server-list {
|
||
max-height: 300px;
|
||
overflow-y: auto;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.server-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
padding: 0.875rem 1rem;
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 10px;
|
||
margin-bottom: 0.5rem;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.server-item:hover {
|
||
border-color: var(--accent);
|
||
background: rgba(99, 102, 241, 0.05);
|
||
}
|
||
|
||
.server-item.selected {
|
||
border-color: var(--accent);
|
||
background: rgba(99, 102, 241, 0.1);
|
||
box-shadow: 0 0 0 2px var(--accent-glow);
|
||
}
|
||
|
||
.server-radio {
|
||
width: 18px;
|
||
height: 18px;
|
||
border: 2px solid var(--border-color);
|
||
border-radius: 50%;
|
||
position: relative;
|
||
flex-shrink: 0;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.server-item.selected .server-radio {
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
.server-item.selected .server-radio::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
width: 8px;
|
||
height: 8px;
|
||
background: var(--accent);
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.server-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.server-name {
|
||
font-weight: 500;
|
||
font-size: 0.9rem;
|
||
margin-bottom: 0.25rem;
|
||
}
|
||
|
||
.server-details {
|
||
font-size: 0.75rem;
|
||
color: var(--text-secondary);
|
||
font-family: 'JetBrains Mono', monospace;
|
||
}
|
||
|
||
.server-type {
|
||
font-size: 0.7rem;
|
||
padding: 0.2rem 0.5rem;
|
||
background: rgba(99, 102, 241, 0.15);
|
||
color: var(--accent);
|
||
border-radius: 4px;
|
||
text-transform: uppercase;
|
||
font-weight: 600;
|
||
letter-spacing: 0.03em;
|
||
}
|
||
|
||
.btn-secondary {
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border-color);
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.btn-secondary:hover {
|
||
border-color: var(--accent);
|
||
background: rgba(99, 102, 241, 0.1);
|
||
}
|
||
|
||
.btn-group {
|
||
display: flex;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.btn-group .btn {
|
||
flex: 1;
|
||
}
|
||
|
||
.empty-state {
|
||
text-align: center;
|
||
padding: 2rem;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.empty-state-icon {
|
||
font-size: 2.5rem;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
|
||
/* Responsive */
|
||
@media (max-width: 480px) {
|
||
body {
|
||
padding: 1rem;
|
||
}
|
||
|
||
.card {
|
||
padding: 1.5rem;
|
||
border-radius: 20px;
|
||
}
|
||
|
||
h1 {
|
||
font-size: 1.25rem;
|
||
}
|
||
|
||
.tabs {
|
||
flex-direction: column;
|
||
gap: 0.25rem;
|
||
}
|
||
|
||
.btn-group {
|
||
flex-direction: column;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
|
||
<body>
|
||
<div class="container">
|
||
<div class="card">
|
||
<div class="header">
|
||
<div class="logo">🔐</div>
|
||
<h1>VPN Proxy Control</h1>
|
||
<p class="subtitle">Управление подключением sing-box</p>
|
||
</div>
|
||
|
||
<div class="status-bar" id="statusBar">
|
||
<div class="status-indicator" id="statusIndicator"></div>
|
||
<div class="status-text">
|
||
<div class="status-label">Статус</div>
|
||
<div class="status-value" id="statusValue">Загрузка...</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tabs -->
|
||
<div class="tabs">
|
||
<button class="tab active" data-tab="subscription">📡 Подписка</button>
|
||
<button class="tab" data-tab="vless">🔑 VLESS Ключ</button>
|
||
</div>
|
||
|
||
<!-- Subscription Tab Content -->
|
||
<div id="subscription-tab" class="tab-content active">
|
||
<div class="form-group">
|
||
<label for="subUrlInput">URL подписки</label>
|
||
<div class="input-wrapper">
|
||
<input type="text" id="subUrlInput" placeholder="https://..." autocomplete="off"
|
||
spellcheck="false">
|
||
</div>
|
||
<p class="hint">Вставьте ссылку подписки (sing-box формат)</p>
|
||
</div>
|
||
|
||
<button type="button" class="btn btn-secondary" id="fetchServersBtn" style="margin-bottom: 1rem;">
|
||
<span class="btn-icon">🔄</span>
|
||
<span id="fetchBtnText">Загрузить серверы</span>
|
||
</button>
|
||
|
||
<div id="serverListContainer" style="display: none;">
|
||
<label style="margin-bottom: 0.75rem; display: block;">Выберите сервер</label>
|
||
<div class="server-list" id="serverList"></div>
|
||
|
||
<button type="button" class="btn btn-primary" id="applySubBtn">
|
||
<span class="btn-icon">⚡</span>
|
||
<span id="applySubBtnText">Применить</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div id="emptyServers" class="empty-state" style="display: none;">
|
||
<div class="empty-state-icon">📭</div>
|
||
<p>Серверы не загружены</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- VLESS Tab Content -->
|
||
<div id="vless-tab" class="tab-content">
|
||
<form id="proxyForm">
|
||
<div class="form-group">
|
||
<label for="urlInput">VLESS Key</label>
|
||
<div class="input-wrapper">
|
||
<input type="text" id="urlInput" placeholder="vless://..." autocomplete="off"
|
||
spellcheck="false">
|
||
</div>
|
||
<p class="hint">Вставьте VLESS ссылку</p>
|
||
</div>
|
||
|
||
<button type="submit" class="btn btn-primary" id="submitBtn">
|
||
<span class="btn-icon">⚡</span>
|
||
<span id="btnText">Применить</span>
|
||
</button>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="message" id="message">
|
||
<span class="message-icon" id="messageIcon"></span>
|
||
<span id="messageText"></span>
|
||
</div>
|
||
|
||
<div class="connection-info">
|
||
<h3>Данные для подключения</h3>
|
||
<div class="info-item">
|
||
<span class="label">HTTP / HTTPS</span>
|
||
<code class="value" id="httpLink" title="Нажмите, чтобы скопировать">...</code>
|
||
</div>
|
||
<div class="info-item">
|
||
<span class="label">SOCKS5</span>
|
||
<code class="value" id="socksLink" title="Нажмите, чтобы скопировать">...</code>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="footer">
|
||
Proxy работает на порту <strong>8082</strong> •
|
||
<a href="https://github.com/SagerNet/sing-box" target="_blank">sing-box</a>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// DOM Elements
|
||
const form = document.getElementById('proxyForm');
|
||
const urlInput = document.getElementById('urlInput');
|
||
const submitBtn = document.getElementById('submitBtn');
|
||
const btnText = document.getElementById('btnText');
|
||
const message = document.getElementById('message');
|
||
const messageIcon = document.getElementById('messageIcon');
|
||
const messageText = document.getElementById('messageText');
|
||
const statusIndicator = document.getElementById('statusIndicator');
|
||
const statusValue = document.getElementById('statusValue');
|
||
|
||
// Subscription elements
|
||
const subUrlInput = document.getElementById('subUrlInput');
|
||
const fetchServersBtn = document.getElementById('fetchServersBtn');
|
||
const fetchBtnText = document.getElementById('fetchBtnText');
|
||
const serverListContainer = document.getElementById('serverListContainer');
|
||
const serverList = document.getElementById('serverList');
|
||
const emptyServers = document.getElementById('emptyServers');
|
||
const applySubBtn = document.getElementById('applySubBtn');
|
||
const applySubBtnText = document.getElementById('applySubBtnText');
|
||
|
||
// State
|
||
let subscriptionConfig = null;
|
||
let selectedServer = null;
|
||
let subscriptionUrl = null;
|
||
|
||
// Tab switching
|
||
document.querySelectorAll('.tab').forEach(tab => {
|
||
tab.addEventListener('click', () => {
|
||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||
|
||
tab.classList.add('active');
|
||
const targetTab = tab.dataset.tab;
|
||
document.getElementById(`${targetTab}-tab`).classList.add('active');
|
||
|
||
// Hide message when switching tabs
|
||
message.className = 'message';
|
||
});
|
||
});
|
||
|
||
// Fetch initial status
|
||
async function fetchStatus() {
|
||
try {
|
||
const res = await fetch('/status');
|
||
const data = await res.json();
|
||
|
||
if (data.active) {
|
||
statusIndicator.classList.add('active');
|
||
statusValue.textContent = data.tag
|
||
? `${data.tag} (${data.server})`
|
||
: 'Активен';
|
||
} else {
|
||
statusIndicator.classList.remove('active');
|
||
statusValue.textContent = 'Не настроен';
|
||
}
|
||
} catch (e) {
|
||
statusValue.textContent = 'Ошибка загрузки';
|
||
}
|
||
}
|
||
|
||
function showMessage(type, text) {
|
||
message.className = `message show ${type}`;
|
||
messageIcon.textContent = type === 'success' ? '✓' : '✕';
|
||
messageText.textContent = text;
|
||
}
|
||
|
||
function setLoading(btn, textEl, loading, defaultText = 'Применить') {
|
||
btn.disabled = loading;
|
||
if (loading) {
|
||
textEl.innerHTML = '<div class="spinner"></div>';
|
||
} else {
|
||
textEl.textContent = defaultText;
|
||
}
|
||
}
|
||
|
||
// VLESS form submit
|
||
form.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
|
||
const url = urlInput.value.trim();
|
||
if (!url) {
|
||
showMessage('error', 'Введите URL');
|
||
return;
|
||
}
|
||
|
||
message.className = 'message';
|
||
setLoading(submitBtn, btnText, true);
|
||
|
||
try {
|
||
const res = await fetch('/apply', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ url })
|
||
});
|
||
|
||
const data = await res.json();
|
||
|
||
if (data.success) {
|
||
showMessage('success', data.message || 'Конфигурация применена!');
|
||
urlInput.value = '';
|
||
fetchStatus();
|
||
} else {
|
||
showMessage('error', data.error || 'Произошла ошибка');
|
||
}
|
||
} catch (e) {
|
||
showMessage('error', `Ошибка сети: ${e.message}`);
|
||
} finally {
|
||
setLoading(submitBtn, btnText, false);
|
||
}
|
||
});
|
||
|
||
// Fetch servers from subscription
|
||
fetchServersBtn.addEventListener('click', async () => {
|
||
const url = subUrlInput.value.trim();
|
||
if (!url) {
|
||
showMessage('error', 'Введите URL подписки');
|
||
return;
|
||
}
|
||
|
||
message.className = 'message';
|
||
setLoading(fetchServersBtn, fetchBtnText, true, 'Загрузить серверы');
|
||
|
||
try {
|
||
const res = await fetch('/fetch-subscription', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ url })
|
||
});
|
||
|
||
const data = await res.json();
|
||
|
||
if (data.success && data.servers && data.servers.length > 0) {
|
||
subscriptionConfig = data.config;
|
||
subscriptionUrl = url; // Save URL for persistence
|
||
renderServerList(data.servers);
|
||
serverListContainer.style.display = 'block';
|
||
emptyServers.style.display = 'none';
|
||
showMessage('success', `Найдено ${data.servers.length} сервер(ов)`);
|
||
} else {
|
||
serverListContainer.style.display = 'none';
|
||
emptyServers.style.display = 'block';
|
||
showMessage('error', data.error || 'Серверы не найдены');
|
||
}
|
||
} catch (e) {
|
||
showMessage('error', `Ошибка сети: ${e.message}`);
|
||
} finally {
|
||
setLoading(fetchServersBtn, fetchBtnText, false, 'Загрузить серверы');
|
||
}
|
||
});
|
||
|
||
// Render server list
|
||
function renderServerList(servers, savedServerTag = null) {
|
||
serverList.innerHTML = '';
|
||
selectedServer = null;
|
||
|
||
servers.forEach((server, index) => {
|
||
const item = document.createElement('div');
|
||
item.className = 'server-item';
|
||
item.dataset.tag = server.tag;
|
||
|
||
item.innerHTML = `
|
||
<div class="server-radio"></div>
|
||
<div class="server-info">
|
||
<div class="server-name">${escapeHtml(server.tag)}</div>
|
||
<div class="server-details">${escapeHtml(server.server)}:${server.port}</div>
|
||
</div>
|
||
<span class="server-type">${server.type}</span>
|
||
`;
|
||
|
||
item.addEventListener('click', () => {
|
||
document.querySelectorAll('.server-item').forEach(i => i.classList.remove('selected'));
|
||
item.classList.add('selected');
|
||
selectedServer = server.tag;
|
||
});
|
||
|
||
// Select saved server or first by default
|
||
const shouldSelect = savedServerTag
|
||
? server.tag === savedServerTag
|
||
: index === 0;
|
||
|
||
if (shouldSelect) {
|
||
item.classList.add('selected');
|
||
selectedServer = server.tag;
|
||
}
|
||
|
||
serverList.appendChild(item);
|
||
});
|
||
}
|
||
|
||
// Escape HTML to prevent XSS
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
// Apply subscription with selected server
|
||
applySubBtn.addEventListener('click', async () => {
|
||
if (!subscriptionConfig) {
|
||
showMessage('error', 'Сначала загрузите серверы');
|
||
return;
|
||
}
|
||
|
||
if (!selectedServer) {
|
||
showMessage('error', 'Выберите сервер');
|
||
return;
|
||
}
|
||
|
||
message.className = 'message';
|
||
setLoading(applySubBtn, applySubBtnText, true);
|
||
|
||
try {
|
||
const res = await fetch('/apply-subscription', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
config: subscriptionConfig,
|
||
selectedServer: selectedServer,
|
||
subUrl: subscriptionUrl
|
||
})
|
||
});
|
||
|
||
const data = await res.json();
|
||
|
||
if (data.success) {
|
||
showMessage('success', data.message || 'Конфигурация применена!');
|
||
fetchStatus();
|
||
} else {
|
||
showMessage('error', data.error || 'Произошла ошибка');
|
||
}
|
||
} catch (e) {
|
||
showMessage('error', `Ошибка сети: ${e.message}`);
|
||
} finally {
|
||
setLoading(applySubBtn, applySubBtnText, false);
|
||
}
|
||
});
|
||
|
||
// Load saved subscription
|
||
async function loadSavedSubscription() {
|
||
try {
|
||
const res = await fetch('/subscription');
|
||
const data = await res.json();
|
||
|
||
if (data.saved && data.url) {
|
||
subUrlInput.value = data.url;
|
||
subscriptionUrl = data.url;
|
||
|
||
// Auto-load servers from saved subscription
|
||
setLoading(fetchServersBtn, fetchBtnText, true, 'Загрузить серверы');
|
||
try {
|
||
const subRes = await fetch('/fetch-subscription', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ url: data.url })
|
||
});
|
||
const subData = await subRes.json();
|
||
|
||
if (subData.success && subData.servers && subData.servers.length > 0) {
|
||
subscriptionConfig = subData.config;
|
||
renderServerList(subData.servers, data.selectedServer);
|
||
serverListContainer.style.display = 'block';
|
||
emptyServers.style.display = 'none';
|
||
}
|
||
} finally {
|
||
setLoading(fetchServersBtn, fetchBtnText, false, 'Загрузить серверы');
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.log('No saved subscription found');
|
||
}
|
||
}
|
||
|
||
// Initial load
|
||
fetchStatus();
|
||
loadSavedSubscription();
|
||
// Refresh status every 30 seconds
|
||
setInterval(fetchStatus, 30000);
|
||
|
||
// Update connection links
|
||
const hostname = window.location.hostname;
|
||
const httpLink = document.getElementById('httpLink');
|
||
const socksLink = document.getElementById('socksLink');
|
||
|
||
httpLink.textContent = `http://${hostname}:8082`;
|
||
socksLink.textContent = `socks5://${hostname}:8082`;
|
||
|
||
// Copy to clipboard on click
|
||
[httpLink, socksLink].forEach(el => {
|
||
el.addEventListener('click', () => {
|
||
navigator.clipboard.writeText(el.textContent);
|
||
const original = el.style.color;
|
||
el.style.color = 'var(--success)';
|
||
setTimeout(() => el.style.color = original, 500);
|
||
});
|
||
});
|
||
</script>
|
||
</body>
|
||
|
||
</html> |