This commit is contained in:
Alexey Berezhok
2024-03-19 22:05:27 +03:00
commit 346a50856b
1572 changed files with 182163 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
.js and .php files placed in this directory will automatically be included in scripts loading init.js.
subfolders will be ignored (recursive file loading is not supported. you're free to create sub-folders, but they will not be invoked automatically.)
These custom files will persist across HestiaCP upgrades.
This directory is not meant for HestiaCP development, but meant for people wanting to customize their own HestiaCP control panel.
Warning: modifications to this README.txt may be lost during HestiaCP upgrades.

25
web/js/src/addIpLists.js Normal file
View File

@@ -0,0 +1,25 @@
import { parseAndSortIpLists } from './helpers';
// Populates the "IP address / IP list" select with created IP lists
// on the Add Firewall Rule page
export default function handleAddIpLists() {
const ipListSelect = document.querySelector('.js-ip-list-select');
if (!ipListSelect) {
return;
}
const ipSetLists = parseAndSortIpLists(ipListSelect.dataset.ipsetLists);
const headerOption = document.createElement('option');
headerOption.textContent = 'IPset IP Lists';
headerOption.disabled = true;
ipListSelect.appendChild(headerOption);
ipSetLists.forEach((ipSet) => {
const ipOption = document.createElement('option');
ipOption.textContent = ipSet.name;
ipOption.value = `ipset:${ipSet.name}`;
ipListSelect.appendChild(ipOption);
});
}

75
web/js/src/alpineInit.js Normal file
View File

@@ -0,0 +1,75 @@
// Set up various Alpine things after it's initialized
export default function alpineInit() {
// Bulk edit forms
Alpine.bind('BulkEdit', () => ({
/** @param {SubmitEvent} evt */
'@submit'(evt) {
evt.preventDefault();
document.querySelectorAll('.js-unit-checkbox').forEach((el) => {
if (el.checked) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = el.name;
input.value = el.value;
evt.target.appendChild(input);
}
});
evt.target.submit();
},
}));
// Form state
Alpine.store('form', {
dirty: false,
makeDirty() {
this.dirty = true;
},
});
document
.querySelectorAll('#main-form input, #main-form select, #main-form textarea')
.forEach((el) => {
el.addEventListener('change', () => {
Alpine.store('form').makeDirty();
});
});
// Notifications methods called by the view code
Alpine.data('notifications', () => ({
initialized: false,
open: false,
notifications: [],
toggle() {
this.open = !this.open;
if (!this.initialized) {
this.list();
}
},
async list() {
const token = document.querySelector('#token').getAttribute('token');
const res = await fetch(`/list/notifications/?ajax=1&token=${token}`);
this.initialized = true;
if (!res.ok) {
throw new Error('An error occurred while listing notifications.');
}
this.notifications = Object.values(await res.json());
},
async remove(id) {
const token = document.querySelector('#token').getAttribute('token');
await fetch(`/delete/notification/?delete=1&notification_id=${id}&token=${token}`);
this.notifications = this.notifications.filter((notification) => notification.ID != id);
if (this.notifications.length === 0) {
this.open = false;
}
},
async removeAll() {
const token = document.querySelector('#token').getAttribute('token');
await fetch(`/delete/notification/?delete=1&token=${token}`);
this.notifications = [];
this.open = false;
},
}));
}

View File

@@ -0,0 +1,16 @@
import { createConfirmationDialog } from './helpers';
// Listen to .js-confirm-action links and intercept clicks with a confirmation dialog
export default function handleConfirmAction() {
document.querySelectorAll('.js-confirm-action').forEach((triggerLink) => {
triggerLink.addEventListener('click', (evt) => {
evt.preventDefault();
const title = triggerLink.dataset.confirmTitle;
const message = triggerLink.dataset.confirmMessage;
const targetUrl = triggerLink.getAttribute('href');
createConfirmationDialog({ title, message, targetUrl, spinner: true });
});
});
}

27
web/js/src/copyCreds.js Normal file
View File

@@ -0,0 +1,27 @@
import { debounce } from './helpers';
// Monitor "Account" and "Password" inputs on "Add/Edit Mail Account"
// page and update the sidebar "Account" and "Password" output
export default function handleCopyCreds() {
monitorAndUpdate('.js-account-input', '.js-account-output');
monitorAndUpdate('.js-password-input', '.js-password-output');
}
function monitorAndUpdate(inputSelector, outputSelector) {
const inputElement = document.querySelector(inputSelector);
const outputElement = document.querySelector(outputSelector);
if (!inputElement || !outputElement) {
return;
}
function updateOutput(value) {
outputElement.textContent = value;
}
inputElement.addEventListener(
'input',
debounce((evt) => updateOutput(evt.target.value))
);
updateOutput(inputElement.value);
}

View File

@@ -0,0 +1,25 @@
// Copies values from cron generator fields to main cron fields when "Generate" is clicked
export default function handleCronGenerator() {
document.querySelectorAll('.js-generate-cron').forEach((button) => {
button.addEventListener('click', () => {
const fieldset = button.closest('fieldset');
const inputNames = ['min', 'hour', 'day', 'month', 'wday'];
inputNames.forEach((inputName) => {
const value = fieldset.querySelector(`[name=h_${inputName}]`).value;
const formInput = document.querySelector(`#main-form input[name=v_${inputName}]`);
formInput.value = value;
formInput.classList.add('highlighted');
formInput.addEventListener(
'transitionend',
() => {
formInput.classList.remove('highlighted');
},
{ once: true }
);
});
});
});
}

View File

@@ -0,0 +1,44 @@
import { debounce } from './helpers';
// Attach listener to database "Name" and "Username" fields to update their hints
export default function handleDatabaseHints() {
const usernameInput = document.querySelector('.js-db-hint-username');
const databaseNameInput = document.querySelector('.js-db-hint-database-name');
if (!usernameInput || !databaseNameInput) {
return;
}
removeUserPrefix(databaseNameInput);
attachUpdateHintListener(usernameInput);
attachUpdateHintListener(databaseNameInput);
}
// Remove prefix from "Database" input if it exists during initial load (for editing)
function removeUserPrefix(input) {
const prefixIndex = input.value.indexOf(Alpine.store('globals').USER_PREFIX);
if (prefixIndex === 0) {
input.value = input.value.slice(Alpine.store('globals').USER_PREFIX.length);
}
}
function attachUpdateHintListener(input) {
if (input.value.trim() !== '') {
updateHint(input);
}
input.addEventListener(
'input',
debounce((evt) => updateHint(evt.target))
);
}
function updateHint(input) {
const hintElement = input.parentElement.querySelector('.hint');
if (input.value.trim() === '') {
hintElement.textContent = '';
}
hintElement.textContent = Alpine.store('globals').USER_PREFIX + input.value;
}

View File

@@ -0,0 +1,30 @@
// "Discard all mail" checkbox behavior on Add/Edit Mail Account pages
export default function handleDiscardAllMail() {
const discardAllMailCheckbox = document.querySelector('.js-discard-all-mail');
if (!discardAllMailCheckbox) {
return;
}
discardAllMailCheckbox.addEventListener('click', () => {
const forwardToTextarea = document.querySelector('.js-forward-to-textarea');
const doNotStoreCheckbox = document.querySelector('.js-do-not-store-checkbox');
if (discardAllMailCheckbox.checked) {
// Disable "Forward to" textarea
forwardToTextarea.disabled = true;
// Check "Do not store forwarded mail" checkbox
doNotStoreCheckbox.checked = true;
// Hide "Do not store forwarded mail" checkbox container
doNotStoreCheckbox.parentElement.classList.add('u-hidden');
} else {
// Enable "Forward to" textarea
forwardToTextarea.disabled = false;
// Show "Do not store forwarded mail" checkbox container
doNotStoreCheckbox.parentElement.classList.remove('u-hidden');
}
});
}

