Browse Source

Refactor JS (#3500)

- Fix a JS error
- Show no password strength when no password
- Extract cron generator JS to module
- Extract tabs JS to module
Alec Rust 2 years ago
parent
commit
74e147f0e1

+ 3 - 3
package.json

@@ -27,8 +27,8 @@
 	},
 	"devDependencies": {
 		"@prettier/plugin-php": "^0.19.4",
-		"@typescript-eslint/eslint-plugin": "^5.59.0",
-		"@typescript-eslint/parser": "^5.59.0",
+		"@typescript-eslint/eslint-plugin": "^5.59.1",
+		"@typescript-eslint/parser": "^5.59.1",
 		"cssnano": "^6.0.0",
 		"esbuild": "^0.17.18",
 		"eslint": "^8.39.0",
@@ -50,7 +50,7 @@
 		"stylelint": "^15.6.0",
 		"stylelint-config-standard": "^33.0.0",
 		"typescript": "^5.0.4",
-		"vitepress": "1.0.0-alpha.73",
+		"vitepress": "1.0.0-alpha.74",
 		"vue": "^3.2.47"
 	},
 	"browserslist": [

File diff suppressed because it is too large
+ 0 - 0
web/js/dist/main.min.js


File diff suppressed because it is too large
+ 0 - 1
web/js/dist/main.min.js.map


+ 0 - 2
web/js/pages/add_db.js

@@ -35,7 +35,6 @@ App.Listeners.DB.keypress_db_username = () => {
 		}, 100);
 	};
 
-	input.addEventListener('keypress', updateTimeout);
 	input.addEventListener('input', updateTimeout);
 };
 
@@ -54,7 +53,6 @@ App.Listeners.DB.keypress_db_databasename = () => {
 		}, 100);
 	};
 
-	input.addEventListener('keypress', updateTimeout);
 	input.addEventListener('input', updateTimeout);
 };
 

+ 0 - 1
web/js/pages/add_dns_rec.js

@@ -43,7 +43,6 @@ App.Listeners.DB.keypress_dns_rec_entry = () => {
 		}, 100);
 	};
 
-	input.addEventListener('keypress', updateTimeout);
 	input.addEventListener('input', updateTimeout);
 };
 

+ 0 - 13
web/js/pages/add_mail_acc.js

@@ -1,13 +0,0 @@
-$('#v_blackhole').on('click', function () {
-	if ($('#v_blackhole').is(':checked')) {
-		$('#v_fwd').prop('disabled', true);
-		$('#v_fwd_for').prop('checked', true);
-		$('#id_fwd_for').hide();
-	} else {
-		$('#v_fwd').prop('disabled', false);
-		$('#id_fwd_for').show();
-	}
-});
-
-Hestia.helpers.monitorAndUpdate('.js-account-input', '.js-account-output');
-Hestia.helpers.monitorAndUpdate('.js-password-input', '.js-password-output');

+ 1 - 1
web/js/pages/add_web.js

@@ -93,7 +93,7 @@ $(function () {
 	});
 });
 
-document.getElementById('vstobjects').addEventListener('submit', function () {
+document.querySelector('#vstobjects').addEventListener('submit', () => {
 	$('input[disabled]').each(function (i, elm) {
 		$(elm).removeAttr('disabled');
 	});

+ 0 - 48
web/js/pages/edit_cron.js

@@ -1,48 +0,0 @@
-const tabs = document.querySelector('.js-tabs');
-if (tabs) {
-	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();
-		});
-	});
-}
-
-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(`#vstobjects input[name=v_${inputName}]`);
-
-			formInput.value = value;
-			formInput.classList.add('highlighted');
-
-			formInput.addEventListener(
-				'transitionend',
-				() => {
-					formInput.classList.remove('highlighted');
-				},
-				{ once: true }
-			);
-		});
-	});
-});

+ 0 - 2
web/js/pages/edit_db.js

@@ -35,7 +35,6 @@ App.Listeners.DB.keypress_db_username = () => {
 		}, 100);
 	};
 
-	input.addEventListener('keypress', updateTimeout);
 	input.addEventListener('input', updateTimeout);
 };
 
@@ -59,7 +58,6 @@ App.Listeners.DB.keypress_db_databasename = () => {
 		}, 100);
 	};
 
-	input.addEventListener('keypress', updateTimeout);
 	input.addEventListener('input', updateTimeout);
 };
 

+ 0 - 1
web/js/pages/edit_dns_rec.js

@@ -43,7 +43,6 @@ App.Listeners.DB.keypress_dns_rec_entry = () => {
 		}, 100);
 	};
 
-	input.addEventListener('keypress', updateTimeout);
 	input.addEventListener('input', updateTimeout);
 };
 

+ 1 - 1
web/js/pages/edit_mail.js

