Добавление и Удаление сайта

Backend (Go):
- Добавлен полный функционал создания сайтов
- Добавлен функционал удаления сайтов
- Новые API методы в admin.go:
- Добавлен шаблон стартовой страницы
- Добавлена функция DecodeBase64

Исправления критических ошибок:
- Исправлена работа wildcard алиасов (*.domain.com) в handler.go
- Исправлены ошибки "файл не найден" при создании файлов

Frontend (JavaScript + HTML + CSS):
- Добавлена страница создания сайта
- Добавлена кнопка "Удалить сайт" в редактировании
- Мелкие доработки стилей

Build:
- Обновлён build_admin.ps1 - добавлен шаг генерации биндингов (wails generate module)

Fixes:
- #fix Wildcard алиасы (*.domain.com) теперь работают корректно
- #fix Удалён порт из host при проверке алиасов
- #fix Приоритет точных доменов над wildcard
- #fix Ошибки "файл не найден" при создании сайтов/vAccess
- #fix Секция добавления сайта теперь скрывается при навигации
This commit is contained in:
2025-11-14 14:18:26 +07:00
parent 0ed6a6007d
commit 4b13923375
22 changed files with 1823 additions and 57 deletions

View File

@@ -62,6 +62,19 @@
}
}
/* Delete Button Variant */
.delete-btn {
background: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.3);
color: var(--accent-red);
&:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.25);
border-color: rgba(239, 68, 68, 0.5);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.2);
}
}
/* Icon Button - Квадратная кнопка с иконкой */
.icon-btn {
width: 32px;

View File

@@ -91,11 +91,15 @@
.modal-footer {
display: flex;
justify-content: flex-end;
gap: var(--space-lg);
gap: 8px;
padding: 20px var(--space-lg);
border-top: 1px solid var(--glass-border);
}
.modal-footer .action-btn {
margin: 0;
}
/* Notification Modal */
.notification {
position: fixed;

View File

@@ -48,7 +48,7 @@
/* Footer */
.footer {
margin-top: var(--space-3xl);
margin-top: var(--space-lg);
padding: 20px;
text-align: center;
color: var(--text-muted);

View File

@@ -23,4 +23,5 @@
/* 4. Pages */
@import 'pages/dashboard.css';
@import 'pages/vaccess.css';
@import 'pages/site-creator.css';

View File

@@ -0,0 +1,272 @@
/* ============================================
Site Creator Page
Страница создания нового сайта
============================================ */
/* Form Section */
.form-section {
padding: var(--space-xl);
background: rgba(139, 92, 246, 0.02);
border-radius: var(--radius-xl);
border: 1px solid var(--glass-border);
transition: all var(--transition-base);
}
.form-section:hover {
background: rgba(139, 92, 246, 0.04);
border-color: rgba(139, 92, 246, 0.2);
}
/* Subsection Title (внутри формы) */
.form-subsection-title {
font-size: var(--text-lg);
font-weight: var(--font-semibold);
color: var(--text-primary);
margin: 0 0 var(--space-lg) 0;
display: flex;
align-items: center;
gap: var(--space-md);
padding-bottom: var(--space-sm);
border-bottom: 1px solid rgba(139, 92, 246, 0.1);
i {
color: var(--accent-purple-light);
font-size: 16px;
}
}
/* Второй и последующие подзаголовки - добавляем отступ сверху */
.form-subsection-title:not(:first-of-type) {
margin-top: var(--space-xl);
}
/* Custom Select Styling */
.form-input[type="text"],
.form-input[type="number"] {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-image: none;
}
/* Кастомный Select */
.custom-select {
position: relative;
width: 100%;
}
.custom-select-trigger {
padding: 10px 40px 10px 14px;
background: var(--glass-bg-dark);
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: var(--text-base);
cursor: pointer;
transition: all var(--transition-base);
display: flex;
align-items: center;
justify-content: space-between;
user-select: none;
}
.custom-select-trigger:hover {
border-color: rgba(139, 92, 246, 0.4);
background-color: rgba(139, 92, 246, 0.05);
}
.custom-select.open .custom-select-trigger {
border-color: rgba(139, 92, 246, 0.6);
box-shadow: 0 0 16px rgba(139, 92, 246, 0.2);
background-color: rgba(139, 92, 246, 0.03);
}
.custom-select-value {
flex: 1;
}
.custom-select-arrow {
color: var(--accent-purple-light);
font-size: 12px;
transition: transform var(--transition-base);
}
.custom-select.open .custom-select-arrow {
transform: rotate(180deg);
}
/* Dropdown */
.custom-select-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: #1a1d2e;
border: 1px solid rgba(139, 92, 246, 0.3);
border-radius: var(--radius-md);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
max-height: 0;
overflow: hidden;
opacity: 0;
transform: translateY(-10px);
transition: all var(--transition-base);
z-index: 1000;
}
.custom-select.open .custom-select-dropdown {
max-height: 300px;
overflow-y: auto;
opacity: 1;
transform: translateY(0);
}
/* Scrollbar для dropdown */
.custom-select-dropdown::-webkit-scrollbar {
width: 6px;
}
.custom-select-dropdown::-webkit-scrollbar-track {
background: transparent;
}
.custom-select-dropdown::-webkit-scrollbar-thumb {
background: rgba(139, 92, 246, 0.3);
border-radius: 3px;
}
.custom-select-dropdown::-webkit-scrollbar-thumb:hover {
background: rgba(139, 92, 246, 0.5);
}
/* Option */
.custom-select-option {
padding: 10px 14px;
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition-base);
font-size: var(--text-base);
}
.custom-select-option:hover {
background: rgba(139, 92, 246, 0.1);
color: var(--text-primary);
}
.custom-select-option.selected {
background: rgba(139, 92, 246, 0.2);
color: var(--accent-purple-light);
font-weight: var(--font-semibold);
}
.custom-select-option.selected::before {
content: '✓ ';
margin-right: 8px;
}
/* File Upload Styling */
.file-upload-wrapper {
position: relative;
display: inline-block;
width: 100%;
}
.file-input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
.file-upload-btn {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-md);
padding: 12px 20px;
background: var(--glass-bg-dark);
border: 2px dashed var(--glass-border);
border-radius: var(--radius-md);
color: var(--text-secondary);
font-size: var(--text-base);
font-weight: var(--font-medium);
cursor: pointer;
transition: all var(--transition-base);
text-align: center;
i {
color: var(--accent-purple-light);
font-size: 18px;
}
&:hover {
background: rgba(139, 92, 246, 0.05);
border-color: rgba(139, 92, 246, 0.4);
color: var(--text-primary);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
}
.file-input:focus + .file-upload-btn {
border-color: rgba(139, 92, 246, 0.6);
box-shadow: 0 0 16px rgba(139, 92, 246, 0.2);
}
/* Drag over state */
.file-upload-wrapper.drag-over .file-upload-btn {
background: rgba(139, 92, 246, 0.15);
border-color: rgba(139, 92, 246, 0.7);
border-style: solid;
color: var(--text-primary);
transform: scale(1.02);
box-shadow: 0 0 20px rgba(139, 92, 246, 0.3);
}
.file-upload-wrapper.drag-over .file-upload-btn i {
animation: bounce 0.6s ease infinite;
}
@keyframes bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-4px);
}
}
/* File uploaded state */
.file-uploaded {
border-style: solid;
border-color: rgba(16, 185, 129, 0.5);
background: rgba(16, 185, 129, 0.05);
&:hover {
border-color: rgba(16, 185, 129, 0.6);
background: rgba(16, 185, 129, 0.1);
}
i {
color: var(--accent-green);
}
}
/* File status */
#certFileStatus,
#keyFileStatus,
#caFileStatus {
font-size: var(--text-sm);
font-weight: var(--font-medium);
display: flex;
align-items: center;
gap: var(--space-sm);
i {
font-size: 14px;
}
}