View File

@@ -0,0 +1,49 @@
import { debounce } from './helpers';
// Attach listener to DNS "Record" field to update its hint
export default function handleDnsRecordHint() {
const recordInput = document.querySelector('.js-dns-record-input');
if (!recordInput) {
return;
}
if (recordInput.value.trim() != '') {
updateHint(recordInput);
}
recordInput.addEventListener(
'input',
debounce((evt) => updateHint(evt.target))
);
}
// Update DNS "Record" field hint
function updateHint(input) {
const domainInput = document.querySelector('.js-dns-record-domain');
const hintElement = input.parentElement.querySelector('.hint');
let hint = input.value.trim();
// Clear the hint if input is empty
if (hint === '') {
hintElement.textContent = '';
return;
}
// Set domain name without rec in case of @ entries
if (hint === '@') {
hint = '';
}
// Don't show prefix if domain name equals rec value
if (hint === domainInput.value) {
hint = '';
}
// Add dot at the end if needed
if (hint !== '' && hint.slice(-1) !== '.') {
hint += '.';
}
hintElement.textContent = hint + domainInput.value;
}

29
web/js/src/docRootHint.js Normal file
View File

@@ -0,0 +1,29 @@
import { debounce } from './helpers';
// Handle "Custom document root -> Directory" hint on Edit Web Domain page
export default function handleDocRootHint() {
const domainSelect = document.querySelector('.js-custom-docroot-domain');
const dirInput = document.querySelector('.js-custom-docroot-dir');
const prepathHiddenInput = document.querySelector('.js-custom-docroot-prepath');
const docRootHint = document.querySelector('.js-custom-docroot-hint');
if (!domainSelect || !dirInput || !prepathHiddenInput || !docRootHint) {
return;
}
// Set initial hint on page load
updateDocRootHint();
// Add input listeners
dirInput.addEventListener('input', debounce(updateDocRootHint));
domainSelect.addEventListener('change', updateDocRootHint);
// Update hint value
function updateDocRootHint() {
const prepath = prepathHiddenInput.value;
const domain = domainSelect.value;
const folder = dirInput.value;
docRootHint.textContent = `${prepath}${domain}/public_html/${folder}`;
}
}

View File

@@ -0,0 +1,66 @@
// Simple hide/show input listeners specific to Edit Web Domain form
// TODO: Replace these with Alpine.js usage consistently
// NOTE: Some functions use inline styles, as Alpine.js also uses them
export default function handleEditWebListeners() {
// Listen to "Web Statistics" select menu to hide/show
// "Statistics Authorization" checkbox and inner fields
const statsSelect = document.querySelector('.js-stats-select');
const statsAuthContainers = document.querySelectorAll('.js-stats-auth');
if (statsSelect && statsAuthContainers.length) {
statsSelect.addEventListener('change', () => {
if (statsSelect.value === 'none') {
statsAuthContainers.forEach((container) => {
container.style.display = 'none';
});
} else {
statsAuthContainers.forEach((container) => {
container.style.display = 'block';
});
}
});
}
// Listen to "Enable domain redirection" radio items to show
// additional inputs if radio with value "custom" is selected
document.querySelectorAll('.js-redirect-custom-value').forEach((element) => {
element.addEventListener('change', () => {
const customRedirectFields = document.querySelector('.js-custom-redirect-fields');
if (customRedirectFields) {
if (element.value === 'custom') {
customRedirectFields.classList.remove('u-hidden');
} else {
customRedirectFields.classList.add('u-hidden');
}
}
});
});
// Listen to "Use Lets Encrypt to obtain SSL certificate" checkbox to
// hide/show SSL textareas
const toggleLetsEncryptCheckbox = document.querySelector('.js-toggle-lets-encrypt');
const sslDetails = document.querySelector('.js-ssl-details');
if (toggleLetsEncryptCheckbox && sslDetails) {
toggleLetsEncryptCheckbox.addEventListener('change', () => {
if (toggleLetsEncryptCheckbox.checked) {
sslDetails.style.display = 'none';
} else {
sslDetails.style.display = 'block';
}
});
}
// Listen to "Advanced Options -> Proxy Template" select menu to
// show "Purge Nginx Cache" button if "caching" selected
const proxyTemplateSelect = document.querySelector('.js-proxy-template-select');
const clearCacheButton = document.querySelector('.js-clear-cache-button');
if (proxyTemplateSelect && clearCacheButton) {
proxyTemplateSelect.addEventListener('change', () => {
// NOTE: Match "caching" and "caching-*" values
if (proxyTemplateSelect.value === 'caching' || proxyTemplateSelect.value.match(/^caching-/)) {
clearCacheButton.classList.remove('u-hidden');
} else {
clearCacheButton.classList.add('u-hidden');
}
});
}
}

View File

@@ -0,0 +1,10 @@
import { createConfirmationDialog } from './helpers';
// Displays page error message/notice in a confirmation dialog
export default function handleErrorMessage() {
const errorMessage = Alpine.store('globals').ERROR_MESSAGE;
if (errorMessage) {
createConfirmationDialog({ message: errorMessage });
}
}

View File

@@ -0,0 +1,13 @@
// If no dialog is open, focus first input in main content form
// TODO: Replace this with autofocus attributes in the HTML
export default function focusFirstInput() {
const openDialogs = document.querySelectorAll('dialog[open]');
if (openDialogs.length === 0) {
const input = document.querySelector(
'#main-form .form-control:not([disabled]), #main-form .form-select:not([disabled])'
);
if (input) {
input.focus();
}
}
}

37
web/js/src/formSubmit.js Normal file
View File

@@ -0,0 +1,37 @@
import { enableUnlimitedInputs } from './unlimitedInput';
import { updateAdvancedTextarea } from './toggleAdvanced';
import { showSpinner } from './helpers';
export default function handleFormSubmit() {
const mainForm = document.querySelector('#main-form');
if (mainForm) {
mainForm.addEventListener('submit', () => {
// Show loading spinner
showSpinner();
// Enable any disabled inputs to ensure all fields are submitted
if (mainForm.classList.contains('js-enable-inputs-on-submit')) {
document.querySelectorAll('input[disabled]').forEach((input) => {
input.disabled = false;
});
}
// Enable any disabled unlimited inputs and set their value to "unlimited"
enableUnlimitedInputs();
// Update the "advanced options" textarea with "basic options" input values
const basicOptionsWrapper = document.querySelector('.js-basic-options');
if (basicOptionsWrapper && !basicOptionsWrapper.classList.contains('u-hidden')) {
updateAdvancedTextarea();
}
});
}
const bulkEditForm = document.querySelector('[x-bind="BulkEdit"]');
if (bulkEditForm) {
bulkEditForm.addEventListener('submit', () => {
// Show loading spinner
showSpinner();
});
}
}

View File