@@ -1,4 +1,4 @@
-document.getElementById('vstobjects').addEventListener('submit', function () {
+document.querySelector('#vstobjects').addEventListener('submit', () => {
 	$('input[disabled]').each(function (i, elm) {
 		var copy_elm = $(elm).clone(true);
 		$(copy_elm).attr('type', 'hidden');

+ 0 - 13
web/js/pages/edit_mail_acc.js

@@ -1,13 +0,0 @@
-$('#v_blackhole').on('click', function () {
-	if ($('#v_blackhole').is(':checked')) {
-		$('#v_fwd').prop('disabled', true);
-		$('#v_fwd_for').prop('checked', true);
-		$('#id_fwd_for').hide();
-	} else {
-		$('#v_fwd').prop('disabled', false);
-		$('#id_fwd_for').show();
-	}
-});
-
-Hestia.helpers.monitorAndUpdate('.js-account-input', '.js-account-output');
-Hestia.helpers.monitorAndUpdate('.js-password-input', '.js-password-output');

+ 1 - 1
web/js/pages/edit_web.js

@@ -261,7 +261,7 @@ $(function () {
 		}
 	});
 
-	document.getElementById('vstobjects').addEventListener('submit', function () {
+	document.querySelector('#vstobjects').addEventListener('submit', () => {
 		$('input[disabled]').each(function (i, elm) {
 			var copy_elm = $(elm).clone(true);
 			$(copy_elm).attr('type', 'hidden');

+ 10 - 14
web/js/pages/list_rrd.js

@@ -1,9 +1,4 @@
-async function loadChartJs() {
-	const module = await import('/js/dist/chart.js-auto.min.js');
-	return module.Chart;
-}
-
-async function initCharts() {
+async function init() {
 	const Chart = await loadChartJs();
 	const chartCanvases = document.querySelectorAll('.js-rrd-chart');
 
@@ -22,6 +17,11 @@ async function initCharts() {
 	}
 }
 
+async function loadChartJs() {
+	const module = await import('/js/dist/chart.js-auto.min.js');
+	return module.Chart;
+}
+
 async function fetchRrdData(service, period) {
 	const response = await fetch('/list/rrd/ajax.php', {
 		method: 'POST',
@@ -40,7 +40,7 @@ function prepareChartData(rrdData, period) {
 			return formatLabel(date, period);
 		}),
 		datasets: rrdData.meta.legend.map((legend, legendIndex) => {
-			const lineColor = getCssVariable(`--chart-line-${legendIndex + 1}-color`);
+			const lineColor = Hestia.helpers.getCssVariable(`--chart-line-${legendIndex + 1}-color`);
 
 			return {
 				label: legend,
@@ -66,8 +66,8 @@ function formatLabel(date, period) {
 }
 
 function getChartOptions(unit) {
-	const labelColor = getCssVariable('--chart-label-color');
-	const gridColor = getCssVariable('--chart-grid-color');
+	const labelColor = Hestia.helpers.getCssVariable('--chart-label-color');
+	const gridColor = Hestia.helpers.getCssVariable('--chart-grid-color');
 
 	return {
 		plugins: {
@@ -104,8 +104,4 @@ function getChartOptions(unit) {
 	};
 }
 
-function getCssVariable(variableName) {
-	return getComputedStyle(document.documentElement).getPropertyValue(variableName).trim();
-}
-
-initCharts();
+init();

+ 61 - 0
web/js/src/copyCreds.js

@@ -0,0 +1,61 @@
+// 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;
+		generateMailCredentials();
+	}
+
+	inputElement.addEventListener('input', (event) => {
+		updateOutput(event.target.value);
+	});
+	updateOutput(inputElement.value);
+}
+
+// Update hidden input field with values from cloned email info panel
+function generateMailCredentials() {
+	const mailInfoPanel = document.querySelector('.js-mail-info');
+
+	if (!mailInfoPanel) return;
+
+	const formattedCredentials = emailCredentialsAsPlainText(mailInfoPanel.cloneNode(true));
+	document.querySelector('.js-hidden-credentials').value = formattedCredentials;
+}
+
+// Reformats cloned DOM email credentials into plain text
+function emailCredentialsAsPlainText(element) {
+	const headings = [...element.querySelectorAll('h2')];
+	const lists = [...element.querySelectorAll('ul')];
+
+	return headings
+		.map((heading, index) => {
+			const items = [...lists[index].querySelectorAll('li')];
+
+			const itemText = items
+				.map((item) => {
+					const label = item.querySelector('.values-list-label');
+					const value = item.querySelector('.values-list-value');
+					const valueLink = value.querySelector('a');
+
+					const valueText = valueLink ? valueLink.href : value.textContent;
+
+					return `${label.textContent}: ${valueText}`;
+				})
+				.join('\n');
+
+			return `${heading.textContent}\n${itemText}\n`;
+		})
+		.join('\n');
+}

+ 25 - 0
web/js/src/cronGenerator.js

@@ -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(`#vstobjects input[name=v_${inputName}]`);
+
+				formInput.value = value;
+				formInput.classList.add('highlighted');
+
+				formInput.addEventListener(
+					'transitionend',
+					() => {
+						formInput.classList.remove('highlighted');
+					},
+					{ once: true }
+				);
+			});
+		});
+	});
+}

+ 28 - 0
web/js/src/discardAllMail.js

@@ -0,0 +1,28 @@
+// "Discard all mail" checkbox behavior
+export default function handleDiscardAllMail() {
+	const discardAllMailCheckbox = document.querySelector('.js-discard-all-mail');
+
+	if (!discardAllMailCheckbox) return;
+
+	discardAllMailCheckbox.addEventListener('click', () => {
+		const forwardToTextarea = document.getElementById('v_fwd');
+		const doNotStoreCheckbox = document.getElementById('v_fwd_for');
+
+		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');
+		}
+	});
+}

+ 5 - 54
web/js/src/helpers.js

@@ -18,6 +18,11 @@ export function randomPassword(length = 16) {
 	return password;
 }
 
+// Returns the value of a CSS variable
+export function getCssVariable(variableName) {
+	return getComputedStyle(document.documentElement).getPropertyValue(variableName).trim();
+}
+
 // Creates a confirmation <dialog> on the fly
 export function createConfirmationDialog({ title, message = 'Are you sure?', targetUrl }) {
 	// Create the dialog
@@ -83,34 +88,6 @@ export function createConfirmationDialog({ title, message = 'Are you sure?', tar
 	dialog.showModal();
 }
 
-// Monitors an input field for change and updates another selector with the value
-export function monitorAndUpdate(inputSelector, outputSelector) {
-	const inputElement = document.querySelector(inputSelector);
-	const outputElement = document.querySelector(outputSelector);
-
-	if (!inputElement || !outputElement) {
-		return;
-	}
-
-	function updateOutput(value) {
-		outputElement.textContent = value;
-		generateMailCredentials();
-	}
-
-	inputElement.addEventListener('input', (event) => {
-		updateOutput(event.target.value);
-	});
-	updateOutput(inputElement.value);
-}
-
-// Updates hidden input field with values from cloned email info panel
-export function generateMailCredentials() {
-	const mailInfoPanel = document.querySelector('.js-mail-info');
-	if (!mailInfoPanel) return;
-	const formattedCredentials = emailCredentialsAsPlainText(mailInfoPanel.cloneNode(true));
-	document.querySelector('.js-hidden-credentials').value = formattedCredentials;
-}
-
 // Updates textarea with values from text inputs
 export function updateTextareaWithInputValues(textInputs, textarea) {
 	textInputs.forEach((textInput) => {
@@ -121,29 +98,3 @@ export function updateTextareaWithInputValues(textInputs, textarea) {
 		textarea.value = textarea.value.replace(regexp, `$1$2${textInput.value}`);
 	});
 }
-
-// Reformats cloned DOM email credentials into plain text
-export function emailCredentialsAsPlainText(element) {
-	const headings = [...element.querySelectorAll('h2')];
-	const lists = [...element.querySelectorAll('ul')];
-
-	return headings
-		.map((heading, index) => {
-			const items = [...lists[index].querySelectorAll('li')];
-
-			const itemText = items
-				.map((item) => {
-					const label = item.querySelector('.values-list-label');
-					const value = item.querySelector('.values-list-value');
-					const valueLink = value.querySelector('a');
-
-					const valueText = valueLink ? valueLink.href : value.textContent;
-
-					return `${label.textContent}: ${valueText}`;
-				})
-				.join('\n');
-
-			return `${heading.textContent}\n${itemText}\n`;
-		})
-		.join('\n');
-}

+ 8 - 0
web/js/src/main.js

@@ -1,6 +1,9 @@
 import alpineInit from './alpineInit.js';
 import focusFirstInput from './focusFirstInput.js';
 import handleConfirmationDialogs from './confirmationDialog.js';
+import handleCopyCreds from './copyCreds.js';
+import handleCronGenerator from './cronGenerator.js';
+import handleDiscardAllMail from './discardAllMail.js';
 import handleErrorMessage from './errorHandler.js';
 import handleListSelectAll from './listSelectAll.js';
 import handleListSorting from './listSorting.js';
@@ -9,6 +12,7 @@ import handleNameServerInput from './nameServerInput.js';
 import handlePasswordInput from './passwordInput.js';
 import handleShortcuts from './shortcuts.js';
 import handleStickyToolbar from './stickyToolbar.js';
+import handleTabPanels from './tabPanels.js';
 import handleToggleAdvanced from './toggleAdvanced.js';
 import handleUnlimitedInput from './unlimitedInput.js';
 import * as helpers from './helpers.js';
@@ -20,12 +24,16 @@ focusFirstInput();
 
 function initListeners() {
 	handleConfirmationDialogs();
+	handleCopyCreds();
+	handleCronGenerator();
+	handleDiscardAllMail();
 	handleListSelectAll();
 	handleListSorting();
 	handleLoadingSpinner();
 	handleNameServerInput();
 	handlePasswordInput();
 	handleStickyToolbar();
+	handleTabPanels();
 	handleToggleAdvanced();
 }
 

+ 7 - 7
web/js/src/passwordInput.js

@@ -1,5 +1,5 @@
 import { passwordStrength } from 'check-password-strength';
-import { randomPassword, generateMailCredentials } from './helpers.js';
+import { randomPassword } from './helpers.js';
 
 // Adds listeners to password inputs (to monitor strength) and generate password buttons
 export default function handlePasswordInput() {
@@ -12,11 +12,10 @@ export default function handlePasswordInput() {
 			}, 100);
 		};
 
-		passwordInput.addEventListener('keypress', updateTimeout);
 		passwordInput.addEventListener('input', updateTimeout);
 	});
 
-	// Listen for clicks on all js-generate-password buttons and generate a password
+	// 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 =
@@ -24,8 +23,6 @@ export default function handlePasswordInput() {
 			if (passwordInput) {
 				passwordInput.value = randomPassword();
 				passwordInput.dispatchEvent(new Event('input'));
-				recalculatePasswordStrength(passwordInput);
-				generateMailCredentials();
 			}
 		});
 	});
@@ -34,8 +31,11 @@ export default function handlePasswordInput() {
 function recalculatePasswordStrength(input) {
 	const password = input.value;
 	const meter = input.parentNode.querySelector('.js-password-meter');
+
 	if (meter) {
-		const strength = passwordStrength(password).id;
-		meter.value = strength + 1;
+		if (password === '') {
+			return (meter.value = 0);
+		}
+		meter.value = passwordStrength(password).id + 1;
 	}
 }

+ 6 - 25
web/js/pages/add_cron.js → web/js/src/tabPanels.js

@@ -1,5 +1,9 @@
-const tabs = document.querySelector('.js-tabs');
-if (tabs) {
+// 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) => {
@@ -23,26 +27,3 @@ if (tabs) {
 		});
 	});
 }
-
-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(`#vstobjects input[name=v_${inputName}]`);
-
-			formInput.value = value;
-			formInput.classList.add('highlighted');
-
-			formInput.addEventListener(
-				'transitionend',
-				() => {
-					formInput.classList.remove('highlighted');
-				},
-				{ once: true }
-			);
-		});
-	});
-});