View File

@@ -136,6 +136,58 @@ class WailsAPI {
log(`Ошибка открытия папки: ${error.message}`, 'error');
}
}
/**
* Создать новый сайт
*/
async createNewSite(siteJSON) {
if (!this.checkAvailability()) return 'Error: API недоступен';
try {
return await window.go.admin.App.CreateNewSite(siteJSON);
} catch (error) {
log(`Ошибка создания сайта: ${error.message}`, 'error');
return `Error: ${error.message}`;
}
}
/**
* Загрузить сертификат для сайта
*/
async uploadCertificate(host, certType, certDataBase64) {
if (!this.checkAvailability()) return 'Error: API недоступен';
try {
return await window.go.admin.App.UploadCertificate(host, certType, certDataBase64);
} catch (error) {
log(`Ошибка загрузки сертификата: ${error.message}`, 'error');
return `Error: ${error.message}`;
}
}
/**
* Перезагрузить SSL сертификаты
*/
async reloadSSLCertificates() {
if (!this.checkAvailability()) return 'Error: API недоступен';
try {
return await window.go.admin.App.ReloadSSLCertificates();
} catch (error) {
log(`Ошибка перезагрузки сертификатов: ${error.message}`, 'error');
return `Error: ${error.message}`;
}
}
/**
* Удалить сайт
*/
async deleteSite(host) {
if (!this.checkAvailability()) return 'Error: API недоступен';
try {
return await window.go.admin.App.DeleteSite(host);
} catch (error) {
log(`Ошибка удаления сайта: ${error.message}`, 'error');
return `Error: ${error.message}`;
}
}
}
// Экспортируем единственный экземпляр

