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

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);
}
}