@@ -0,0 +1,54 @@
import { debounce } from './helpers';
// Attach event listeners to FTP account "Username" and "Path" fields to update their hints
export default function handleFtpAccountHints() {
addHintListeners('.js-ftp-user', updateFtpUsernameHint);
addHintListeners('.js-ftp-path', updateFtpPathHint);
}
function addHintListeners(selector, updateHintFunction) {
document.querySelectorAll(selector).forEach((inputElement) => {
const currentValue = inputElement.value.trim();
if (currentValue !== '') {
updateHintFunction(inputElement, currentValue);
}
inputElement.addEventListener(
'input',
debounce((event) => updateHintFunction(event.target, event.target.value))
);
});
}
function updateFtpUsernameHint(usernameInput, username) {
const inputWrapper = usernameInput.parentElement;
const hintElement = inputWrapper.querySelector('.js-ftp-user-hint');
// Remove special characters
const sanitizedUsername = username.replace(/[^\w\d]/gi, '');
if (sanitizedUsername !== username) {
usernameInput.value = sanitizedUsername;
}
hintElement.textContent = Alpine.store('globals').USER_PREFIX + sanitizedUsername;
}
function updateFtpPathHint(pathInput, path) {
const inputWrapper = pathInput.parentElement;
const hintElement = inputWrapper.querySelector('.js-ftp-path-hint');
const normalizedPath = normalizePath(path);
hintElement.textContent = normalizedPath;
}
function normalizePath(path) {
// Add leading slash
if (path[0] !== '/') {
path = '/' + path;
}
// Remove double slashes
return path.replace(/\/(\/+)/g, '/');
}

156
web/js/src/ftpAccounts.js Normal file
View File

@@ -0,0 +1,156 @@
import handleFtpAccountHints from './ftpAccountHints';
import { debounce, randomPassword } from './helpers';
// Add/remove FTP accounts on Edit Web Domain page
export default function handleFtpAccounts() {
// Listen to FTP user "Password" field changes and insert
// "Send FTP credentials to email" field if it doesn't exist
handlePasswordInputChange();
// Listen to FTP user "Password" generate button clicks and generate a random password
// Also insert "Send FTP credentials to email" field if it doesn't exist
handleGeneratePasswordClick();
// Listen to "Add FTP account" button click and add new FTP account form
handleAddAccountClick();
// Listen to FTP account "Delete" button clicks and delete FTP account
handleDeleteAccountClick();
// Listen to "Additional FTP account(s)" checkbox and show/hide FTP accounts section
handleToggleFtpAccountsCheckbox();
}
function handlePasswordInputChange() {
document.querySelectorAll('.js-ftp-user-psw').forEach((ftpPasswordInput) => {
ftpPasswordInput.addEventListener(
'input',
debounce((evt) => insertEmailField(evt.target))
);
});
}
function handleGeneratePasswordClick() {
document.querySelectorAll('.js-ftp-password-generate').forEach((generateButton) => {
generateButton.addEventListener('click', () => {
const ftpPasswordInput =
generateButton.parentElement.parentElement.querySelector('.js-ftp-user-psw');
ftpPasswordInput.value = randomPassword();
insertEmailField(ftpPasswordInput);
});
});
}
function handleAddAccountClick() {
const addFtpAccountButton = document.querySelector('.js-add-ftp-account');
if (addFtpAccountButton) {
addFtpAccountButton.addEventListener('click', () => {
const template = document
.querySelector('.js-ftp-account-template .js-ftp-account-nrm')
.cloneNode(true);
const ftpAccounts = document.querySelectorAll('.js-active-ftp-accounts .js-ftp-account');
const newIndex = ftpAccounts.length;
template.querySelectorAll('input').forEach((input) => {
const name = input.getAttribute('name');
const id = input.getAttribute('id');
input.setAttribute('name', name.replace('%INDEX%', newIndex));
if (id) {
input.setAttribute('id', id.replace('%INDEX%', newIndex));
}
});
template.querySelectorAll('input + label').forEach((label) => {
const forAttr = label.getAttribute('for');
label.setAttribute('for', forAttr.replace('%INDEX%', newIndex));
});
template.querySelector('.js-ftp-user-number').textContent = newIndex;
document.querySelector('.js-active-ftp-accounts').appendChild(template);
updateUserNumbers();
// Refresh input listeners
handleFtpAccountHints();
handleGeneratePasswordClick();
handleDeleteAccountClick();
});
}
}
function handleDeleteAccountClick() {
document.querySelectorAll('.js-delete-ftp-account').forEach((deleteButton) => {
deleteButton.addEventListener('click', () => {
const ftpAccount = deleteButton.closest('.js-ftp-account');
ftpAccount.querySelector('.js-ftp-user-deleted').value = '1';
if (ftpAccount.querySelector('.js-ftp-user-is-new').value == 1) {
return ftpAccount.remove();
}
ftpAccount.classList.remove('js-ftp-account-nrm');
ftpAccount.style.display = 'none';
updateUserNumbers();
if (document.querySelectorAll('.js-active-ftp-accounts .js-ftp-account-nrm').length == 0) {
document.querySelector('.js-add-ftp-account').style.display = 'none';
document.querySelector('input[name="v_ftp"]').checked = false;
}
});
});
}
function updateUserNumbers() {
const ftpUserNumbers = document.querySelectorAll('.js-active-ftp-accounts .js-ftp-user-number');
ftpUserNumbers.forEach((number, index) => {
number.textContent = index + 1;
});
}
function handleToggleFtpAccountsCheckbox() {
const toggleFtpAccountsCheckbox = document.querySelector('.js-toggle-ftp-accounts');
if (!toggleFtpAccountsCheckbox) {
return;
}
toggleFtpAccountsCheckbox.addEventListener('change', (evt) => {
const isChecked = evt.target.checked;
const addFtpAccountButton = document.querySelector('.js-add-ftp-account');
const ftpAccounts = document.querySelectorAll('.js-ftp-account-nrm');
addFtpAccountButton.style.display = isChecked ? 'block' : 'none';
ftpAccounts.forEach((ftpAccount) => {
const usernameInput = ftpAccount.querySelector('.js-ftp-user');
const hiddenUserDeletedInput = ftpAccount.querySelector('.js-ftp-user-deleted');
if (usernameInput.value.trim() !== '') {
hiddenUserDeletedInput.value = isChecked ? '0' : '1';
}
ftpAccount.style.display = isChecked ? 'block' : 'none';
});
});
}
// Insert "Send FTP credentials to email" field if not present in FTP account
function insertEmailField(ftpPasswordInput) {
const accountWrapper = ftpPasswordInput.closest('.js-ftp-account');
if (accountWrapper.querySelector('.js-email-alert-on-psw')) {
return;
}
const hiddenIsNewInput = accountWrapper.querySelector('.js-ftp-user-is-new');
const inputName = hiddenIsNewInput.name.replace('is_new', 'v_ftp_email');
const emailFieldHTML = `
<div class="u-pl30 u-mb10">
<label for="${inputName}" class="form-label">
Send FTP credentials to email
</label>
<input type="email" class="form-control js-email-alert-on-psw"
value="" name="${inputName}" id="${inputName}">
</div>`;
accountWrapper.insertAdjacentHTML('beforeend', emailFieldHTML);
}

138
web/js/src/helpers.js Normal file
View File

