From 19710fbc19169abc857b0122d83012f13458f1ee Mon Sep 17 00:00:00 2001 From: Andy Oknen Date: Thu, 31 Jul 2025 14:25:38 +0000 Subject: [PATCH] Implement modal system for adding peers and logout confirmation in WebUI --- src/webui/static/app.js | 73 +++++-- src/webui/static/index.html | 16 ++ src/webui/static/lang/en.js | 22 +- src/webui/static/lang/ru.js | 22 +- src/webui/static/main.js | 25 ++- src/webui/static/modal.js | 415 ++++++++++++++++++++++++++++++++++++ src/webui/static/style.css | 248 +++++++++++++++++++++ 7 files changed, 789 insertions(+), 32 deletions(-) create mode 100644 src/webui/static/modal.js diff --git a/src/webui/static/app.js b/src/webui/static/app.js index 480226d5..6054a92b 100644 --- a/src/webui/static/app.js +++ b/src/webui/static/app.js @@ -239,29 +239,60 @@ function createPeerElement(peer) { } /** - * Add a new peer + * Add a new peer with modal form */ async function addPeer() { - const uri = prompt('Enter peer URI:\nExamples:\n• tcp://example.com:54321\n• tls://peer.yggdrasil.network:443'); - if (!uri || uri.trim() === '') { - showWarning('Peer URI is required'); - return; - } - - // Basic URI validation - if (!uri.includes('://')) { - showError('Invalid URI format. Must include protocol (tcp://, tls://, etc.)'); - return; - } - - try { - showInfo('Adding peer...'); - await window.yggAPI.addPeer(uri.trim()); - showSuccess(`Peer added successfully: ${uri.trim()}`); - await loadPeers(); // Refresh peer list - } catch (error) { - showError('Failed to add peer: ' + error.message); - } + showModal({ + title: 'add_peer', + content: 'add_peer_modal_description', + size: 'medium', + inputs: [ + { + type: 'text', + name: 'peer_uri', + label: 'peer_uri_label', + placeholder: 'peer_uri_placeholder', + required: true, + help: 'peer_uri_help' + } + ], + buttons: [ + { + text: 'modal_cancel', + type: 'secondary', + action: 'close' + }, + { + text: 'add_peer_btn', + type: 'primary', + callback: async (formData) => { + const uri = formData.peer_uri?.trim(); + + if (!uri) { + showWarning('Peer URI is required'); + return false; // Don't close modal + } + + // Basic URI validation + if (!uri.includes('://')) { + showError('Invalid URI format. Must include protocol (tcp://, tls://, etc.)'); + return false; // Don't close modal + } + + try { + showInfo('Adding peer...'); + await window.yggAPI.addPeer(uri); + showSuccess(`Peer added successfully: ${uri}`); + await loadPeers(); // Refresh peer list + return true; // Close modal + } catch (error) { + showError('Failed to add peer: ' + error.message); + return false; // Don't close modal + } + } + } + ] + }); } /** diff --git a/src/webui/static/index.html b/src/webui/static/index.html index b2fb9880..df76aee0 100644 --- a/src/webui/static/index.html +++ b/src/webui/static/index.html @@ -9,6 +9,7 @@ + @@ -146,6 +147,21 @@
+ + diff --git a/src/webui/static/lang/en.js b/src/webui/static/lang/en.js index c4bc3c39..0d0d91ae 100644 --- a/src/webui/static/lang/en.js +++ b/src/webui/static/lang/en.js @@ -78,5 +78,25 @@ window.translations.en = { 'peer_quality_good': 'Good', 'peer_quality_fair': 'Fair', 'peer_quality_poor': 'Poor', - 'peer_quality_unknown': 'Unknown' + 'peer_quality_unknown': 'Unknown', + + // Modal translations + 'modal_close': 'Close', + 'modal_cancel': 'Cancel', + 'modal_ok': 'OK', + 'modal_confirm': 'Confirmation', + 'modal_confirm_yes': 'Yes', + 'modal_confirm_message': 'Are you sure?', + 'modal_alert': 'Alert', + 'modal_input': 'Input', + 'modal_error': 'Error', + 'modal_success': 'Success', + 'modal_warning': 'Warning', + 'modal_info': 'Information', + + // Add peer modal + 'add_peer_modal_description': 'Enter peer URI to connect to a new network node', + 'peer_uri_label': 'Peer URI', + 'peer_uri_placeholder': 'tcp://example.com:54321', + 'peer_uri_help': 'Examples: tcp://example.com:54321, tls://peer.yggdrasil.network:443' }; \ No newline at end of file diff --git a/src/webui/static/lang/ru.js b/src/webui/static/lang/ru.js index 10fb59c0..5ff168ca 100644 --- a/src/webui/static/lang/ru.js +++ b/src/webui/static/lang/ru.js @@ -78,5 +78,25 @@ window.translations.ru = { 'peer_quality_good': 'Хорошее', 'peer_quality_fair': 'Приемлемое', 'peer_quality_poor': 'Плохое', - 'peer_quality_unknown': 'Неизвестно' + 'peer_quality_unknown': 'Неизвестно', + + // Modal translations + 'modal_close': 'Закрыть', + 'modal_cancel': 'Отмена', + 'modal_ok': 'ОК', + 'modal_confirm': 'Подтверждение', + 'modal_confirm_yes': 'Да', + 'modal_confirm_message': 'Вы уверены?', + 'modal_alert': 'Уведомление', + 'modal_input': 'Ввод данных', + 'modal_error': 'Ошибка', + 'modal_success': 'Успешно', + 'modal_warning': 'Предупреждение', + 'modal_info': 'Информация', + + // Add peer modal + 'add_peer_modal_description': 'Введите URI пира для подключения к новому узлу сети', + 'peer_uri_label': 'URI пира', + 'peer_uri_placeholder': 'tcp://example.com:54321', + 'peer_uri_help': 'Примеры: tcp://example.com:54321, tls://peer.yggdrasil.network:443' }; \ No newline at end of file diff --git a/src/webui/static/main.js b/src/webui/static/main.js index c029d1e6..1a048899 100644 --- a/src/webui/static/main.js +++ b/src/webui/static/main.js @@ -170,17 +170,24 @@ function showSection(sectionName) { } /** - * Logout function (placeholder) + * Logout function with modal confirmation */ function logout() { - if (confirm('Are you sure you want to logout?')) { - // Clear stored preferences - localStorage.removeItem('yggdrasil-language'); - localStorage.removeItem('yggdrasil-theme'); - - // Redirect or refresh - window.location.reload(); - } + showConfirmModal({ + title: 'modal_confirm', + message: 'logout_confirm', + confirmText: 'modal_confirm_yes', + cancelText: 'modal_cancel', + type: 'danger', + onConfirm: () => { + // Clear stored preferences + localStorage.removeItem('yggdrasil-language'); + localStorage.removeItem('yggdrasil-theme'); + + // Redirect or refresh + window.location.reload(); + } + }); } // Notification system diff --git a/src/webui/static/modal.js b/src/webui/static/modal.js new file mode 100644 index 00000000..9fc85239 --- /dev/null +++ b/src/webui/static/modal.js @@ -0,0 +1,415 @@ +/** + * Modal System for Yggdrasil Web Interface + * Provides flexible modal dialogs with multiple action buttons and input forms + */ + +// Global modal state +let currentModal = null; +let modalCallbacks = {}; + +/** + * Show a modal dialog + * @param {Object} options - Modal configuration + * @param {string} options.title - Modal title (supports localization key) + * @param {string|HTMLElement} options.content - Modal content (supports localization key) + * @param {Array} options.buttons - Array of button configurations + * @param {Array} options.inputs - Array of input field configurations + * @param {Function} options.onClose - Callback when modal is closed + * @param {boolean} options.closable - Whether modal can be closed by clicking overlay or X button + * @param {string} options.size - Modal size: 'small', 'medium', 'large' + */ +function showModal(options = {}) { + const { + title = 'Modal', + content = '', + buttons = [{ text: 'modal_close', type: 'secondary', action: 'close' }], + inputs = [], + onClose = null, + closable = true, + size = 'medium' + } = options; + + const overlay = document.getElementById('modal-overlay'); + const container = document.getElementById('modal-container'); + const titleElement = document.getElementById('modal-title'); + const contentElement = document.getElementById('modal-content'); + const footerElement = document.getElementById('modal-footer'); + const closeBtn = document.getElementById('modal-close-btn'); + + if (!overlay || !container) { + console.error('Modal elements not found in DOM'); + return; + } + + // Set modal size + container.className = `modal-container modal-${size}`; + + // Set title (with localization support) + titleElement.textContent = getLocalizedText(title); + + // Set content + if (typeof content === 'string') { + contentElement.innerHTML = `

${getLocalizedText(content)}

`; + } else if (content instanceof HTMLElement) { + contentElement.innerHTML = ''; + contentElement.appendChild(content); + } else { + contentElement.innerHTML = content; + } + + // Add input fields if provided + if (inputs && inputs.length > 0) { + const formContainer = document.createElement('div'); + formContainer.className = 'modal-form-container'; + + inputs.forEach((input, index) => { + const formGroup = createFormGroup(input, index); + formContainer.appendChild(formGroup); + }); + + contentElement.appendChild(formContainer); + } + + // Create buttons + footerElement.innerHTML = ''; + buttons.forEach((button, index) => { + const btn = createModalButton(button, index); + footerElement.appendChild(btn); + }); + + // Configure close button + closeBtn.style.display = closable ? 'flex' : 'none'; + + // Set up event handlers + modalCallbacks.onClose = onClose; + + // Close on overlay click (if closable) + if (closable) { + overlay.onclick = (e) => { + if (e.target === overlay) { + closeModal(); + } + }; + } else { + overlay.onclick = null; + } + + // Close on Escape key (if closable) + if (closable) { + document.addEventListener('keydown', handleEscapeKey); + } + + // Show modal + currentModal = options; + overlay.classList.add('show'); + + // Focus first input if available + setTimeout(() => { + const firstInput = contentElement.querySelector('.modal-form-input'); + if (firstInput) { + firstInput.focus(); + } + }, 100); +} + +/** + * Close the current modal + */ +function closeModal() { + const overlay = document.getElementById('modal-overlay'); + if (!overlay) return; + + overlay.classList.remove('show'); + + // Clean up event listeners + document.removeEventListener('keydown', handleEscapeKey); + overlay.onclick = null; + + // Call onClose callback if provided + if (modalCallbacks.onClose) { + modalCallbacks.onClose(); + } + + // Clear state + currentModal = null; + modalCallbacks = {}; +} + +/** + * Create a form group element + */ +function createFormGroup(input, index) { + const { + type = 'text', + name = `input_${index}`, + label = '', + placeholder = '', + value = '', + required = false, + help = '', + options = [] // for select inputs + } = input; + + const formGroup = document.createElement('div'); + formGroup.className = 'modal-form-group'; + + // Create label + if (label) { + const labelElement = document.createElement('label'); + labelElement.className = 'modal-form-label'; + labelElement.textContent = getLocalizedText(label); + labelElement.setAttribute('for', name); + formGroup.appendChild(labelElement); + } + + // Create input element + let inputElement; + + if (type === 'textarea') { + inputElement = document.createElement('textarea'); + inputElement.className = 'modal-form-textarea'; + } else if (type === 'select') { + inputElement = document.createElement('select'); + inputElement.className = 'modal-form-select'; + + // Add options + options.forEach(option => { + const optionElement = document.createElement('option'); + optionElement.value = option.value || option; + optionElement.textContent = getLocalizedText(option.text || option); + if (option.selected || option.value === value) { + optionElement.selected = true; + } + inputElement.appendChild(optionElement); + }); + } else { + inputElement = document.createElement('input'); + inputElement.type = type; + inputElement.className = 'modal-form-input'; + inputElement.value = value; + } + + inputElement.name = name; + inputElement.id = name; + + if (placeholder) { + inputElement.placeholder = getLocalizedText(placeholder); + } + + if (required) { + inputElement.required = true; + } + + formGroup.appendChild(inputElement); + + // Create help text + if (help) { + const helpElement = document.createElement('div'); + helpElement.className = 'modal-form-help'; + helpElement.textContent = getLocalizedText(help); + formGroup.appendChild(helpElement); + } + + return formGroup; +} + +/** + * Create a modal button + */ +function createModalButton(button, index) { + const { + text = 'Button', + type = 'secondary', + action = null, + callback = null, + disabled = false + } = button; + + const btn = document.createElement('button'); + btn.className = `modal-btn modal-btn-${type}`; + btn.textContent = getLocalizedText(text); + btn.disabled = disabled; + + btn.onclick = () => { + if (action === 'close') { + closeModal(); + } else if (callback) { + const formData = getModalFormData(); + const result = callback(formData); + + // If callback returns false, don't close modal + if (result !== false) { + closeModal(); + } + } + }; + + return btn; +} + +/** + * Get form data from modal inputs + */ +function getModalFormData() { + const formData = {}; + const inputs = document.querySelectorAll('#modal-content .modal-form-input, #modal-content .modal-form-textarea, #modal-content .modal-form-select'); + + inputs.forEach(input => { + formData[input.name] = input.value; + }); + + return formData; +} + +/** + * Handle Escape key press + */ +function handleEscapeKey(e) { + if (e.key === 'Escape' && currentModal) { + closeModal(); + } +} + +/** + * Get localized text or return original if not found + */ +function getLocalizedText(key) { + if (typeof key !== 'string') return key; + + const currentLang = window.getCurrentLanguage ? window.getCurrentLanguage() : 'en'; + + if (window.translations && + window.translations[currentLang] && + window.translations[currentLang][key]) { + return window.translations[currentLang][key]; + } + + return key; +} + +// Convenience functions for common modal types + +/** + * Show a confirmation dialog + */ +function showConfirmModal(options = {}) { + const { + title = 'modal_confirm', + message = 'modal_confirm_message', + confirmText = 'modal_confirm_yes', + cancelText = 'modal_cancel', + onConfirm = null, + onCancel = null, + type = 'danger' // danger, primary, success + } = options; + + showModal({ + title, + content: message, + closable: true, + buttons: [ + { + text: cancelText, + type: 'secondary', + action: 'close', + callback: onCancel + }, + { + text: confirmText, + type: type, + callback: () => { + if (onConfirm) onConfirm(); + return true; // Close modal + } + } + ] + }); +} + +/** + * Show an alert dialog + */ +function showAlertModal(options = {}) { + const { + title = 'modal_alert', + message = '', + buttonText = 'modal_ok', + type = 'primary', + onClose = null + } = options; + + showModal({ + title, + content: message, + closable: true, + onClose, + buttons: [ + { + text: buttonText, + type: type, + action: 'close' + } + ] + }); +} + +/** + * Show a prompt dialog with input + */ +function showPromptModal(options = {}) { + const { + title = 'modal_input', + message = '', + inputLabel = '', + inputPlaceholder = '', + inputValue = '', + inputType = 'text', + inputRequired = true, + confirmText = 'modal_ok', + cancelText = 'modal_cancel', + onConfirm = null, + onCancel = null + } = options; + + showModal({ + title, + content: message, + closable: true, + inputs: [ + { + type: inputType, + name: 'input_value', + label: inputLabel, + placeholder: inputPlaceholder, + value: inputValue, + required: inputRequired + } + ], + buttons: [ + { + text: cancelText, + type: 'secondary', + action: 'close', + callback: onCancel + }, + { + text: confirmText, + type: 'primary', + callback: (formData) => { + if (inputRequired && !formData.input_value.trim()) { + return false; // Don't close modal + } + if (onConfirm) onConfirm(formData.input_value); + return true; // Close modal + } + } + ] + }); +} + +// Export functions to global scope +window.showModal = showModal; +window.closeModal = closeModal; +window.showConfirmModal = showConfirmModal; +window.showAlertModal = showAlertModal; +window.showPromptModal = showPromptModal; \ No newline at end of file diff --git a/src/webui/static/style.css b/src/webui/static/style.css index aa708f53..53c7137c 100644 --- a/src/webui/static/style.css +++ b/src/webui/static/style.css @@ -1315,4 +1315,252 @@ button[onclick="copyNodeKey()"]:hover { /* Responsive design for peer items */ @media (max-width: 768px) { +} + +/* ======================== */ +/* MODAL SYSTEM */ +/* ======================== */ + +/* Modal overlay background */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + opacity: 0; + visibility: hidden; + transition: all 0.3s ease; +} + +.modal-overlay.show { + opacity: 1; + visibility: visible; +} + +/* Modal container */ +.modal-container { + background: var(--bg-info-card); + border-radius: 12px; + box-shadow: 0 20px 60px var(--shadow-heavy); + max-width: 500px; + width: 90%; + max-height: 80vh; + overflow: hidden; + transform: scale(0.9) translateY(-20px); + transition: all 0.3s ease; + border: 1px solid var(--border-card); +} + +/* Modal sizes */ +.modal-small { + max-width: 400px; +} + +.modal-medium { + max-width: 500px; +} + +.modal-large { + max-width: 700px; +} + +.modal-overlay.show .modal-container { + transform: scale(1) translateY(0); +} + +/* Modal header */ +.modal-header { + padding: 20px 24px 16px; + border-bottom: 1px solid var(--border-card); + display: flex; + align-items: center; + justify-content: space-between; + background: var(--bg-nav-item); +} + +.modal-title { + margin: 0; + color: var(--text-heading); + font-size: 1.25rem; + font-weight: 600; +} + +.modal-close-btn { + background: none; + border: none; + font-size: 20px; + color: var(--text-muted); + cursor: pointer; + padding: 4px; + border-radius: 4px; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; +} + +.modal-close-btn:hover { + background: var(--bg-nav-hover); + color: var(--text-heading); +} + +/* Modal content */ +.modal-content { + padding: 24px; + max-height: 60vh; + overflow-y: auto; + color: var(--text-body); +} + +.modal-content p { + margin: 0 0 16px 0; + line-height: 1.5; +} + +.modal-content p:last-child { + margin-bottom: 0; +} + +/* Modal footer */ +.modal-footer { + padding: 16px 24px 20px; + border-top: 1px solid var(--border-card); + display: flex; + gap: 12px; + justify-content: flex-end; + background: var(--bg-nav-item); +} + +/* Modal buttons */ +.modal-btn { + padding: 10px 20px; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + min-width: 80px; +} + +.modal-btn-primary { + background: var(--bg-nav-active); + color: var(--text-white); +} + +.modal-btn-primary:hover { + background: var(--bg-nav-active-border); + transform: translateY(-1px); +} + +.modal-btn-secondary { + background: var(--bg-nav-item); + color: var(--text-nav); + border: 1px solid var(--border-nav-item); +} + +.modal-btn-secondary:hover { + background: var(--bg-nav-hover); +} + +.modal-btn-danger { + background: var(--bg-logout); + color: var(--text-white); +} + +.modal-btn-danger:hover { + background: var(--bg-logout-hover); + transform: translateY(-1px); +} + +.modal-btn-success { + background: var(--bg-success-dark); + color: var(--text-white); +} + +.modal-btn-success:hover { + background: #218838; + transform: translateY(-1px); +} + +/* Modal form elements */ +.modal-form-group { + margin-bottom: 20px; +} + +.modal-form-group:last-child { + margin-bottom: 0; +} + +.modal-form-label { + display: block; + margin-bottom: 6px; + color: var(--text-heading); + font-weight: 500; + font-size: 14px; +} + +.modal-form-input, +.modal-form-textarea, +.modal-form-select { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--border-nav-item); + border-radius: 6px; + font-size: 14px; + background: var(--bg-info-card); + color: var(--text-body); + transition: border-color 0.2s ease; + box-sizing: border-box; +} + +.modal-form-input:focus, +.modal-form-textarea:focus, +.modal-form-select:focus { + outline: none; + border-color: var(--bg-nav-active); +} + +.modal-form-textarea { + resize: vertical; + min-height: 80px; +} + +.modal-form-help { + margin-top: 6px; + font-size: 12px; + color: var(--text-muted); +} + +/* Responsive design */ +@media (max-width: 600px) { + .modal-container { + width: 95%; + margin: 20px; + max-height: 90vh; + } + + .modal-header, + .modal-content, + .modal-footer { + padding-left: 16px; + padding-right: 16px; + } + + .modal-footer { + flex-direction: column-reverse; + } + + .modal-btn { + width: 100%; + } } \ No newline at end of file