+ 10 - 7
web/js/src/unlimitedInput.js

@@ -16,14 +16,17 @@ export default function handleUnlimitedInput() {
 
 	// Enable any disabled unlimited inputs before submitting
 	// the page form, and set their value to "unlimited"
-	document.querySelector('form').addEventListener('submit', () => {
-		document.querySelectorAll('input:disabled').forEach((input) => {
-			if (isUnlimitedValue(input.value)) {
-				input.disabled = false;
-				input.value = Alpine.store('globals').UNLIM_VALUE;
-			}
+	const pageForm = document.querySelector('#vstobjects');
+	if (pageForm) {
+		pageForm.addEventListener('submit', () => {
+			document.querySelectorAll('input:disabled').forEach((input) => {
+				if (isUnlimitedValue(input.value)) {
+					input.disabled = false;
+					input.value = Alpine.store('globals').UNLIM_VALUE;
+				}
+			});
 		});
-	});
+	}
 }
 
 function isUnlimitedValue(value) {

+ 6 - 8
web/templates/pages/add_mail_acc.php

@@ -92,18 +92,16 @@
 							<textarea class="form-control" name="v_fwd" id="v_fwd" <?php if($v_blackhole == 'yes') echo "disabled";?>><?=htmlentities(trim($v_fwd, "'"))?></textarea>
 						</div>
 						<div class="form-check">
-							<input class="form-check-input" type="checkbox" name="v_blackhole" id="v_blackhole" <?php if ($v_blackhole == 'yes') echo 'checked' ?>>
+							<input class="form-check-input js-discard-all-mail" type="checkbox" name="v_blackhole" id="v_blackhole" <?php if ($v_blackhole == 'yes') echo 'checked' ?>>
 							<label for="v_blackhole">
 								<?= _("Discard all mail") ?>
 							</label>
 						</div>
-						<div id="id_fwd_for" style="display:<?php if ($v_blackhole == 'yes') {echo 'none';} else {echo 'block';}?> ;">
-							<div class="form-check">
-								<input class="form-check-input" type="checkbox" name="v_fwd_only" id="v_fwd_for" <?php if ($v_fwd_only == 'yes') echo 'checked' ?>>
-								<label for="v_fwd_for">
-									<?= _("Do not store forwarded mail") ?>
-								</label>
-							</div>
+						<div class="form-check <?php if ($v_blackhole == 'yes') { echo 'u-hidden'; } ?>">
+							<input class="form-check-input" type="checkbox" name="v_fwd_only" id="v_fwd_for" <?php if ($v_fwd_only == 'yes') echo 'checked' ?>>
+							<label for="v_fwd_for">
+								<?= _("Do not store forwarded mail") ?>
+							</label>
 						</div>
 						<div class="u-mt10 u-mb10">
 							<label for="v_rate" class="form-label">

+ 11 - 16
web/templates/pages/edit_mail_acc.php

@@ -85,27 +85,22 @@
 						<textarea class="form-control" name="v_aliases" id="v_aliases"><?= htmlentities(trim($v_aliases, "'")) ?></textarea>
 					</div>
 					<div class="form-check">
-						<input class="form-check-input" type="checkbox" name="v_blackhole" id="v_blackhole" <?php if ($v_blackhole == 'yes') echo 'checked' ?>>
+						<input class="form-check-input js-discard-all-mail" type="checkbox" name="v_blackhole" id="v_blackhole" <?php if ($v_blackhole == 'yes') echo 'checked' ?>>
 						<label for="v_blackhole">
 							<?= _("Discard all mail") ?>
 						</label>
 					</div>
-					<div id="id_fwd_for" style="display:<?php if ($v_blackhole == 'yes') {echo 'none';} else {echo 'block';}?> ;">
-						<div class="form-check">
-							<input class="form-check-input" type="checkbox" name="v_fwd_only" id="v_fwd_for" <?php if ($v_fwd_only == 'yes') echo 'checked' ?>>
-							<label for="v_fwd_for">
-								<?= _("Do not store forwarded mail") ?>
-							</label>
-						</div>
+					<div class="form-check <?php if ($v_blackhole == 'yes') { echo 'u-hidden'; } ?>">
+						<input class="form-check-input" type="checkbox" name="v_fwd_only" id="v_fwd_for" <?php if ($v_fwd_only == 'yes') echo 'checked' ?>>
+						<label for="v_fwd_for">
+							<?= _("Do not store forwarded mail") ?>
+						</label>
 					</div>
-
-					<div id="v-fwd-opt">
-						<div class="u-mb10">
-							<label for="v_fwd" class="form-label">
-								<?= _("Forward to") ?> <span class="optional">(<?= _("one or more email addresses") ?>)</span>
-							</label>
-							<textarea class="form-control" name="v_fwd" id="v_fwd" <?php if($v_blackhole == 'yes') echo "disabled";?>><?=htmlentities(trim($v_fwd, "'"))?></textarea>
-						</div>
+					<div class="u-mb10">
+						<label for="v_fwd" class="form-label">
+							<?= _("Forward to") ?> <span class="optional">(<?= _("one or more email addresses") ?>)</span>
+						</label>
+						<textarea class="form-control" name="v_fwd" id="v_fwd" <?php if($v_blackhole == 'yes') echo "disabled";?>><?=htmlentities(trim($v_fwd, "'"))?></textarea>
 					</div>
 					<div class="form-check u-mb10">
 						<input x-model="hasAutoReply" class="form-check-input" type="checkbox" name="v_autoreply" id="v_autoreply">

+ 81 - 74
yarn.lock

@@ -971,14 +971,14 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@typescript-eslint/eslint-plugin@npm:^5.59.0":
-  version: 5.59.0
-  resolution: "@typescript-eslint/eslint-plugin@npm:5.59.0"
+"@typescript-eslint/eslint-plugin@npm:^5.59.1":
+  version: 5.59.1
+  resolution: "@typescript-eslint/eslint-plugin@npm:5.59.1"
   dependencies:
     "@eslint-community/regexpp": ^4.4.0
-    "@typescript-eslint/scope-manager": 5.59.0
-    "@typescript-eslint/type-utils": 5.59.0
-    "@typescript-eslint/utils": 5.59.0
+    "@typescript-eslint/scope-manager": 5.59.1
+    "@typescript-eslint/type-utils": 5.59.1
+    "@typescript-eslint/utils": 5.59.1
     debug: ^4.3.4
     grapheme-splitter: ^1.0.4
     ignore: ^5.2.0
@@ -991,43 +991,43 @@ __metadata:
   peerDependenciesMeta:
     typescript:
       optional: true
-  checksum: 3b2582fe7baa9bf7733be79c6e35a390806f91c8d5ba5b604f71cb3635fb36abc975b926195c3ef5c6a4018bb94f66e009d727e3af2ce8b92c96aa3ee9ed194a
+  checksum: 9ada3ae721594ddd8101a6093e6383bc95e4dcb19b3929210dee5480637786473a9eba2e69e61e560fa592965f4fd02aeb98ddfda91b00b448ae01c5d77431d6
   languageName: node
   linkType: hard
 
-"@typescript-eslint/parser@npm:^5.59.0":
-  version: 5.59.0
-  resolution: "@typescript-eslint/parser@npm:5.59.0"
+"@typescript-eslint/parser@npm:^5.59.1":
+  version: 5.59.1
+  resolution: "@typescript-eslint/parser@npm:5.59.1"
   dependencies:
-    "@typescript-eslint/scope-manager": 5.59.0
-    "@typescript-eslint/types": 5.59.0
-    "@typescript-eslint/typescript-estree": 5.59.0
+    "@typescript-eslint/scope-manager": 5.59.1
+    "@typescript-eslint/types": 5.59.1
+    "@typescript-eslint/typescript-estree": 5.59.1
     debug: ^4.3.4
   peerDependencies:
     eslint: ^6.0.0 || ^7.0.0 || ^8.0.0
   peerDependenciesMeta:
     typescript:
       optional: true
-  checksum: 1a442d6b776fc1dca4fe104bac77eae0a59b807ba11cef00dec8f5dbbc0fb4e5fc10519eac03dd94d52e4dd6d814800d0e5c0a3bd43eefce80d829c65ba47ad0
+  checksum: d324d32a69e06ab12aacb72cd3e2a8eb8ade6c2a4d4e6bb013941588a675e818a8ebd973bef1cd818da6a76eb00908bf66d84ef214c3f015dfcb40f8067a335e
   languageName: node
   linkType: hard
 
-"@typescript-eslint/scope-manager@npm:5.59.0":
-  version: 5.59.0
-  resolution: "@typescript-eslint/scope-manager@npm:5.59.0"
+"@typescript-eslint/scope-manager@npm:5.59.1":
+  version: 5.59.1
+  resolution: "@typescript-eslint/scope-manager@npm:5.59.1"
   dependencies:
-    "@typescript-eslint/types": 5.59.0
-    "@typescript-eslint/visitor-keys": 5.59.0
-  checksum: dd89cd34291f7674edcbe9628748faa61dbf7199f9776586167e81fd91b93ba3a7f0ddd493c559c0dbb805b58629858fae648d56550e8ac5330b2ed1802b0178
+    "@typescript-eslint/types": 5.59.1
+    "@typescript-eslint/visitor-keys": 5.59.1
+  checksum: ae7758181d0f18d1ad20abf95164553fa98c20410968d538ac7abd430ec59f69e30d4da16ad968d029feced1ed49abc65daf6685c996eb4529d798e8320204ff
   languageName: node
   linkType: hard
 
-"@typescript-eslint/type-utils@npm:5.59.0":
-  version: 5.59.0
-  resolution: "@typescript-eslint/type-utils@npm:5.59.0"
+"@typescript-eslint/type-utils@npm:5.59.1":
+  version: 5.59.1
+  resolution: "@typescript-eslint/type-utils@npm:5.59.1"
   dependencies:
-    "@typescript-eslint/typescript-estree": 5.59.0
-    "@typescript-eslint/utils": 5.59.0
+    "@typescript-eslint/typescript-estree": 5.59.1
+    "@typescript-eslint/utils": 5.59.1
     debug: ^4.3.4
     tsutils: ^3.21.0
   peerDependencies:
@@ -1035,23 +1035,23 @@ __metadata:
   peerDependenciesMeta:
     typescript:
       optional: true
-  checksum: 811981ea117808315fe37ce8489ae6e20979f588cf0fdef2bd969d58c505ececff0bccf7957f3b178933028433ce28764ebc9fea32a35a4c2da81b5b1e98b454
+  checksum: ff46cc049995bb6505a6170550a9e658c42cd5699a95e1976822318fef2963381223505f797051fc727938ace66d4a7dc072a4b4cadbbdf91d2fda1a16c05c98
   languageName: node
   linkType: hard
 
-"@typescript-eslint/types@npm:5.59.0":
-  version: 5.59.0
-  resolution: "@typescript-eslint/types@npm:5.59.0"
-  checksum: 5dc608a867b07b4262a236a264a65e894f841388b3aba461c4c1a30d76a2c3aed0c6a1e3d1ea2f64cce55e783091bafb826bf01a0ef83258820af63da860addf
+"@typescript-eslint/types@npm:5.59.1":
+  version: 5.59.1
+  resolution: "@typescript-eslint/types@npm:5.59.1"
+  checksum: 40ea7ccf59c4951797d3761e53c866a5979e07fbdabef9dc07d3a3f625a99d4318d5329ae8e628cdfdc0bb9bb6e6d8dfb740f33c7bf318e63fa0a863b9ae85c7
   languageName: node
   linkType: hard
 
-"@typescript-eslint/typescript-estree@npm:5.59.0":
-  version: 5.59.0
-  resolution: "@typescript-eslint/typescript-estree@npm:5.59.0"
+"@typescript-eslint/typescript-estree@npm:5.59.1":
+  version: 5.59.1
+  resolution: "@typescript-eslint/typescript-estree@npm:5.59.1"
   dependencies:
-    "@typescript-eslint/types": 5.59.0
-    "@typescript-eslint/visitor-keys": 5.59.0
+    "@typescript-eslint/types": 5.59.1
+    "@typescript-eslint/visitor-keys": 5.59.1
     debug: ^4.3.4
     globby: ^11.1.0
     is-glob: ^4.0.3
@@ -1060,45 +1060,45 @@ __metadata:
   peerDependenciesMeta:
     typescript:
       optional: true
-  checksum: d80f2766e2830dc830b9f4f1b9e744e1e7a285ebe72babdf0970f75bfe26cb832c6623bb836a53c48f1e707069d1e407ac1ea095bd583807007f713ba6e2e0e1
+  checksum: e33081937225f38e717ac2f9e90c4a8c6b71b701923eea3e03be76d8c466f0d3c6a4ec1d65c9fc1da4f1989416d386305353c5b53aa736d3af9503061001e3eb
   languageName: node
   linkType: hard
 
-"@typescript-eslint/utils@npm:5.59.0":
-  version: 5.59.0
-  resolution: "@typescript-eslint/utils@npm:5.59.0"
+"@typescript-eslint/utils@npm:5.59.1":
+  version: 5.59.1
+  resolution: "@typescript-eslint/utils@npm:5.59.1"
   dependencies:
     "@eslint-community/eslint-utils": ^4.2.0
     "@types/json-schema": ^7.0.9
     "@types/semver": ^7.3.12
-    "@typescript-eslint/scope-manager": 5.59.0
-    "@typescript-eslint/types": 5.59.0
-    "@typescript-eslint/typescript-estree": 5.59.0
+    "@typescript-eslint/scope-manager": 5.59.1
+    "@typescript-eslint/types": 5.59.1
+    "@typescript-eslint/typescript-estree": 5.59.1
     eslint-scope: ^5.1.1
     semver: ^7.3.7
   peerDependencies:
     eslint: ^6.0.0 || ^7.0.0 || ^8.0.0
-  checksum: 228318df02f2381f859af184cafa5de4146a2e1518a5062444bf9bd7d468e058f9bd93a3e46cc4683d9bd02159648f416e5c7c539901ca16142456cae3c1af5f
+  checksum: ca32c90efa57e937ebf812221e070c0604ca99f900fbca60578b42d40c923d5a94fd9503cf5918ecd75b687b68a1be562f7c6593a329bc40b880c95036a021c0
   languageName: node
   linkType: hard
 
-"@typescript-eslint/visitor-keys@npm:5.59.0":
-  version: 5.59.0
-  resolution: "@typescript-eslint/visitor-keys@npm:5.59.0"
+"@typescript-eslint/visitor-keys@npm:5.59.1":
+  version: 5.59.1
+  resolution: "@typescript-eslint/visitor-keys@npm:5.59.1"
   dependencies:
-    "@typescript-eslint/types": 5.59.0
+    "@typescript-eslint/types": 5.59.1
     eslint-visitor-keys: ^3.3.0
-  checksum: e21656de02e221a27a5fe9f7fd34a1ca28530e47675134425f84fd0d1f276695fe39e35120837a491b02255d49aa2fd871e2c858ecccc66c687db972d057bd1c
+  checksum: f98e399147310cad67de718a8a6336f053d46753bade380c89ddac3dd49512555c3f613636b255ce0b5e2b004654d1c167eb5e53fc8085148b637a5afc20cdd8
   languageName: node
   linkType: hard
 
 "@vitejs/plugin-vue@npm:^4.1.0":
-  version: 4.1.0
-  resolution: "@vitejs/plugin-vue@npm:4.1.0"
+  version: 4.2.0
+  resolution: "@vitejs/plugin-vue@npm:4.2.0"
   peerDependencies:
     vite: ^4.0.0
     vue: ^3.2.25
-  checksum: 532192a3da39f3fd6ba9d4fa9de74533cf6e222785c26bcb75bde9f826c5c4a22ad6f0bb09fae83e3393f9d8d24b6982d547683b1967972a4350e8e76aecf3cb
+  checksum: 210dad859d996a9b0fa00f7806de282d6a04581ced1792dd902d3f28c46cde33f8db03b164bed3b066c5602f2b962a00473b7dfbe234f0a276eb61cc9cc96669
   languageName: node
   linkType: hard
 
@@ -1221,7 +1221,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@vueuse/core@npm:^10.0.2":
+"@vueuse/core@npm:^10.1.0":
   version: 10.1.0
   resolution: "@vueuse/core@npm:10.1.0"
   dependencies:
@@ -2230,9 +2230,9 @@ __metadata:
   linkType: hard
 
 "electron-to-chromium@npm:^1.4.284":
-  version: 1.4.369
-  resolution: "electron-to-chromium@npm:1.4.369"
-  checksum: de31f3c765e8ab7096da252fecd4cdf717bceb0a027aa29db26450822535f745b09cc9f1dbb27a9e8767ef3730d35cbe7269e030ccf4421c2e3e2435a966ea86
+  version: 1.4.372
+  resolution: "electron-to-chromium@npm:1.4.372"
+  checksum: 946c50f1ec5df2408fc90164ab3814081dacd20cad7c440b829da391f864fefcda202e9e5a7016ce9d06d3c8746a505ffad820dfc51aa269dd13a6fdbf71d15d
   languageName: node
   linkType: hard
 
@@ -3150,8 +3150,8 @@ __metadata:
   dependencies:
     "@fortawesome/fontawesome-free": ^6.4.0
     "@prettier/plugin-php": ^0.19.4
-    "@typescript-eslint/eslint-plugin": ^5.59.0
-    "@typescript-eslint/parser": ^5.59.0
+    "@typescript-eslint/eslint-plugin": ^5.59.1
+    "@typescript-eslint/parser": ^5.59.1
     chart.js: ^4.2.1
     check-password-strength: ^2.0.7
     cssnano: ^6.0.0
@@ -3177,7 +3177,7 @@ __metadata:
     stylelint: ^15.6.0
     stylelint-config-standard: ^33.0.0
     typescript: ^5.0.4
-    vitepress: 1.0.0-alpha.73
+    vitepress: 1.0.0-alpha.74
     vue: ^3.2.47
   languageName: unknown
   linkType: soft
@@ -5691,9 +5691,9 @@ __metadata:
   languageName: node
   linkType: hard
 
-"rollup@npm:^3.20.2":
-  version: 3.20.7
-  resolution: "rollup@npm:3.20.7"
+"rollup@npm:^3.21.0":
+  version: 3.21.0
+  resolution: "rollup@npm:3.21.0"
   dependencies:
     fsevents: ~2.3.2
   dependenciesMeta:
@@ -5701,7 +5701,7 @@ __metadata:
       optional: true
   bin:
     rollup: dist/bin/rollup
-  checksum: 443f26aa6e42b94b4b137fbc3a07c78254cafc4ae53e0547bf5a94337b0658ca100d4e1c7e24870e399e5c7068a419cc0d367bfd35f4b1552d0b5e6d3482c517
+  checksum: f3294d712147c0975c59ff81b3010dc08d07743cdad72fbe12879044b3e467139b3c2aeec85768656c4f7ec6a7b3d19354a78fc2050044bf8e90a499e145e31e
   languageName: node
   linkType: hard
 
@@ -6551,14 +6551,14 @@ __metadata:
   languageName: node
   linkType: hard
 
-"vite@npm:^4.3.0":
-  version: 4.3.1
-  resolution: "vite@npm:4.3.1"
+"vite@npm:^4.3.1":
+  version: 4.3.2
+  resolution: "vite@npm:4.3.2"
   dependencies:
     esbuild: ^0.17.5
     fsevents: ~2.3.2
     postcss: ^8.4.21
-    rollup: ^3.20.2
+    rollup: ^3.21.0
   peerDependencies:
     "@types/node": ">= 14"
     less: "*"
@@ -6584,28 +6584,28 @@ __metadata:
       optional: true
   bin:
     vite: bin/vite.js
-  checksum: e58ba33ec10af6167686c1c43c61f31fcbc6b3b6a1f16ed03fb6cc8278700c6edaf4ff394ef454291ee35924054c7772feac159f1acf3af1a33d289fe8494d9c
+  checksum: 0194c06c44efa6fc2f81389d16d90cc3da8e7a58d071fe80e787053d604e0ea653e367fc6eb0528058d255c1ae72625396128561301add48b8e480c45343d11c
   languageName: node
   linkType: hard
 
-"vitepress@npm:1.0.0-alpha.73":
-  version: 1.0.0-alpha.73
-  resolution: "vitepress@npm:1.0.0-alpha.73"
+"vitepress@npm:1.0.0-alpha.74":
+  version: 1.0.0-alpha.74
+  resolution: "vitepress@npm:1.0.0-alpha.74"
   dependencies:
     "@docsearch/css": ^3.3.3
     "@docsearch/js": ^3.3.3
     "@vitejs/plugin-vue": ^4.1.0
     "@vue/devtools-api": ^6.5.0
-    "@vueuse/core": ^10.0.2
+    "@vueuse/core": ^10.1.0
     body-scroll-lock: 4.0.0-beta.0
     mark.js: 8.11.1
     minisearch: ^6.0.1
     shiki: ^0.14.1
-    vite: ^4.3.0
+    vite: ^4.3.1
     vue: ^3.2.47
   bin:
     vitepress: bin/vitepress.js
-  checksum: 20755f8503a5a0f8850ee47e563498391c9aff80995bdc5958bb88da3289de0db04f6eebe6a8e8768dd6cd1b5d9d33cd50c1ac7b8fe632ee3c1202bb45d069f5
+  checksum: efd2f4f951d122139cfec7b67297d544c0f1d401055cd97c82d71daf3dac1b314b2a54b12af52b4224c165bd0611da5ac300823cf139a087faf0968fa93ae36d
   languageName: node
   linkType: hard
 
@@ -6770,13 +6770,20 @@ __metadata:
   languageName: node
   linkType: hard
 
-"yaml@npm:2.2.1, yaml@npm:^2.2.1":
+"yaml@npm:2.2.1":
   version: 2.2.1
   resolution: "yaml@npm:2.2.1"
   checksum: 84f68cbe462d5da4e7ded4a8bded949ffa912bc264472e5a684c3d45b22d8f73a3019963a32164023bdf3d83cfb6f5b58ff7b2b10ef5b717c630f40bd6369a23
   languageName: node
   linkType: hard
 
+"yaml@npm:^2.2.1":
+  version: 2.2.2
+  resolution: "yaml@npm:2.2.2"
+  checksum: d90c235e099e30094dcff61ba3350437aef53325db4a6bcd04ca96e1bfe7e348b191f6a7a52b5211e2dbc4eeedb22a00b291527da030de7c189728ef3f2b4eb3
+  languageName: node
+  linkType: hard
+
 "yargs-parser@npm:^20.2.3":
   version: 20.2.9
   resolution: "yargs-parser@npm:20.2.9"

Some files were not shown because too many files changed in this diff