@@ -0,0 +1,138 @@
import { customAlphabet } from 'nanoid';
// Generates a random password that always passes password requirements
export function randomPassword(length = 16) {
const uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const lowercase = 'abcdefghijklmnopqrstuvwxyz';
const numbers = '0123456789';
const symbols = '!@#$%^&*()_+-=[]{}|;:/?';
const allCharacters = uppercase + lowercase + numbers + symbols;
const generate = customAlphabet(allCharacters, length);
let password;
do {
password = generate();
// Must contain at least one uppercase letter, one lowercase letter, and one number
} while (!(/[a-z]/.test(password) && /[A-Z]/.test(password) && /\d/.test(password)));
return password;
}
// Debounces a function to avoid excessive calls
export function debounce(func, wait = 100) {
let timeout;
return function (...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
// Returns the value of a CSS variable
export function getCssVariable(variableName) {
return getComputedStyle(document.documentElement).getPropertyValue(variableName).trim();
}
// Shows the loading spinner overlay
export function showSpinner() {
document.querySelector('.js-spinner').classList.add('active');
}
// Parses and sorts IP lists from HTML
export function parseAndSortIpLists(ipListsData) {
const ipLists = JSON.parse(ipListsData || '[]');
return ipLists.sort((a, b) => a.name.localeCompare(b.name));
}
// Posts data to the given URL and returns the response
export async function post(url, data, headers = {}) {
const requestOptions = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...headers,
},
body: JSON.stringify(data),
};
const response = await fetch(url, requestOptions);
if (!response.ok) {
throw new Error(`HTTP error ${response.status}: ${response.statusText}`);
}
return response.json();
}
// Creates a confirmation <dialog> on the fly
export function createConfirmationDialog({
title,
message = 'Are you sure?',
targetUrl,
spinner = false,
}) {
// Create the dialog
const dialog = document.createElement('dialog');
dialog.classList.add('modal');
// Create and insert the title
if (title) {
const titleElement = document.createElement('h2');
titleElement.innerHTML = title;
titleElement.classList.add('modal-title');
dialog.append(titleElement);
}
// Create and insert the message
const messageElement = document.createElement('p');
messageElement.innerHTML = message;
messageElement.classList.add('modal-message');
dialog.append(messageElement);
// Create and insert the options
const optionsElement = document.createElement('div');
optionsElement.classList.add('modal-options');
const confirmButton = document.createElement('button');
confirmButton.type = 'submit';
confirmButton.classList.add('button');
confirmButton.textContent = 'OK';
optionsElement.append(confirmButton);
const cancelButton = document.createElement('button');
cancelButton.type = 'button';
cancelButton.classList.add('button', 'button-secondary', 'u-ml5');
cancelButton.textContent = 'Cancel';
if (targetUrl) {
optionsElement.append(cancelButton);
}
dialog.append(optionsElement);
// Define named functions to handle the event listeners
const handleConfirm = () => {
if (targetUrl) {
if (spinner) {
showSpinner();
}
window.location.href = targetUrl;
}
handleClose();
};
const handleCancel = () => handleClose();
const handleClose = () => {
confirmButton.removeEventListener('click', handleConfirm);
cancelButton.removeEventListener('click', handleCancel);
dialog.removeEventListener('close', handleClose);
dialog.remove();
};
// Add event listeners
confirmButton.addEventListener('click', handleConfirm);
cancelButton.addEventListener('click', handleCancel);
dialog.addEventListener('close', handleClose);
// Add to DOM and show
document.body.append(dialog);
dialog.showModal();
}

62
web/js/src/index.js Normal file
View File

@@ -0,0 +1,62 @@
import alpineInit from './alpineInit';
import focusFirstInput from './focusFirstInput';
import handleAddIpLists from './addIpLists';
import handleConfirmAction from './confirmAction';
import handleCopyCreds from './copyCreds';
import handleCronGenerator from './cronGenerator';
import handleDatabaseHints from './databaseHints';
import handleDiscardAllMail from './discardAllMail';
import handleDnsRecordHint from './dnsRecordHint';
import handleDocRootHint from './docRootHint';
import handleEditWebListeners from './editWebListeners';
import handleErrorMessage from './errorHandler';
import handleFormSubmit from './formSubmit';
import handleFtpAccountHints from './ftpAccountHints';
import handleFtpAccounts from './ftpAccounts';
import handleIpListDataSource from './ipListDataSource';
import handleListSorting from './listSorting';
import handleListUnitSelect from './listUnitSelect';
import handleNameServerInput from './nameServerInput';
import handlePasswordInput from './passwordInput';
import handleShortcuts from './shortcuts';
import handleStickyToolbar from './stickyToolbar';
import handleSyncEmailValues from './syncEmailValues';
import handleTabPanels from './tabPanels';
import handleToggleAdvanced from './toggleAdvanced';
import handleUnlimitedInput from './unlimitedInput';
import initRrdCharts from './rrdCharts';
initListeners();
focusFirstInput();
function initListeners() {
handleAddIpLists();
handleConfirmAction();
handleCopyCreds();
handleCronGenerator();
handleDiscardAllMail();
handleDnsRecordHint();
handleDocRootHint();
handleEditWebListeners();
handleFormSubmit();
handleFtpAccounts();
handleListSorting();
handleListUnitSelect();
handleNameServerInput();
handlePasswordInput();
handleStickyToolbar();
handleSyncEmailValues();
handleTabPanels();
handleToggleAdvanced();
initRrdCharts();
}
document.addEventListener('alpine:init', () => {
alpineInit();
handleDatabaseHints();
handleErrorMessage();
handleFtpAccountHints();
handleIpListDataSource();
handleShortcuts();
handleUnlimitedInput();
});

View File

@@ -0,0 +1,38 @@
import { parseAndSortIpLists } from './helpers';
// Populates the "Data Source" select with various IP lists on the New IP List page
export default function handleIpListDataSource() {
const dataSourceSelect = document.querySelector('.js-datasource-select');
if (!dataSourceSelect) {
return;
}
// Parse IP lists from HTML and sort them alphabetically
const countryIpLists = parseAndSortIpLists(dataSourceSelect.dataset.countryIplists);
const blacklistIpLists = parseAndSortIpLists(dataSourceSelect.dataset.blacklistIplists);
// Add IP lists to the "Data Source" select
addIPListsToSelect(dataSourceSelect, Alpine.store('globals').BLACKLIST, blacklistIpLists);
addIPListsToSelect(dataSourceSelect, Alpine.store('globals').IPVERSE, countryIpLists);
}
function addIPListsToSelect(dataSourceSelect, label, ipLists) {
// Add a disabled option as a label
addOption(dataSourceSelect, label, '', true);
// Add IP lists to the select element
ipLists.forEach((ipList) => {
addOption(dataSourceSelect, ipList.name, ipList.source, false);
});
}
function addOption(element, text, value, disabled) {
const option = document.createElement('option');
option.text = text;
option.value = value;
if (disabled) {
option.disabled = true;
}
element.append(option);
}

75
web/js/src/listSorting.js Normal file
View File