View File

@@ -0,0 +1,393 @@
/* ============================================
Site Creator Component
Управление созданием новых сайтов
============================================ */
import { api } from '../api/wails.js';
import { configAPI } from '../api/config.js';
import { $, hide, show } from '../utils/dom.js';
import { notification } from '../ui/notification.js';
import { isWailsAvailable } from '../utils/helpers.js';
import { initCustomSelects } from '../ui/custom-select.js';
/**
* Класс для создания новых сайтов
*/
export class SiteCreator {
constructor() {
this.aliases = [];
this.certificates = {
certificate: null,
privatekey: null,
cabundle: null
};
}
/**
* Открыть страницу создания сайта
*/
open() {
// Скрываем все секции
this.hideAllSections();
// Показываем страницу создания
show($('sectionAddSite'));
// Очищаем форму
this.resetForm();
// Привязываем обработчики
this.attachEventListeners();
// Инициализируем кастомные select'ы
setTimeout(() => initCustomSelects(), 100);
}
/**
* Скрыть все секции
*/
hideAllSections() {
hide($('sectionServices'));
hide($('sectionSites'));
hide($('sectionProxy'));
hide($('sectionSettings'));
hide($('sectionVAccessEditor'));
hide($('sectionAddSite'));
}
/**
* Вернуться на главную
*/
backToMain() {
this.hideAllSections();
show($('sectionServices'));
show($('sectionSites'));
show($('sectionProxy'));
}
/**
* Очистить форму
*/
resetForm() {
$('newSiteName').value = '';
$('newSiteHost').value = '';
$('newSiteAliasInput').value = '';
$('newSiteRootFile').value = 'index.html';
$('newSiteStatus').value = 'active';
$('newSiteRouting').checked = true;
$('certMode').value = 'none';
this.aliases = [];
this.certificates = {
certificate: null,
privatekey: null,
cabundle: null
};
// Скрываем блок загрузки сертификатов
hide($('certUploadBlock'));
// Очищаем статусы файлов
$('certFileStatus').innerHTML = '';
$('keyFileStatus').innerHTML = '';
$('caFileStatus').innerHTML = '';
// Очищаем labels файлов
if ($('certFileName')) $('certFileName').textContent = 'Выберите файл...';
if ($('keyFileName')) $('keyFileName').textContent = 'Выберите файл...';
if ($('caFileName')) $('caFileName').textContent = 'Выберите файл...';
// Очищаем input файлов
if ($('certFile')) $('certFile').value = '';
if ($('keyFile')) $('keyFile').value = '';
if ($('caFile')) $('caFile').value = '';
// Убираем класс uploaded
const labels = document.querySelectorAll('.file-upload-btn');
labels.forEach(label => label.classList.remove('file-uploaded'));
}
/**
* Привязать обработчики событий
*/
attachEventListeners() {
const createBtn = $('createSiteBtn');
if (createBtn) {
createBtn.onclick = async () => await this.createSite();
}
// Drag & Drop для файлов сертификатов
this.setupDragAndDrop();
}
/**
* Настроить Drag & Drop для файлов
*/
setupDragAndDrop() {
const fileWrappers = [
{ wrapper: document.querySelector('label[for="certFile"]')?.parentElement, input: $('certFile'), type: 'certificate' },
{ wrapper: document.querySelector('label[for="keyFile"]')?.parentElement, input: $('keyFile'), type: 'privatekey' },
{ wrapper: document.querySelector('label[for="caFile"]')?.parentElement, input: $('caFile'), type: 'cabundle' }
];
fileWrappers.forEach(({ wrapper, input, type }) => {
if (!wrapper || !input) return;
// Предотвращаем стандартное поведение
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
wrapper.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
});
});
// Подсветка при наведении файла
['dragenter', 'dragover'].forEach(eventName => {
wrapper.addEventListener(eventName, () => {
wrapper.classList.add('drag-over');
});
});
['dragleave', 'drop'].forEach(eventName => {
wrapper.addEventListener(eventName, () => {
wrapper.classList.remove('drag-over');
});
});
// Обработка dropped файла
wrapper.addEventListener('drop', (e) => {
const files = e.dataTransfer.files;
if (files.length > 0) {
// Создаём объект DataTransfer и присваиваем файлы input'у
const dataTransfer = new DataTransfer();
dataTransfer.items.add(files[0]);
input.files = dataTransfer.files;
// Триггерим событие change
const event = new Event('change', { bubbles: true });
input.dispatchEvent(event);
}
});
});
}
/**
* Парсить aliases из строки (через запятую)
*/
parseAliases() {
const input = $('newSiteAliasInput');
const value = input?.value.trim();
if (!value) {
this.aliases = [];
return;
}
// Разделяем по запятой и очищаем
this.aliases = value
.split(',')
.map(alias => alias.trim())
.filter(alias => alias.length > 0);
}
/**
* Переключить видимость блока загрузки сертификатов
*/
toggleCertUpload() {
const mode = $('certMode')?.value;
const block = $('certUploadBlock');
if (mode === 'upload') {
show(block);
} else {
hide(block);
}
}
/**
* Обработать выбор файла сертификата
*/
handleCertFile(input, certType) {
const file = input.files[0];
const statusId = certType === 'certificate' ? 'certFileStatus' :
certType === 'privatekey' ? 'keyFileStatus' : 'caFileStatus';
const labelId = certType === 'certificate' ? 'certFileName' :
certType === 'privatekey' ? 'keyFileName' : 'caFileName';
const statusDiv = $(statusId);
const labelSpan = $(labelId);
const labelBtn = input.nextElementSibling; // label элемент
if (!file) {
this.certificates[certType] = null;
statusDiv.innerHTML = '';
if (labelSpan) labelSpan.textContent = 'Выберите файл...';
if (labelBtn) labelBtn.classList.remove('file-uploaded');
return;
}
// Проверяем размер файла (макс 1MB)
if (file.size > 1024 * 1024) {
statusDiv.innerHTML = '<span style="color: #e74c3c;"><i class="fas fa-times-circle"></i> Файл слишком большой (макс 1MB)</span>';
this.certificates[certType] = null;
input.value = '';
if (labelSpan) labelSpan.textContent = 'Выберите файл...';
if (labelBtn) labelBtn.classList.remove('file-uploaded');
return;
}
// Обновляем UI
if (labelSpan) labelSpan.textContent = file.name;
if (labelBtn) labelBtn.classList.add('file-uploaded');
// Читаем файл
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target.result;
// Сохраняем как base64
this.certificates[certType] = btoa(content);
statusDiv.innerHTML = `<span style="color: #2ecc71;"><i class="fas fa-check-circle"></i> Загружен успешно</span>`;
};
reader.onerror = () => {
statusDiv.innerHTML = '<span style="color: #e74c3c;"><i class="fas fa-times-circle"></i> Ошибка чтения файла</span>';
this.certificates[certType] = null;
if (labelSpan) labelSpan.textContent = 'Выберите файл...';
if (labelBtn) labelBtn.classList.remove('file-uploaded');
};
reader.readAsText(file);
}
/**
* Валидация формы
*/
validateForm() {
const name = $('newSiteName')?.value.trim();
const host = $('newSiteHost')?.value.trim();
const rootFile = $('newSiteRootFile')?.value;
const certMode = $('certMode')?.value;
if (!name) {
notification.error('❌ Укажите название сайта');
return false;
}
if (!host) {
notification.error('❌ Укажите host (домен)');
return false;
}
if (!rootFile) {
notification.error('❌ Укажите root файл');
return false;
}
// Проверка сертификатов если режим загрузки
if (certMode === 'upload') {
if (!this.certificates.certificate) {
notification.error('❌ Загрузите файл certificate.crt');
return false;
}
if (!this.certificates.privatekey) {
notification.error('❌ Загрузите файл private.key');
return false;
}
}
return true;
}
/**
* Создать сайт
*/
async createSite() {
if (!this.validateForm()) {
return;
}
if (!isWailsAvailable()) {
notification.error('Wails API недоступен');
return;
}
const createBtn = $('createSiteBtn');
const originalText = createBtn.querySelector('span').textContent;
try {
createBtn.disabled = true;
createBtn.querySelector('span').textContent = 'Создание...';
// Парсим aliases из поля ввода
this.parseAliases();
// Собираем данные сайта
const siteData = {
name: $('newSiteName').value.trim(),
host: $('newSiteHost').value.trim(),
alias: this.aliases,
status: $('newSiteStatus').value,
root_file: $('newSiteRootFile').value,
root_file_routing: $('newSiteRouting').checked
};
// Создаём сайт
const siteJSON = JSON.stringify(siteData);
const result = await api.createNewSite(siteJSON);
if (result.startsWith('Error')) {
notification.error(result, 3000);
return;
}
notification.success('✅ Сайт успешно создан!', 1500);
// Загружаем сертификаты если нужно
const certMode = $('certMode').value;
if (certMode === 'upload') {
createBtn.querySelector('span').textContent = 'Загрузка сертификатов...';
// Загружаем certificate
if (this.certificates.certificate) {
await api.uploadCertificate(siteData.host, 'certificate', this.certificates.certificate);
}
// Загружаем private key
if (this.certificates.privatekey) {
await api.uploadCertificate(siteData.host, 'privatekey', this.certificates.privatekey);
}
// Загружаем ca bundle если есть
if (this.certificates.cabundle) {
await api.uploadCertificate(siteData.host, 'cabundle', this.certificates.cabundle);
}
notification.success('🔒 Сертификаты загружены!', 1500);
}
// Перезапускаем HTTP/HTTPS
createBtn.querySelector('span').textContent = 'Перезапуск серверов...';
await configAPI.stopHTTPService();
await configAPI.stopHTTPSService();
await new Promise(resolve => setTimeout(resolve, 500));
await configAPI.startHTTPService();
await configAPI.startHTTPSService();
notification.success('🚀 Серверы перезапущены! Сайт готов к работе!', 2000);
// Возвращаемся на главную
setTimeout(() => {
this.backToMain();
// Перезагружаем список сайтов
if (window.sitesManager) {
window.sitesManager.load();
}
}, 1000);
} catch (error) {
notification.error('Ошибка: ' + error.message, 3000);
} finally {
createBtn.disabled = false;
createBtn.querySelector('span').textContent = originalText;
}
}
}

