Initial
This commit is contained in:
9
web/js/custom_scripts/README.txt
Normal file
9
web/js/custom_scripts/README.txt
Normal 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
25
web/js/src/addIpLists.js
Normal 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
75
web/js/src/alpineInit.js
Normal 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¬ification_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;
|
||||
},
|
||||
}));
|
||||
}
|
||||
16
web/js/src/confirmAction.js
Normal file
16
web/js/src/confirmAction.js
Normal 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
27
web/js/src/copyCreds.js
Normal 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);
|
||||
}
|
||||
25
web/js/src/cronGenerator.js
Normal file
25
web/js/src/cronGenerator.js
Normal 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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
44
web/js/src/databaseHints.js
Normal file
44
web/js/src/databaseHints.js
Normal 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;
|
||||
}
|
||||
30
web/js/src/discardAllMail.js
Normal file
30
web/js/src/discardAllMail.js
Normal 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');
|
||||
}
|
||||
});
|
||||
}
|
||||
49
web/js/src/dnsRecordHint.js
Normal file
49
web/js/src/dnsRecordHint.js
Normal 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
29
web/js/src/docRootHint.js
Normal 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}`;
|
||||
}
|
||||
}
|
||||
66
web/js/src/editWebListeners.js
Normal file
66
web/js/src/editWebListeners.js
Normal 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');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
10
web/js/src/errorHandler.js
Normal file
10
web/js/src/errorHandler.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
13
web/js/src/focusFirstInput.js
Normal file
13
web/js/src/focusFirstInput.js
Normal 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
37
web/js/src/formSubmit.js
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
54
web/js/src/ftpAccountHints.js
Normal file
54
web/js/src/ftpAccountHints.js
Normal 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
156
web/js/src/ftpAccounts.js
Normal 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
138
web/js/src/helpers.js
Normal 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
62
web/js/src/index.js
Normal 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();
|
||||
});
|
||||
38
web/js/src/ipListDataSource.js
Normal file
38
web/js/src/ipListDataSource.js
Normal 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
75
web/js/src/listSorting.js
Normal 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));
|
||||
});
|
||||
});
|
||||
}
|
||||
45
web/js/src/listUnitSelect.js
Normal file
45
web/js/src/listUnitSelect.js
Normal 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);
|
||||
}
|
||||
}
|
||||
38
web/js/src/nameServerInput.js
Normal file
38
web/js/src/nameServerInput.js
Normal 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
119
web/js/src/navigation.js
Normal 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');
|
||||
}
|
||||
}
|
||||
38
web/js/src/passwordInput.js
Normal file
38
web/js/src/passwordInput.js
Normal 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
108
web/js/src/rrdCharts.js
Normal 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
462
web/js/src/shortcuts.js
Normal 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 }
|
||||
);
|
||||
}
|
||||
22
web/js/src/stickyToolbar.js
Normal file
22
web/js/src/stickyToolbar.js
Normal 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);
|
||||
}
|
||||
}
|
||||
23
web/js/src/syncEmailValues.js
Normal file
23
web/js/src/syncEmailValues.js
Normal 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
31
web/js/src/tabPanels.js
Normal 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
36
web/js/src/toggleAdvanced.js
Normal file
36
web/js/src/toggleAdvanced.js
Normal 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}`);
|
||||
});
|
||||
}
|
||||
60
web/js/src/unlimitedInput.js
Normal file
60
web/js/src/unlimitedInput.js
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user