@@ -0,0 +1,75 @@
// List view sorting dropdown
export default function handleListSorting() {
const state = {
sort_par: 'sort-name',
sort_direction: -1,
sort_as_int: false,
};
const toggleButton = document.querySelector('.js-toggle-sorting-menu');
const sortingMenu = document.querySelector('.js-sorting-menu');
const unitsContainer = document.querySelector('.js-units-container');
if (!toggleButton || !sortingMenu || !unitsContainer) {
return;
}
// Toggle dropdown button
toggleButton.addEventListener('click', () => {
sortingMenu.classList.toggle('u-hidden');
});
// "Click outside" to close dropdown
document.addEventListener('click', (event) => {
const isClickInside = sortingMenu.contains(event.target) || toggleButton.contains(event.target);
if (!isClickInside && !sortingMenu.classList.contains('u-hidden')) {
sortingMenu.classList.add('u-hidden');
}
});
// Inner dropdown sorting behavior
sortingMenu.querySelectorAll('span').forEach((span) => {
span.addEventListener('click', function () {
sortingMenu.classList.add('u-hidden');
// Skip if the clicked sort is already active
if (span.classList.contains('active')) {
return;
}
// Remove 'active' class from all spans and add it to the clicked span
sortingMenu.querySelectorAll('span').forEach((s) => {
s.classList.remove('active');
});
span.classList.add('active');
// Update state with new sorting parameters
const parentLi = span.closest('li');
state.sort_par = parentLi.dataset.entity;
state.sort_as_int = Boolean(parentLi.dataset.sortAsInt);
state.sort_direction = span.classList.contains('up') ? 1 : -1;
// Update toggle button text and icon
toggleButton.querySelector('span').innerHTML = parentLi.querySelector('.name').innerHTML;
const faIcon = toggleButton.querySelector('.fas');
faIcon.classList.remove('fa-arrow-up-a-z', 'fa-arrow-down-a-z');
faIcon.classList.add(span.classList.contains('up') ? 'fa-arrow-up-a-z' : 'fa-arrow-down-a-z');
// Sort units and reattach them to the DOM
const units = Array.from(document.querySelectorAll('.js-unit')).sort((a, b) => {
const aAttr = a.getAttribute(`data-${state.sort_par}`);
const bAttr = b.getAttribute(`data-${state.sort_par}`);
if (state.sort_as_int) {
const aInt = Number.parseInt(aAttr);
const bInt = Number.parseInt(bAttr);
return aInt >= bInt ? state.sort_direction : state.sort_direction * -1;
}
return aAttr <= bAttr ? state.sort_direction : state.sort_direction * -1;
});
units.forEach((unit) => unitsContainer.appendChild(unit));
});
});
}

View File

@@ -0,0 +1,45 @@
// Select unit behavior
export default function handleListUnitSelect() {
const checkboxes = Array.from(document.querySelectorAll('.js-unit-checkbox'));
const units = checkboxes.map((checkbox) => checkbox.closest('.js-unit'));
const selectAllCheckbox = document.querySelector('.js-toggle-all-checkbox');
if (checkboxes.length === 0 || !selectAllCheckbox) {
return;
}
let lastCheckedIndex = null;
checkboxes.forEach((checkbox, index) => {
checkbox.addEventListener('click', (event) => {
const isChecked = checkbox.checked;
updateUnitSelection(units[index], isChecked);
if (event.shiftKey && lastCheckedIndex !== null) {
handleMultiSelect(checkboxes, units, index, lastCheckedIndex, isChecked);
}
lastCheckedIndex = index;
});
});
selectAllCheckbox.addEventListener('change', () => {
const isChecked = selectAllCheckbox.checked;
checkboxes.forEach((checkbox) => (checkbox.checked = isChecked));
units.forEach((unit) => updateUnitSelection(unit, isChecked));
});
}
function updateUnitSelection(unit, isChecked) {
unit.classList.toggle('selected', isChecked);
}
function handleMultiSelect(checkboxes, units, index, lastCheckedIndex, isChecked) {
const rangeStart = Math.min(index, lastCheckedIndex);
const rangeEnd = Math.max(index, lastCheckedIndex);
for (let i = rangeStart; i <= rangeEnd; i++) {
checkboxes[i].checked = isChecked;
updateUnitSelection(units[i], isChecked);
}
}

View File

@@ -0,0 +1,38 @@
// Attaches listeners to nameserver add and remove links to clone or remove the input
export default function handleNameServerInput() {
// Add new name server input
const addNsButton = document.querySelector('.js-add-ns');
if (addNsButton) {
addNsButton.addEventListener('click', () => addNsInput(addNsButton));
}
// Remove name server input
document.querySelectorAll('.js-remove-ns').forEach((removeNsElem) => {
removeNsElem.addEventListener('click', () => removeNsInput(removeNsElem));
});
}
function addNsInput(addNsButton) {
const currentNsInputs = document.querySelectorAll('input[name^=v_ns]');
const inputCount = currentNsInputs.length;
if (inputCount < 8) {
const template = currentNsInputs[0].parentElement.cloneNode(true);
const templateNsInput = template.querySelector('input');
templateNsInput.removeAttribute('value');
templateNsInput.name = `v_ns${inputCount + 1}`;
addNsButton.before(template);
}
if (inputCount === 7) {
addNsButton.classList.add('u-hidden');
}
}
function removeNsInput(removeNsElement) {
removeNsElement.parentElement.remove();
const currentNsInputs = document.querySelectorAll('input[name^=v_ns]');
currentNsInputs.forEach((input, index) => (input.name = `v_ns${index + 1}`));
document.querySelector('.js-add-ns').classList.remove('u-hidden');
}

119
web/js/src/navigation.js Normal file
View File

@@ -0,0 +1,119 @@
// Page navigation methods called by shortcuts
const state = {
active_menu: 1,
menu_selector: '.main-menu-item',
menu_active_selector: '.active',
};
export function moveFocusLeft() {
moveFocusLeftRight('left');
}
export function moveFocusRight() {
moveFocusLeftRight('right');
}
export function moveFocusDown() {
moveFocusUpDown('down');
}
export function moveFocusUp() {
moveFocusUpDown('up');
}
// Navigate to whatever item has been selected in the UI by other shortcuts
export function enterFocused() {
const activeMainMenuItem = document.querySelector(state.menu_selector + '.focus a');
if (activeMainMenuItem) {
return (location.href = activeMainMenuItem.getAttribute('href'));
}
const activeUnit = document.querySelector(
'.js-unit.focus .units-table-row-actions .shortcut-enter a'
);
if (activeUnit) {
location.href = activeUnit.getAttribute('href');
}
}
// Either click or follow a link based on the data-key-action attribute
export function executeShortcut(elm) {
const action = elm.dataset.keyAction;
if (action === 'js') {
return elm.querySelector('.data-controls').click();
}
if (action === 'href') {
location.href = elm.querySelector('a').getAttribute('href');
}
}
function moveFocusLeftRight(direction) {
const menuSelector = state.menu_selector;
const activeSelector = state.menu_active_selector;
const menuItems = Array.from(document.querySelectorAll(menuSelector));
const currentFocused = document.querySelector(`${menuSelector}.focus`);
const currentActive = document.querySelector(menuSelector + activeSelector);
let index = menuItems.indexOf(currentFocused);
if (index === -1) {
index = menuItems.indexOf(currentActive);
}
menuItems.forEach((item) => item.classList.remove('focus'));
if (direction === 'left') {
if (index > 0) {
menuItems[index - 1].classList.add('focus');
} else {
switchMenu('last');
}
} else if (direction === 'right') {
if (index < menuItems.length - 1) {
menuItems[index + 1].classList.add('focus');
} else {
switchMenu('first');
}
}
}
function moveFocusUpDown(direction) {
const units = Array.from(document.querySelectorAll('.js-unit'));
const currentFocused = document.querySelector('.js-unit.focus');
let index = units.indexOf(currentFocused);
if (index === -1) {
index = 0;
}
if (direction === 'up' && index > 0) {
index--;
} else if (direction === 'down' && index < units.length - 1) {
index++;
} else {
return;
}
if (currentFocused) {
currentFocused.classList.remove('focus');
}
units[index].classList.add('focus');
window.scrollTo({
top: units[index].getBoundingClientRect().top - 200 + window.scrollY,
behavior: 'smooth',
});
}
function switchMenu(position = 'first') {
if (state.active_menu === 0) {
state.active_menu = 1;
state.menu_selector = '.main-menu-item';
state.menu_active_selector = '.active';
const menuItems = document.querySelectorAll(state.menu_selector);
const focusedIndex = position === 'first' ? 0 : menuItems.length - 1;
menuItems[focusedIndex].classList.add('focus');
}
}