View File

@@ -12,7 +12,10 @@ import { ServicesManager } from './components/services.js';
import { SitesManager } from './components/sites.js';
import { ProxyManager } from './components/proxy.js';
import { VAccessManager } from './components/vaccess.js';
import { SiteCreator } from './components/site-creator.js';
import { api } from './api/wails.js';
import { configAPI } from './api/config.js';
import { initCustomSelects } from './ui/custom-select.js';
import { $ } from './utils/dom.js';
/**
@@ -26,6 +29,7 @@ class App {
this.sitesManager = new SitesManager();
this.proxyManager = new ProxyManager();
this.vAccessManager = new VAccessManager();
this.siteCreator = new SiteCreator();
this.isWails = isWailsAvailable();
@@ -69,6 +73,9 @@ class App {
// Привязываем кнопки
this.setupButtons();
// Инициализируем кастомные select'ы
initCustomSelects();
log('Приложение запущено');
}
@@ -96,6 +103,14 @@ class App {
* Привязать кнопки
*/
setupButtons() {
// Кнопка добавления сайта
const addSiteBtn = $('addSiteBtn');
if (addSiteBtn) {
addSiteBtn.addEventListener('click', () => {
this.siteCreator.open();
});
}
// Кнопка сохранения настроек
const saveSettingsBtn = $('saveSettingsBtn');
if (saveSettingsBtn) {
@@ -128,6 +143,23 @@ class App {
* Настроить глобальные обработчики
*/
setupGlobalHandlers() {
// Глобальная ссылка на sitesManager
window.sitesManager = this.sitesManager;
window.siteCreator = this.siteCreator;
// Для SiteCreator
window.backToMainFromAddSite = () => {
this.siteCreator.backToMain();
};
window.toggleCertUpload = () => {
this.siteCreator.toggleCertUpload();
};
window.handleCertFileSelect = (input, certType) => {
this.siteCreator.handleCertFile(input, certType);
};
// Для vAccess
window.editVAccess = (host, isProxy) => {
this.vAccessManager.open(host, isProxy);
@@ -248,6 +280,10 @@ class App {
window.openSiteFolder = async (host) => {
await this.sitesManager.handleAction('open-folder', { getAttribute: () => host });
};
window.deleteSiteConfirm = async () => {
await this.deleteSiteConfirm();
};
}
/**
@@ -382,6 +418,9 @@ class App {
modal.open('Редактировать сайт', content);
window.currentEditType = 'site';
window.currentEditIndex = index;
// Добавляем кнопку удаления в футер модального окна
this.addDeleteButtonToModal();
}
/**
@@ -446,6 +485,9 @@ class App {
modal.open('Редактировать прокси', content);
window.currentEditType = 'proxy';
window.currentEditIndex = index;
// Убираем кнопку удаления (для прокси не нужна)
this.removeDeleteButtonFromModal();
}
/**
@@ -574,6 +616,94 @@ class App {
modal.close();
}
}
/**
* Добавить кнопку удаления в модальное окно
*/
addDeleteButtonToModal() {
const footer = document.querySelector('.modal-footer');
if (!footer) return;
// Удаляем старую кнопку удаления если есть
const oldDeleteBtn = footer.querySelector('#modalDeleteBtn');
if (oldDeleteBtn) oldDeleteBtn.remove();
// Создаём кнопку удаления
const deleteBtn = document.createElement('button');
deleteBtn.className = 'action-btn delete-btn';
deleteBtn.id = 'modalDeleteBtn';
deleteBtn.innerHTML = `
<i class="fas fa-trash"></i>
<span>Удалить сайт</span>
`;
deleteBtn.onclick = () => this.deleteSiteConfirm();
// Вставляем перед кнопкой "Отмена"
const cancelBtn = footer.querySelector('#modalCancelBtn');
if (cancelBtn) {
footer.insertBefore(deleteBtn, cancelBtn);
}
}
/**
* Удалить кнопку удаления из модального окна
*/
removeDeleteButtonFromModal() {
const deleteBtn = document.querySelector('#modalDeleteBtn');
if (deleteBtn) deleteBtn.remove();
}
/**
* Подтверждение удаления сайта
*/
async deleteSiteConfirm() {
const index = window.currentEditIndex;
const site = this.sitesManager.sitesData[index];
if (!site) return;
// Подтверждение
const confirmed = confirm(
`⚠️ ВНИМАНИЕ!\n\n` +
`Вы действительно хотите удалить сайт "${site.name}" (${site.host})?\n\n` +
`Будут удалены:\n` +
`• Папка сайта: WebServer/www/${site.host}/\n` +
`• SSL сертификаты (если есть)\n` +
`• Запись в конфигурации\n\n` +
`Это действие НЕОБРАТИМО!`
);
if (!confirmed) return;
try {
notification.show('Удаление сайта...', 'info', 1000);
const result = await api.deleteSite(site.host);
if (result.startsWith('Error')) {
notification.error(result, 3000);
return;
}
notification.success('✅ Сайт успешно удалён!', 1500);
// Перезапускаем HTTP/HTTPS
notification.show('Перезапуск серверов...', 'success', 800);
await configAPI.stopHTTPService();
await configAPI.stopHTTPSService();
await sleep(500);
await configAPI.startHTTPService();
await configAPI.startHTTPSService();
notification.success('🚀 Серверы перезапущены!', 1000);
// Закрываем модальное окно и обновляем список
modal.close();
await this.sitesManager.load();
} catch (error) {
notification.error('Ошибка: ' + error.message, 3000);
}
}
}
// Инициализация приложения при загрузке DOM

View File

@@ -0,0 +1,140 @@
/* ============================================
Custom Select Component
Кастомные выпадающие списки
============================================ */
import { $ } from '../utils/dom.js';
/**
* Инициализация всех кастомных select'ов на странице
*/
export function initCustomSelects() {
const selects = document.querySelectorAll('select.form-input');
selects.forEach(select => {
if (!select.dataset.customized) {
createCustomSelect(select);
}
});
}
/**
* Создать кастомный select из нативного
*/
function createCustomSelect(selectElement) {
// Помечаем как обработанный
selectElement.dataset.customized = 'true';
// Создаём контейнер
const wrapper = document.createElement('div');
wrapper.className = 'custom-select';
// Получаем выбранное значение
const selectedOption = selectElement.options[selectElement.selectedIndex];
const selectedText = selectedOption ? selectedOption.text : '';
// Создаём кнопку (видимая часть)
const button = document.createElement('div');
button.className = 'custom-select-trigger';
button.innerHTML = `
<span class="custom-select-value">${selectedText}</span>
<i class="fas fa-chevron-down custom-select-arrow"></i>
`;
// Создаём выпадающий список
const dropdown = document.createElement('div');
dropdown.className = 'custom-select-dropdown';
// Заполняем опции
Array.from(selectElement.options).forEach((option, index) => {
const item = document.createElement('div');
item.className = 'custom-select-option';
item.textContent = option.text;
item.dataset.value = option.value;
item.dataset.index = index;
if (option.selected) {
item.classList.add('selected');
}
// Клик по опции
item.addEventListener('click', () => {
selectOption(selectElement, wrapper, item, index);
});
dropdown.appendChild(item);
});
// Клик по кнопке - открыть/закрыть
button.addEventListener('click', (e) => {
e.stopPropagation();
toggleDropdown(wrapper);
});
// Собираем вместе
wrapper.appendChild(button);
wrapper.appendChild(dropdown);
// Скрываем оригинальный select
selectElement.style.display = 'none';
// Вставляем кастомный select после оригинального
selectElement.parentNode.insertBefore(wrapper, selectElement.nextSibling);
// Закрываем при клике вне
document.addEventListener('click', (e) => {
if (!wrapper.contains(e.target)) {
closeDropdown(wrapper);
}
});
}
/**
* Открыть/закрыть dropdown
*/
function toggleDropdown(wrapper) {
const isOpen = wrapper.classList.contains('open');
// Закрываем все открытые
document.querySelectorAll('.custom-select.open').forEach(el => {
el.classList.remove('open');
});
if (!isOpen) {
wrapper.classList.add('open');
}
}
/**
* Закрыть dropdown
*/
function closeDropdown(wrapper) {
wrapper.classList.remove('open');
}
/**
* Выбрать опцию
*/
function selectOption(selectElement, wrapper, optionElement, index) {
// Обновляем оригинальный select
selectElement.selectedIndex = index;
// Триггерим событие change
const event = new Event('change', { bubbles: true });
selectElement.dispatchEvent(event);
// Обновляем UI
const valueSpan = wrapper.querySelector('.custom-select-value');
valueSpan.textContent = optionElement.textContent;
// Убираем selected у всех опций
wrapper.querySelectorAll('.custom-select-option').forEach(opt => {
opt.classList.remove('selected');
});
// Добавляем selected к выбранной
optionElement.classList.add('selected');
// Закрываем dropdown
closeDropdown(wrapper);
}

View File

@@ -16,7 +16,8 @@ export class Navigation {
sites: $('sectionSites'),
proxy: $('sectionProxy'),
settings: $('sectionSettings'),
vaccess: $('sectionVAccessEditor')
vaccess: $('sectionVAccessEditor'),
addSite: $('sectionAddSite')
};
this.init();
}