Implement modal system for adding peers and logout confirmation in WebUI

This commit is contained in:
Andy Oknen 2025-07-31 14:25:38 +00:00
parent fcb5efd753
commit 19710fbc19
7 changed files with 789 additions and 32 deletions

View file

@ -239,29 +239,60 @@ function createPeerElement(peer) {
} }
/** /**
* Add a new peer * Add a new peer with modal form
*/ */
async function addPeer() { async function addPeer() {
const uri = prompt('Enter peer URI:\nExamples:\n• tcp://example.com:54321\n• tls://peer.yggdrasil.network:443'); showModal({
if (!uri || uri.trim() === '') { title: 'add_peer',
showWarning('Peer URI is required'); content: 'add_peer_modal_description',
return; 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();
// Basic URI validation if (!uri) {
if (!uri.includes('://')) { showWarning('Peer URI is required');
showError('Invalid URI format. Must include protocol (tcp://, tls://, etc.)'); return false; // Don't close modal
return; }
}
try { // Basic URI validation
showInfo('Adding peer...'); if (!uri.includes('://')) {
await window.yggAPI.addPeer(uri.trim()); showError('Invalid URI format. Must include protocol (tcp://, tls://, etc.)');
showSuccess(`Peer added successfully: ${uri.trim()}`); return false; // Don't close modal
await loadPeers(); // Refresh peer list }
} catch (error) {
showError('Failed to add peer: ' + error.message); 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
}
}
}
]
});
} }
/** /**

View file

@ -9,6 +9,7 @@
<script src="static/api.js"></script> <script src="static/api.js"></script>
<script src="static/main.js"></script> <script src="static/main.js"></script>
<script src="static/app.js"></script> <script src="static/app.js"></script>
<script src="static/modal.js"></script>
<script src="static/lang/ru.js"></script> <script src="static/lang/ru.js"></script>
<script src="static/lang/en.js"></script> <script src="static/lang/en.js"></script>
</head> </head>
@ -146,6 +147,21 @@
<!-- Notifications container --> <!-- Notifications container -->
<div class="notifications-container" id="notifications-container"></div> <div class="notifications-container" id="notifications-container"></div>
<!-- Modal System -->
<div class="modal-overlay" id="modal-overlay">
<div class="modal-container" id="modal-container">
<div class="modal-header">
<h3 class="modal-title" id="modal-title">Modal Title</h3>
<button class="modal-close-btn" id="modal-close-btn" onclick="closeModal()"></button>
</div>
<div class="modal-content" id="modal-content">
<!-- Modal content will be injected here -->
</div>
<div class="modal-footer" id="modal-footer">
<!-- Modal buttons will be injected here -->
</div>
</div>
</div>
</body> </body>

View file

@ -78,5 +78,25 @@ window.translations.en = {
'peer_quality_good': 'Good', 'peer_quality_good': 'Good',
'peer_quality_fair': 'Fair', 'peer_quality_fair': 'Fair',
'peer_quality_poor': 'Poor', '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'
}; };

View file

@ -78,5 +78,25 @@ window.translations.ru = {
'peer_quality_good': 'Хорошее', 'peer_quality_good': 'Хорошее',
'peer_quality_fair': 'Приемлемое', 'peer_quality_fair': 'Приемлемое',
'peer_quality_poor': 'Плохое', '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'
}; };

View file

@ -170,17 +170,24 @@ function showSection(sectionName) {
} }
/** /**
* Logout function (placeholder) * Logout function with modal confirmation
*/ */
function logout() { function logout() {
if (confirm('Are you sure you want to logout?')) { showConfirmModal({
// Clear stored preferences title: 'modal_confirm',
localStorage.removeItem('yggdrasil-language'); message: 'logout_confirm',
localStorage.removeItem('yggdrasil-theme'); confirmText: 'modal_confirm_yes',
cancelText: 'modal_cancel',
type: 'danger',
onConfirm: () => {
// Clear stored preferences
localStorage.removeItem('yggdrasil-language');
localStorage.removeItem('yggdrasil-theme');
// Redirect or refresh // Redirect or refresh
window.location.reload(); window.location.reload();
} }
});
} }
// Notification system // Notification system

415
src/webui/static/modal.js Normal file
View file

@ -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 = `<p>${getLocalizedText(content)}</p>`;
} 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;

View file

@ -1316,3 +1316,251 @@ button[onclick="copyNodeKey()"]:hover {
/* Responsive design for peer items */ /* Responsive design for peer items */
@media (max-width: 768px) { @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%;
}
}