View File

@@ -0,0 +1,38 @@
import { passwordStrength } from 'check-password-strength';
import { randomPassword, debounce } from './helpers';
// Adds listeners to password inputs (to monitor strength) and generate password buttons
export default function handlePasswordInput() {
// Listen for changes to password inputs and update the password strength
document.querySelectorAll('.js-password-input').forEach((passwordInput) => {
passwordInput.addEventListener(
'input',
debounce((evt) => recalculatePasswordStrength(evt.target))
);
});
// Listen for clicks on generate password buttons and set a new random password
document.querySelectorAll('.js-generate-password').forEach((generatePasswordButton) => {
generatePasswordButton.addEventListener('click', () => {
const passwordInput =
generatePasswordButton.parentNode.nextElementSibling.querySelector('.js-password-input');
if (passwordInput) {
passwordInput.value = randomPassword();
passwordInput.dispatchEvent(new Event('input'));
}
});
});
}
function recalculatePasswordStrength(input) {
const password = input.value;
const meter = input.parentNode.querySelector('.js-password-meter');
if (meter) {
if (password === '') {
return (meter.value = 0);
}
meter.value = passwordStrength(password).id + 1;
}
}

108
web/js/src/rrdCharts.js Normal file
View File

@@ -0,0 +1,108 @@
import { post, getCssVariable } from './helpers';
// Create Chart.js charts from in-page data on Task Monitor page
export default async function initRrdCharts() {
const chartCanvases = document.querySelectorAll('.js-rrd-chart');
if (!chartCanvases.length) {
return;
}
const Chart = await loadChartJs();
for (const chartCanvas of chartCanvases) {
const service = chartCanvas.dataset.service;
const period = chartCanvas.dataset.period;
const rrdData = await post('/list/rrd/ajax.php', { service, period });
const chartData = prepareChartData(rrdData, period);
const chartOptions = getChartOptions(rrdData.unit);
new Chart(chartCanvas, {
type: 'line',
data: chartData,
options: chartOptions,
});
}
}
async function loadChartJs() {
// NOTE: String expression used to prevent ESBuild from resolving
// the import on build (Chart.js is a separate bundle)
const chartJsBundlePath = '/js/dist/chart.js-auto.min.js';
const chartJsModule = await import(`${chartJsBundlePath}`);
return chartJsModule.Chart;
}
function prepareChartData(rrdData, period) {
return {
labels: rrdData.data.map((_, index) => {
const timestamp = rrdData.meta.start + index * rrdData.meta.step;
const date = new Date(timestamp * 1000);
return formatLabel(date, period);
}),
datasets: rrdData.meta.legend.map((legend, legendIndex) => {
const lineColor = getCssVariable(`--chart-line-${legendIndex + 1}-color`);
return {
label: legend,
data: rrdData.data.map((dataPoint) => dataPoint[legendIndex]),
tension: 0.3,
pointStyle: false,
borderWidth: 2,
borderColor: lineColor,
};
}),
};
}
function formatLabel(date, period) {
const options = {
daily: { hour: '2-digit', minute: '2-digit' },
weekly: { weekday: 'short', day: 'numeric' },
monthly: { month: 'short', day: 'numeric' },
yearly: { month: 'long' },
biennially: { month: 'long', year: 'numeric' },
triennially: { month: 'long', year: 'numeric' },
};
return date.toLocaleString([], options[period]);
}
function getChartOptions(unit) {
const labelColor = getCssVariable('--chart-label-color');
const gridColor = getCssVariable('--chart-grid-color');
return {
plugins: {
legend: {
position: 'bottom',
labels: {
color: labelColor,
},
},
},
scales: {
x: {
ticks: {
color: labelColor,
},
grid: {
color: gridColor,
},
},
y: {
title: {
display: Boolean(unit),
text: unit,
color: labelColor,
},
ticks: {
color: labelColor,
},
grid: {
color: gridColor,
},
},
},
};
}

462
web/js/src/shortcuts.js Normal file
View File

@@ -0,0 +1,462 @@
import {
moveFocusLeft,
moveFocusRight,
moveFocusDown,
moveFocusUp,
enterFocused,
executeShortcut,
} from './navigation';
import { createConfirmationDialog } from './helpers';
/**
* Shortcuts
* @typedef {{ key: string, altKey?: boolean, ctrlKey?: boolean, metaKey?: boolean, shiftKey?: boolean }} KeyCombination
* @typedef {{ code: string, altKey?: boolean, ctrlKey?: boolean, metaKey?: boolean, shiftKey?: boolean }} CodeCombination
* @typedef {{ combination: KeyCombination, event: 'keydown' | 'keyup', callback: (evt: KeyboardEvent) => void, target: EventTarget }} RegisteredShortcut
* @typedef {{ type?: 'keydown' | 'keyup', propagate?: boolean, disabledInInput?: boolean, target?: EventTarget }} ShortcutOptions
*/
export default function handleShortcuts() {
Alpine.store('shortcuts', {
/**
* @type RegisteredShortcut[]
*/
registeredShortcuts: [],
/**
* @param {KeyCombination | CodeCombination} combination
* A combination using a `code` representing a physical key on the keyboard or a `key`
* representing the character generated by pressing the key. Modifiers can be added using the
* `altKey`, `ctrlKey`, `metaKey` or `shiftKey` parameters.
* @param {(evt: KeyboardEvent) => void} callback
* The callback function that will be called when the correct combination is pressed.
* @param {ShortcutOptions?} options
* An object of options, containing the event `type`, whether it will `propagate`, the `target`
* element, and whether it's `disabledInInput`.
* @returns {this} The `Shortcuts` object.
*/
register(combination, callback, options) {
/** @type ShortcutOptions */
const defaultOptions = {
type: 'keydown',
propagate: false,
disabledInInput: false,
target: document,
};
options = { ...defaultOptions, ...options };
/**
* @param {KeyboardEvent} evt
*/
const func = (evt) => {
if (options.disabledInInput) {
// Don't enable shortcut keys in input, textarea, select fields
const element = evt.target.nodeType === 3 ? evt.target.parentNode : evt.target;
if (['input', 'textarea', 'selectbox'].includes(element.tagName.toLowerCase())) {
return;
}
}
const validations = [
combination.code
? combination.code == evt.code
: combination.key.toLowerCase() == evt.key.toLowerCase(),
(combination.altKey && evt.altKey) || (!combination.altKey && !evt.altKey),
(combination.ctrlKey && evt.ctrlKey) || (!combination.ctrlKey && !evt.ctrlKey),
(combination.metaKey && evt.metaKey) || (!combination.metaKey && !evt.metaKey),
(combination.shiftKey && evt.shiftKey) || (!combination.shiftKey && !evt.shiftKey),
];
const valid = validations.filter(Boolean);
if (valid.length === validations.length) {
callback(evt);
if (!options.propagate) {
evt.stopPropagation();
evt.preventDefault();
}
}
};
this.registeredShortcuts.push({
combination,
callback: func,
target: options.target,
event: options.type,
});
options.target.addEventListener(options.type, func);
return this;
},
/**
* @param {KeyCombination | CodeCombination} combination
* A combination using a `code` representing a physical key on the keyboard or a `key`
* representing the character generated by pressing the key. Modifiers can be added using the
* `altKey`, `ctrlKey`, `metaKey` or `shiftKey` parameters.
* @returns {this} The `Shortcuts` object.
*/
unregister(combination) {
const shortcut = this.registeredShortcuts.find(
(shortcut) => JSON.stringify(shortcut.combination) == JSON.stringify(combination)
);
if (!shortcut) {
return;
}
this.registeredShortcuts = this.registeredShortcuts.filter(
(shortcut) => JSON.stringify(shortcut.combination) != JSON.stringify(combination)
);
shortcut.target.removeEventListener(shortcut.event, shortcut.callback, false);
return this;
},
});
Alpine.store('shortcuts')
.register(
{ key: 'A' },
(_evt) => {
const createButton = document.querySelector('a.js-button-create');
if (!createButton) {
return;
}
location.href = createButton.href;
},
{ disabledInInput: true }
)
.register(
{ key: 'A', ctrlKey: true, shiftKey: true },
(_evt) => {
const checked = document.querySelector('.js-unit-checkbox:eq(0)').checked;
document
.querySelectorAll('.js-unit')
.forEach((el) => el.classList.toggle('selected'), !checked);
document.querySelectorAll('.js-unit-checkbox').forEach((el) => (el.checked = !checked));
},
{ disabledInInput: true }
)
.register({ code: 'Enter', ctrlKey: true }, (_evt) => {
document.querySelector('#main-form').submit();
})
.register({ code: 'Backspace', ctrlKey: true }, (_evt) => {
const redirect = document.querySelector('a.js-button-back').href;
if (!redirect) {
return;
}
if (Alpine.store('form').dirty && redirect) {
createConfirmationDialog({
message: Alpine.store('globals').CONFIRM_LEAVE_PAGE,
targetUrl: redirect,
});
} else if (redirect) {
location.href = redirect;
}
})
.register(
{ key: 'F' },
(_evt) => {
const searchBox = document.querySelector('.js-search-input');
if (searchBox) {
searchBox.focus();
}
},
{ disabledInInput: true }
)
.register(
{ code: 'Digit1' },
(_evt) => {
const target = document.querySelector('.main-menu .main-menu-item:nth-of-type(1) a');
if (!target) {
return;
}
if (Alpine.store('form').dirty) {
createConfirmationDialog({
message: Alpine.store('globals').CONFIRM_LEAVE_PAGE,
targetUrl: target.href,
});
} else {
location.href = target.href;
}
},
{ disabledInInput: true }
)
.register(
{ code: 'Digit2' },
(_evt) => {
const target = document.querySelector('.main-menu .main-menu-item:nth-of-type(2) a');
if (!target) {
return;
}
if (Alpine.store('form').dirty) {
createConfirmationDialog({
message: Alpine.store('globals').CONFIRM_LEAVE_PAGE,
targetUrl: target.href,
});
} else {
location.href = target.href;
}
},
{ disabledInInput: true }
)
.register(
{ code: 'Digit3' },
(_evt) => {
const target = document.querySelector('.main-menu .main-menu-item:nth-of-type(3) a');
if (!target) {
return;
}
if (Alpine.store('form').dirty) {
createConfirmationDialog({
message: Alpine.store('globals').CONFIRM_LEAVE_PAGE,
targetUrl: target.href,
});
} else {
location.href = target.href;
}
},
{ disabledInInput: true }
)
.register(
{ code: 'Digit4' },
(_evt) => {
const target = document.querySelector('.main-menu .main-menu-item:nth-of-type(4) a');
if (!target) {
return;
}
if (Alpine.store('form').dirty) {
createConfirmationDialog({
message: Alpine.store('globals').CONFIRM_LEAVE_PAGE,
targetUrl: target.href,
});
} else {
location.href = target.href;
}
},
{ disabledInInput: true }
)
.register(
{ code: 'Digit5' },
(_evt) => {
const target = document.querySelector('.main-menu .main-menu-item:nth-of-type(5) a');
if (!target) {
return;
}
if (Alpine.store('form').dirty) {
createConfirmationDialog({
message: Alpine.store('globals').CONFIRM_LEAVE_PAGE,
targetUrl: target.href,
});
} else {
location.href = target.href;
}
},
{ disabledInInput: true }
)
.register(
{ code: 'Digit6' },
(_evt) => {
const target = document.querySelector('.main-menu .main-menu-item:nth-of-type(6) a');
if (!target) {
return;
}
if (Alpine.store('form').dirty) {
createConfirmationDialog({
message: Alpine.store('globals').CONFIRM_LEAVE_PAGE,
targetUrl: target.href,
});
} else {
location.href = target.href;
}
},
{ disabledInInput: true }
)
.register(
{ code: 'Digit7' },
(_evt) => {
const target = document.querySelector('.main-menu .main-menu-item:nth-of-type(7) a');
if (!target) {
return;
}
if (Alpine.store('form').dirty) {
createConfirmationDialog({
message: Alpine.store('globals').CONFIRM_LEAVE_PAGE,
targetUrl: target.href,
});
} else {
location.href = target.href;
}
},
{ disabledInInput: true }
)
.register(
{ key: 'H' },
(_evt) => {
const shortcutsDialog = document.querySelector('.shortcuts');
if (shortcutsDialog.open) {
shortcutsDialog.close();
} else {
shortcutsDialog.showModal();
}
},
{ disabledInInput: true }
)
.register({ code: 'Escape' }, (_evt) => {
const openDialog = document.querySelector('dialog[open]');
if (openDialog) {
openDialog.close();
}
document.querySelectorAll('input, checkbox, textarea, select').forEach((el) => el.blur());
})
.register(
{ code: 'ArrowLeft' },
(_evt) => {
moveFocusLeft();
},
{ disabledInInput: true }
)
.register(
{ code: 'ArrowRight' },
(_evt) => {
moveFocusRight();
},
{ disabledInInput: true }
)
.register(
{ code: 'ArrowDown' },
(_evt) => {
moveFocusDown();
},
{ disabledInInput: true }
)
.register(
{ code: 'ArrowUp' },
(_evt) => {
moveFocusUp();
},
{ disabledInInput: true }
)
.register(
{ key: 'L' },
(_evt) => {
const element = document.querySelector('.js-units-container .js-unit.focus .shortcut-l');
if (element) {
executeShortcut(element);
}
},
{ disabledInInput: true }
)
.register(
{ key: 'S' },
(_evt) => {
const element = document.querySelector('.js-units-container .js-unit.focus .shortcut-s');
if (element) {
executeShortcut(element);
}
},
{ disabledInInput: true }
)
.register(
{ key: 'W' },
(_evt) => {
const element = document.querySelector('.js-units-container .js-unit.focus .shortcut-w');
if (element) {
executeShortcut(element);
}
},
{ disabledInInput: true }
)
.register(
{ key: 'D' },
(_evt) => {
const element = document.querySelector('.js-units-container .js-unit.focus .shortcut-d');
if (element) {
executeShortcut(element);
}
},
{ disabledInInput: true }
)
.register(
{ key: 'R' },
(_evt) => {
const element = document.querySelector('.js-units-container .js-unit.focus .shortcut-r');
if (element) {
executeShortcut(element);
}
},
{ disabledInInput: true }
)
.register(
{ key: 'N' },
(_evt) => {
const element = document.querySelector('.js-units-container .js-unit.focus .shortcut-n');
if (element) {
executeShortcut(element);
}
},
{ disabledInInput: true }
)
.register(
{ key: 'U' },
(_evt) => {
const element = document.querySelector('.js-units-container .js-unit.focus .shortcut-u');
if (element) {
executeShortcut(element);
}
},
{ disabledInInput: true }
)
.register(
{ code: 'Delete' },
(_evt) => {
const element = document.querySelector(
'.js-units-container .js-unit.focus .shortcut-delete'
);
if (element) {
executeShortcut(element);
}
},
{ disabledInInput: true }
)
.register(
{ code: 'Enter' },
(evt) => {
if (evt.target.tagName === 'INPUT' && evt.target.form.id === 'main-form') {
evt.target.form.submit();
}
if (Alpine.store('form').dirty) {
if (document.querySelector('dialog[open]')) {
const dialog = document.querySelector('dialog[open]');
dialog.querySelector('button[type="submit"]').click();
} else {
createConfirmationDialog({
message: Alpine.store('globals').CONFIRM_LEAVE_PAGE,
targetUrl: document.querySelector('.main-menu-item.focus a').href,
});
}
} else if (document.querySelector('dialog[open]')) {
const dialog = document.querySelector('dialog[open]');
dialog.querySelector('button[type="submit"]').click();
} else {
const element = document.querySelector(
'.js-units-container .js-unit.focus .shortcut-enter'
);
if (element) {
executeShortcut(element);
} else {
enterFocused();
}
}
},
{ propagate: true }
);
}

View File

@@ -0,0 +1,22 @@
// Add class to (sticky) toolbar on list view pages when scrolling
export default function handleStickyToolbar() {
const toolbar = document.querySelector('.toolbar');
const header = document.querySelector('.top-bar');
if (!toolbar || !header) {
return;
}
window.addEventListener('scroll', addClassOnScroll);
function addClassOnScroll() {
const toolbarRectTop = toolbar.getBoundingClientRect().top;
const scrolledDistance = window.scrollY;
const clientTop = document.documentElement.clientTop;
const toolbarOffsetTop = toolbarRectTop + scrolledDistance - clientTop;
const headerHeight = header.offsetHeight;
const isToolbarActive = scrolledDistance > toolbarOffsetTop - headerHeight;
toolbar.classList.toggle('active', isToolbarActive);
}
}

View File

@@ -0,0 +1,23 @@
import { debounce } from './helpers';
// Synchronizes the "Email" input value with "Email login credentials to" input value
// based on the "Send welcome email" checkbox state on Add User page
export default function handleSyncEmailValues() {
const emailInput = document.querySelector('.js-sync-email-input');
const sendWelcomeEmailCheckbox = document.querySelector('.js-sync-email-checkbox');
const emailCredentialsToInput = document.querySelector('.js-sync-email-output');
if (!emailInput || !sendWelcomeEmailCheckbox || !emailCredentialsToInput) {
return;
}
function syncEmailValues() {
emailCredentialsToInput.value = sendWelcomeEmailCheckbox.checked ? emailInput.value : '';
}
emailInput.addEventListener(
'input',
debounce(() => syncEmailValues())
);
sendWelcomeEmailCheckbox.addEventListener('change', syncEmailValues);
}

31
web/js/src/tabPanels.js Normal file
View File

@@ -0,0 +1,31 @@
// Tabs behavior (used on cron pages)
export default function handleTabPanels() {
const tabs = document.querySelector('.js-tabs');
if (!tabs) {
return;
}
const tabItems = tabs.querySelectorAll('.tabs-item');
const panels = tabs.querySelectorAll('.tabs-panel');
tabItems.forEach((tab) => {
tab.addEventListener('click', (event) => {
// Reset state
panels.forEach((panel) => (panel.hidden = true));
tabItems.forEach((tab) => {
tab.setAttribute('aria-selected', false);
tab.setAttribute('tabindex', -1);
});
// Show the selected panel
const tabId = event.target.getAttribute('id');
const panel = document.querySelector(`[aria-labelledby="${tabId}"]`);
panel.hidden = false;
// Mark the selected tab as active
event.target.setAttribute('aria-selected', true);
event.target.setAttribute('tabindex', 0);
event.target.focus();
});
});
}

View File

@@ -0,0 +1,36 @@
// Add listeners to .js-toggle-options buttons
export default function handleToggleAdvanced() {
document.querySelectorAll('.js-toggle-options').forEach((toggleOptionsButton) => {
toggleOptionsButton.addEventListener('click', toggleAdvancedOptions);
});
}
// Toggle between basic and advanced options.
// When switching from basic to advanced, the textarea is updated with the values from the inputs
function toggleAdvancedOptions() {
const advancedOptionsWrapper = document.querySelector('.js-advanced-options');
const basicOptionsWrapper = document.querySelector('.js-basic-options');
if (advancedOptionsWrapper.classList.contains('u-hidden')) {
advancedOptionsWrapper.classList.remove('u-hidden');
basicOptionsWrapper.classList.add('u-hidden');
updateAdvancedTextarea();
} else {
advancedOptionsWrapper.classList.add('u-hidden');
basicOptionsWrapper.classList.remove('u-hidden');
}
}
// Update the "advanced options" textarea with "basic options" input values
export function updateAdvancedTextarea() {
const advancedTextarea = document.querySelector('.js-advanced-textarea');
const textInputs = document.querySelectorAll('#main-form input[type=text]');
textInputs.forEach((textInput) => {
const search = textInput.dataset.regexp;
const prevValue = textInput.dataset.prevValue;
textInput.setAttribute('data-prev-value', textInput.value);
const regexp = new RegExp(`(${search})(.+)(${prevValue})`);
advancedTextarea.value = advancedTextarea.value.replace(regexp, `$1$2${textInput.value}`);
});
}

View File

@@ -0,0 +1,60 @@
export default function handleUnlimitedInput() {
// Add listeners to "unlimited" input toggles
document.querySelectorAll('.js-unlimited-toggle').forEach((toggleButton) => {
const input = toggleButton.parentElement.querySelector('input');
if (isUnlimitedValue(input.value)) {
enableInput(input, toggleButton);
} else {
disableInput(input, toggleButton);
}
toggleButton.addEventListener('click', () => {
toggleInput(input, toggleButton);
});
});
}
// Called on form submit to enable any disabled unlimited inputs
export function enableUnlimitedInputs() {
document.querySelectorAll('input:disabled').forEach((input) => {
if (isUnlimitedValue(input.value)) {
input.disabled = false;
input.value = 'unlimited';
}
});
}
function isUnlimitedValue(value) {
const trimmedValue = value.trim();
return trimmedValue === 'unlimited' || trimmedValue === Alpine.store('globals').UNLIMITED;
}
function enableInput(input, toggleButton) {
toggleButton.classList.add('active');
input.dataset.prevValue = input.value;
input.value = Alpine.store('globals').UNLIMITED;
input.disabled = true;
}
function disableInput(input, toggleButton) {
toggleButton.classList.remove('active');
const previousValue = input.dataset.prevValue ? input.dataset.prevValue.trim() : null;
if (previousValue) {
input.value = previousValue;
}
if (isUnlimitedValue(input.value)) {
input.value = '0';
}
input.disabled = false;
}
function toggleInput(input, toggleButton) {
if (toggleButton.classList.contains('active')) {
disableInput(input, toggleButton);
} else {
enableInput(input, toggleButton);
}
}