Add option type for with line break-separated items (#4126)
authorMatthias Schmidt <gravatronics@live.com>
Fri, 16 Apr 2021 17:11:43 +0000 (19:11 +0200)
committerGitHub <noreply@github.com>
Fri, 16 Apr 2021 17:11:43 +0000 (19:11 +0200)
* Add option type for with line break-separated items

Close #4041

* Apply suggestions from code review

* Use consistent sorting between PHP and JavaScript

* Apply suggestions from code review

com.woltlab.wcf/option.xml
ts/WoltLabSuite/Core/Ui/ItemList/LineBreakSeparatedText.ts [new file with mode: 0644]
wcfsetup/install/files/acp/templates/lineBreakSeparatedTextOptionType.tpl [new file with mode: 0644]
wcfsetup/install/files/js/WoltLabSuite/Core/Ui/ItemList/LineBreakSeparatedText.js [new file with mode: 0644]
wcfsetup/install/files/lib/system/option/LineBreakSeparatedTextOptionType.class.php [new file with mode: 0644]
wcfsetup/install/lang/de.xml
wcfsetup/install/lang/en.xml

index f0fcb294af409d29a1d05677abd37c640140bed7..f2fe415f7c052231f990322204d84c4401fd4d7a 100644 (file)
                        </option>
                        <option name="internal_hostnames">
                                <categoryname>general.page</categoryname>
-                               <optiontype>textarea</optiontype>
+                               <optiontype>lineBreakSeparatedText</optiontype>
                        </option>
                        <option name="head_code">
                                <categoryname>general.page</categoryname>
                        </option>
                        <option name="url_title_component_replacement">
                                <categoryname>general.page.seo</categoryname>
-                               <optiontype>textarea</optiontype>
+                               <optiontype>lineBreakSeparatedText</optiontype>
                        </option>
                        <option name="sitemap_index_time_frame">
                                <categoryname>general.page.sitemap</categoryname>
@@ -1079,7 +1079,7 @@ XING</selectoptions>
                        </option>
                        <option name="image_external_source_whitelist">
                                <categoryname>message.general.image</categoryname>
-                               <optiontype>textarea</optiontype>
+                               <optiontype>lineBreakSeparatedText</optiontype>
                        </option>
                        <option name="message_force_secure_images">
                                <categoryname>message.general.image</categoryname>
@@ -1113,7 +1113,7 @@ XING</selectoptions>
                        </option>
                        <option name="image_proxy_host_whitelist">
                                <categoryname>message.general.imageProxy</categoryname>
-                               <optiontype>textarea</optiontype>
+                               <optiontype>lineBreakSeparatedText</optiontype>
                        </option>
                        <!-- /message.general.image -->
                        <!-- message.censorship -->
@@ -1124,7 +1124,7 @@ XING</selectoptions>
                        </option>
                        <option name="censored_words">
                                <categoryname>message.censorship</categoryname>
-                               <optiontype>textarea</optiontype>
+                               <optiontype>lineBreakSeparatedText</optiontype>
                        </option>
                        <!-- /message.censorship -->
                        <option name="password_min_score">
@@ -1139,15 +1139,15 @@ XING</selectoptions>
                        <!-- user.ban -->
                        <option name="register_forbidden_usernames">
                                <categoryname>user.ban</categoryname>
-                               <optiontype>textarea</optiontype>
+                               <optiontype>lineBreakSeparatedText</optiontype>
                        </option>
                        <option name="register_forbidden_emails">
                                <categoryname>user.ban</categoryname>
-                               <optiontype>textarea</optiontype>
+                               <optiontype>lineBreakSeparatedText</optiontype>
                        </option>
                        <option name="register_allowed_emails">
                                <categoryname>user.ban</categoryname>
-                               <optiontype>textarea</optiontype>
+                               <optiontype>lineBreakSeparatedText</optiontype>
                        </option>
                        <!-- /user.ban -->
                        <!-- user.register -->
@@ -1278,7 +1278,7 @@ retro:wcf.acp.option.gravatar_default_type.retro</selectoptions>
                        </option>
                        <option name="user_forbidden_titles">
                                <categoryname>user.title</categoryname>
-                               <optiontype>textarea</optiontype>
+                               <optiontype>lineBreakSeparatedText</optiontype>
                                <options>module_user_rank</options>
                        </option>
                        <!-- /user.title -->
diff --git a/ts/WoltLabSuite/Core/Ui/ItemList/LineBreakSeparatedText.ts b/ts/WoltLabSuite/Core/Ui/ItemList/LineBreakSeparatedText.ts
new file mode 100644 (file)
index 0000000..3fde6f4
--- /dev/null
@@ -0,0 +1,252 @@
+/**
+ * UI element that shows individual lines of text as distinct list items but saves them as line
+ * break-separated text.
+ *
+ * @author  Matthias Schmidt
+ * @copyright  2001-2021 WoltLab GmbH
+ * @license  GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module  WoltLabSuite/Core/Ui/ItemList/LineBreakSeparatedText
+ * @since 5.4
+ */
+
+import * as UiConfirmation from "../Confirmation";
+import * as Language from "../../Language";
+import DomUtil from "../../Dom/Util";
+
+export interface LineBreakSeparatedTextOptions {
+  submitFieldName: string;
+}
+
+export class UiItemListLineBreakSeparatedText {
+  protected clearButton?: HTMLAnchorElement = undefined;
+  protected itemInput?: HTMLInputElement = undefined;
+  protected readonly itemList: HTMLUListElement;
+  protected readonly items = new Set<string>();
+  protected readonly options: LineBreakSeparatedTextOptions;
+
+  constructor(itemList: HTMLUListElement, options: LineBreakSeparatedTextOptions) {
+    this.itemList = itemList;
+    this.options = options;
+
+    this.itemList.closest("form")!.addEventListener("submit", () => this.submit());
+
+    this.initValues();
+    this.buildUi();
+  }
+
+  /**
+   * Adds an item to the list after clicking on the "add" button.
+   */
+  protected addItem(event: Event): void {
+    event.preventDefault();
+
+    const itemInput = this.itemInput!;
+    const item = itemInput.value.trim();
+
+    if (item === "") {
+      DomUtil.innerError(itemInput.parentElement!, Language.get("wcf.global.form.error.empty"));
+    } else if (!this.items.has(item)) {
+      this.insertItem(item);
+
+      this.resetInput();
+    } else {
+      DomUtil.innerError(
+        itemInput.parentElement!,
+        Language.get("wcf.acp.option.type.lineBreakSeparatedText.error.duplicate", {
+          item,
+        }),
+        true,
+      );
+    }
+
+    itemInput.focus();
+  }
+
+  /**
+   * Builds the user interface during setup.
+   */
+  protected buildUi(): void {
+    const container = document.createElement("div");
+    container.classList.add("itemListFilter");
+
+    this.itemList.insertAdjacentElement("beforebegin", container);
+    container.appendChild(this.itemList);
+
+    const inputAddon = document.createElement("div");
+    inputAddon.classList.add("inputAddon");
+    container.appendChild(inputAddon);
+
+    this.itemInput = document.createElement("input");
+    this.itemInput.classList.add("long");
+    this.itemInput.type = "text";
+    this.itemInput.placeholder = Language.get("wcf.acp.option.type.lineBreakSeparatedText.placeholder");
+    this.itemInput.addEventListener("keydown", (ev) => this.keydown(ev));
+    this.itemInput.addEventListener("paste", (ev) => this.paste(ev));
+    inputAddon.appendChild(this.itemInput);
+
+    const addButton = document.createElement("a");
+    addButton.href = "#";
+    addButton.classList.add("button", "inputSuffix", "jsTooltip");
+    addButton.title = Language.get("wcf.global.button.add");
+    addButton.innerHTML = '<span class="icon icon16 fa-plus"></span>';
+    addButton.addEventListener("click", (ev) => this.addItem(ev));
+    inputAddon.appendChild(addButton);
+
+    this.clearButton = document.createElement("a");
+    this.clearButton.href = "#";
+    this.clearButton.classList.add("button", "inputSuffix", "jsTooltip");
+    this.clearButton.title = Language.get("wcf.global.button.delete");
+    this.clearButton.innerHTML = '<span class="icon icon16 fa-times"></span>';
+    this.clearButton.addEventListener("click", (ev) => this.clearList(ev));
+    inputAddon.appendChild(this.clearButton);
+    if (this.items.size === 0) {
+      DomUtil.hide(this.clearButton);
+    }
+  }
+
+  /**
+   * Clears the item list after clicking on the clear button.
+   */
+  protected clearList(ev: Event): void {
+    ev.preventDefault();
+
+    UiConfirmation.show({
+      confirm: () => {
+        this.itemList.innerHTML = "";
+        this.items.clear();
+
+        this.hideList();
+      },
+      message: Language.get("wcf.acp.option.type.lineBreakSeparatedText.clearList.confirmMessage"),
+      messageIsHtml: true,
+    });
+  }
+
+  /**
+   * Deletes an item from the list after clicking on its delete icon.
+   */
+  protected deleteItem(event: Event): void {
+    const button = event.currentTarget as HTMLElement;
+    const item = button.closest("li")!.dataset.value!;
+
+    UiConfirmation.show({
+      confirm: () => {
+        button.closest("li")!.remove();
+
+        if (this.itemList.childElementCount === 0) {
+          this.hideList();
+        }
+
+        this.items.delete(item);
+      },
+      message: Language.get("wcf.button.delete.confirmMessage", {
+        objectTitle: item,
+      }),
+      messageIsHtml: true,
+    });
+  }
+
+  /**
+   * Hides the item list and clear button.
+   */
+  protected hideList(): void {
+    DomUtil.hide(this.itemList);
+    DomUtil.hide(this.clearButton!);
+  }
+
+  /**
+   * Adds the initial values to the list.
+   */
+  protected initValues(): void {
+    Array.from(this.itemList.children).forEach((el: HTMLElement) => {
+      this.items.add(el.dataset.value!);
+
+      el.querySelector(".jsDeleteItem")!.addEventListener("click", (ev) => this.deleteItem(ev));
+    });
+  }
+
+  /**
+   * Inserts the given item to the list.
+   */
+  protected insertItem(item: string): void {
+    this.items.add(item);
+
+    const itemElement = document.createElement("li");
+    itemElement.dataset.value = item;
+
+    const deleteButton = document.createElement("span");
+    deleteButton.classList.add("icon", "icon16", "fa-times", "jsDeleteItem", "jsTooltip", "pointer");
+    deleteButton.title = Language.get("wcf.global.button.delete");
+    deleteButton.addEventListener("click", (ev) => this.deleteItem(ev));
+    itemElement.append(deleteButton);
+
+    itemElement.append(document.createTextNode(" "));
+
+    const label = document.createElement("span");
+    label.innerText = item;
+    itemElement.append(label);
+
+    const nextElement = Array.from(this.itemList.children).find((el: HTMLElement) => el.dataset.value! > item);
+
+    if (nextElement) {
+      this.itemList.insertBefore(itemElement, nextElement);
+    } else {
+      this.itemList.append(itemElement);
+    }
+
+    this.showList();
+  }
+
+  /**
+   * Adds an item to the list when pressing "Enter" in the input field.
+   */
+  protected keydown(event: KeyboardEvent): void {
+    if (event.key === "Enter") {
+      this.addItem(event);
+    }
+  }
+
+  /**
+   * Adds multiple items at one to the list when pasting multiple lines of text into the input
+   * field.
+   */
+  protected paste(event: ClipboardEvent): void {
+    const items = event.clipboardData!.getData("text/plain").split("\n");
+    if (items.length > 1) {
+      event.preventDefault();
+
+      items.forEach((item) => this.insertItem(item));
+
+      this.resetInput();
+    }
+  }
+
+  /**
+   * Resets the input field.
+   */
+  protected resetInput(): void {
+    DomUtil.innerError(this.itemInput!.parentElement!, "");
+    this.itemInput!.value = "";
+  }
+
+  /**
+   * Shows the item list and clear button.
+   */
+  protected showList(): void {
+    DomUtil.show(this.itemList);
+    DomUtil.show(this.clearButton!);
+  }
+
+  /**
+   * Adds a hidden input field with the data to the form before it is submitted.
+   */
+  protected submit(): void {
+    const input = document.createElement("input");
+    input.type = "hidden";
+    input.name = this.options.submitFieldName;
+    input.value = Array.from(this.items).join("\n");
+    this.itemList.parentElement!.append(input);
+  }
+}
+
+export default UiItemListLineBreakSeparatedText;
diff --git a/wcfsetup/install/files/acp/templates/lineBreakSeparatedTextOptionType.tpl b/wcfsetup/install/files/acp/templates/lineBreakSeparatedTextOptionType.tpl
new file mode 100644 (file)
index 0000000..bc5a5bc
--- /dev/null
@@ -0,0 +1,28 @@
+<ul class="scrollableCheckboxList" {*
+    *}id="lineBreakSeparatedTextOption_{@$option->optionID}"{*
+    *}{if $values|empty} style="display: none"{/if}{*
+*}>
+    {foreach from=$values item=value}
+        <li data-value="{$value}">
+            <span class="icon icon16 fa-times jsDeleteItem jsTooltip pointer" title="{lang}wcf.global.button.delete{/lang}"></span>
+            <span>{$value}</span>
+        </li>
+    {/foreach}
+</ul>
+
+<script data-relocate="true">
+    require(['Language', 'WoltLabSuite/Core/Ui/ItemList/LineBreakSeparatedText'], (Language, { UiItemListLineBreakSeparatedText }) => {
+        Language.addObject({
+            'wcf.acp.option.type.lineBreakSeparatedText.placeholder': '{jslang}wcf.acp.option.type.lineBreakSeparatedText.placeholder{/jslang}',
+            'wcf.acp.option.type.lineBreakSeparatedText.error.duplicate': '{jslang __literal=true}wcf.acp.option.type.lineBreakSeparatedText.error.duplicate{/jslang}',
+            'wcf.acp.option.type.lineBreakSeparatedText.clearList.confirmMessage': '{jslang}wcf.acp.option.type.lineBreakSeparatedText.clearList.confirmMessage{/jslang}',
+        });
+        
+        new UiItemListLineBreakSeparatedText(
+            document.getElementById("lineBreakSeparatedTextOption_{@$option->optionID}"),
+            {
+                submitFieldName: "values[{$option->optionName}]"
+            }
+        );
+    });
+</script>
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/ItemList/LineBreakSeparatedText.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/ItemList/LineBreakSeparatedText.js
new file mode 100644 (file)
index 0000000..3c1f02b
--- /dev/null
@@ -0,0 +1,209 @@
+/**
+ * UI element that shows individual lines of text as distinct list items but saves them as line
+ * break-separated text.
+ *
+ * @author  Matthias Schmidt
+ * @copyright  2001-2021 WoltLab GmbH
+ * @license  GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module  WoltLabSuite/Core/Ui/ItemList/LineBreakSeparatedText
+ * @since 5.4
+ */
+define(["require", "exports", "tslib", "../Confirmation", "../../Language", "../../Dom/Util"], function (require, exports, tslib_1, UiConfirmation, Language, Util_1) {
+    "use strict";
+    Object.defineProperty(exports, "__esModule", { value: true });
+    exports.UiItemListLineBreakSeparatedText = void 0;
+    UiConfirmation = tslib_1.__importStar(UiConfirmation);
+    Language = tslib_1.__importStar(Language);
+    Util_1 = tslib_1.__importDefault(Util_1);
+    class UiItemListLineBreakSeparatedText {
+        constructor(itemList, options) {
+            this.clearButton = undefined;
+            this.itemInput = undefined;
+            this.items = new Set();
+            this.itemList = itemList;
+            this.options = options;
+            this.itemList.closest("form").addEventListener("submit", () => this.submit());
+            this.initValues();
+            this.buildUi();
+        }
+        /**
+         * Adds an item to the list after clicking on the "add" button.
+         */
+        addItem(event) {
+            event.preventDefault();
+            const itemInput = this.itemInput;
+            const item = itemInput.value.trim();
+            if (item === "") {
+                Util_1.default.innerError(itemInput.parentElement, Language.get("wcf.global.form.error.empty"));
+            }
+            else if (!this.items.has(item)) {
+                this.insertItem(item);
+                this.resetInput();
+            }
+            else {
+                Util_1.default.innerError(itemInput.parentElement, Language.get("wcf.acp.option.type.lineBreakSeparatedText.error.duplicate", {
+                    item,
+                }), true);
+            }
+            itemInput.focus();
+        }
+        /**
+         * Builds the user interface during setup.
+         */
+        buildUi() {
+            const container = document.createElement("div");
+            container.classList.add("itemListFilter");
+            this.itemList.insertAdjacentElement("beforebegin", container);
+            container.appendChild(this.itemList);
+            const inputAddon = document.createElement("div");
+            inputAddon.classList.add("inputAddon");
+            container.appendChild(inputAddon);
+            this.itemInput = document.createElement("input");
+            this.itemInput.classList.add("long");
+            this.itemInput.type = "text";
+            this.itemInput.placeholder = Language.get("wcf.acp.option.type.lineBreakSeparatedText.placeholder");
+            this.itemInput.addEventListener("keydown", (ev) => this.keydown(ev));
+            this.itemInput.addEventListener("paste", (ev) => this.paste(ev));
+            inputAddon.appendChild(this.itemInput);
+            const addButton = document.createElement("a");
+            addButton.href = "#";
+            addButton.classList.add("button", "inputSuffix", "jsTooltip");
+            addButton.title = Language.get("wcf.global.button.add");
+            addButton.innerHTML = '<span class="icon icon16 fa-plus"></span>';
+            addButton.addEventListener("click", (ev) => this.addItem(ev));
+            inputAddon.appendChild(addButton);
+            this.clearButton = document.createElement("a");
+            this.clearButton.href = "#";
+            this.clearButton.classList.add("button", "inputSuffix", "jsTooltip");
+            this.clearButton.title = Language.get("wcf.global.button.delete");
+            this.clearButton.innerHTML = '<span class="icon icon16 fa-times"></span>';
+            this.clearButton.addEventListener("click", (ev) => this.clearList(ev));
+            inputAddon.appendChild(this.clearButton);
+            if (this.items.size === 0) {
+                Util_1.default.hide(this.clearButton);
+            }
+        }
+        /**
+         * Clears the item list after clicking on the clear button.
+         */
+        clearList(ev) {
+            ev.preventDefault();
+            UiConfirmation.show({
+                confirm: () => {
+                    this.itemList.innerHTML = "";
+                    this.items.clear();
+                    this.hideList();
+                },
+                message: Language.get("wcf.acp.option.type.lineBreakSeparatedText.clearList.confirmMessage"),
+                messageIsHtml: true,
+            });
+        }
+        /**
+         * Deletes an item from the list after clicking on its delete icon.
+         */
+        deleteItem(event) {
+            const button = event.currentTarget;
+            const item = button.closest("li").dataset.value;
+            UiConfirmation.show({
+                confirm: () => {
+                    button.closest("li").remove();
+                    if (this.itemList.childElementCount === 0) {
+                        this.hideList();
+                    }
+                    this.items.delete(item);
+                },
+                message: Language.get("wcf.button.delete.confirmMessage", {
+                    objectTitle: item,
+                }),
+                messageIsHtml: true,
+            });
+        }
+        /**
+         * Hides the item list and clear button.
+         */
+        hideList() {
+            Util_1.default.hide(this.itemList);
+            Util_1.default.hide(this.clearButton);
+        }
+        /**
+         * Adds the initial values to the list.
+         */
+        initValues() {
+            Array.from(this.itemList.children).forEach((el) => {
+                this.items.add(el.dataset.value);
+                el.querySelector(".jsDeleteItem").addEventListener("click", (ev) => this.deleteItem(ev));
+            });
+        }
+        /**
+         * Inserts the given item to the list.
+         */
+        insertItem(item) {
+            this.items.add(item);
+            const itemElement = document.createElement("li");
+            itemElement.dataset.value = item;
+            const deleteButton = document.createElement("span");
+            deleteButton.classList.add("icon", "icon16", "fa-times", "jsDeleteItem", "jsTooltip", "pointer");
+            deleteButton.title = Language.get("wcf.global.button.delete");
+            deleteButton.addEventListener("click", (ev) => this.deleteItem(ev));
+            itemElement.append(deleteButton);
+            itemElement.append(document.createTextNode(" "));
+            const label = document.createElement("span");
+            label.innerText = item;
+            itemElement.append(label);
+            const nextElement = Array.from(this.itemList.children).find((el) => el.dataset.value > item);
+            if (nextElement) {
+                this.itemList.insertBefore(itemElement, nextElement);
+            }
+            else {
+                this.itemList.append(itemElement);
+            }
+            this.showList();
+        }
+        /**
+         * Adds an item to the list when pressing "Enter" in the input field.
+         */
+        keydown(event) {
+            if (event.key === "Enter") {
+                this.addItem(event);
+            }
+        }
+        /**
+         * Adds multiple items at one to the list when pasting multiple lines of text into the input
+         * field.
+         */
+        paste(event) {
+            const items = event.clipboardData.getData("text/plain").split("\n");
+            if (items.length > 1) {
+                event.preventDefault();
+                items.forEach((item) => this.insertItem(item));
+                this.resetInput();
+            }
+        }
+        /**
+         * Resets the input field.
+         */
+        resetInput() {
+            Util_1.default.innerError(this.itemInput.parentElement, "");
+            this.itemInput.value = "";
+        }
+        /**
+         * Shows the item list and clear button.
+         */
+        showList() {
+            Util_1.default.show(this.itemList);
+            Util_1.default.show(this.clearButton);
+        }
+        /**
+         * Adds a hidden input field with the data to the form before it is submitted.
+         */
+        submit() {
+            const input = document.createElement("input");
+            input.type = "hidden";
+            input.name = this.options.submitFieldName;
+            input.value = Array.from(this.items).join("\n");
+            this.itemList.parentElement.append(input);
+        }
+    }
+    exports.UiItemListLineBreakSeparatedText = UiItemListLineBreakSeparatedText;
+    exports.default = UiItemListLineBreakSeparatedText;
+});
diff --git a/wcfsetup/install/files/lib/system/option/LineBreakSeparatedTextOptionType.class.php b/wcfsetup/install/files/lib/system/option/LineBreakSeparatedTextOptionType.class.php
new file mode 100644 (file)
index 0000000..c21263c
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+
+namespace wcf\system\option;
+
+use wcf\data\option\Option;
+use wcf\system\WCF;
+use wcf\util\ArrayUtil;
+use wcf\util\StringUtil;
+
+/**
+ * Option type implementation for separate items that are stored as line break-separated text.
+ *
+ * @author  Matthias Schmidt
+ * @copyright   2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package WoltLabSuite\Core\System\Option
+ * @since   5.4
+ */
+class LineBreakSeparatedTextOptionType extends TextareaOptionType
+{
+    /**
+     * @inheritDoc
+     */
+    public function getFormElement(Option $option, $value)
+    {
+        $values = ArrayUtil::trim(\explode("\n", StringUtil::unifyNewlines($value)));
+        \uasort($values, 'strnatcmp');
+
+        return WCF::getTPL()->fetch('lineBreakSeparatedTextOptionType', 'wcf', [
+            'option' => $option,
+            'values' => $values,
+        ]);
+    }
+}
index a96f726bb0489bc986d7de6c4a0c4c4175be2563..7f00bf51981dcc65e3292dee4480051580f653a2 100644 (file)
@@ -1423,7 +1423,7 @@ ACHTUNG: Die oben genannten Meldungen sind stark gekürzt. Sie können Details z
                <item name="wcf.acp.option.module_smiley"><![CDATA[Smileys]]></item>
                <item name="wcf.acp.option.category.message.censorship"><![CDATA[Zensur-Funktion]]></item>
                <item name="wcf.acp.option.censored_words"><![CDATA[Zu zensierende Wörter]]></item>
-               <item name="wcf.acp.option.censored_words.description"><![CDATA[Ein Wort pro Zeile. Sollte bei der Erstellung einer Nachricht eines dieser Wörter verwendet werden, so wird die Erstellung verweigert.<br>
+               <item name="wcf.acp.option.censored_words.description"><![CDATA[Sollte bei der Erstellung einer Nachricht eines dieser Wörter verwendet werden, so wird die Erstellung verweigert.<br>
 <em>{if LANGUAGE_USE_INFORMAL_VARIANT}Verwende{else}Verwenden Sie{/if} „*“, um Wortteile zu finden: „wolt*“ findet auch „woltlab“</em><br>
 <em>{if LANGUAGE_USE_INFORMAL_VARIANT}Verwende{else}Verwenden Sie{/if} „~“, um Worttrennungen zu finden: „wolt~“ findet auch „wolt-lab“</em>]]></item>
                <item name="wcf.acp.option.enable_censorship"><![CDATA[Zensur aktivieren]]></item>
@@ -1474,11 +1474,11 @@ ACHTUNG: Die oben genannten Meldungen sind stark gekürzt. Sie können Details z
                <item name="wcf.acp.option.password_min_score.1"><![CDATA[1: Sehr leicht zu erraten (Eine Million Versuche)]]></item>
                <item name="wcf.acp.option.password_min_score.2"><![CDATA[2: Leicht zu erraten (100 Millionen Versuche)]]></item>
                <item name="wcf.acp.option.register_forbidden_usernames"><![CDATA[Reservierte Namen]]></item>
-               <item name="wcf.acp.option.register_forbidden_usernames.description"><![CDATA[Namen, die nicht als Benutzername verwendet werden dürfen. Ein Name pro Zeile]]></item>
+               <item name="wcf.acp.option.register_forbidden_usernames.description"><![CDATA[Namen, die nicht als Benutzername verwendet werden dürfen.]]></item>
                <item name="wcf.acp.option.register_forbidden_emails"><![CDATA[Reservierte E-Mail-Adressen]]></item>
-               <item name="wcf.acp.option.register_forbidden_emails.description"><![CDATA[E-Mail-Adressen, die nicht bei der Registrierung verwendet werden dürfen. Eine Adresse pro Zeile]]></item>
+               <item name="wcf.acp.option.register_forbidden_emails.description"><![CDATA[E-Mail-Adressen, die nicht bei der Registrierung verwendet werden dürfen.]]></item>
                <item name="wcf.acp.option.register_allowed_emails"><![CDATA[Erlaubte E-Mail-Adressen]]></item>
-               <item name="wcf.acp.option.register_allowed_emails.description"><![CDATA[E-Mail-Adressen, die ausschließlich bei der Registrierung verwendet werden dürfen. Eine Adresse pro Zeile]]></item>
+               <item name="wcf.acp.option.register_allowed_emails.description"><![CDATA[E-Mail-Adressen, die ausschließlich bei der Registrierung verwendet werden dürfen.]]></item>
                <item name="wcf.acp.option.register_username_min_length"><![CDATA[Minimale Benutzernamenlänge]]></item>
                <item name="wcf.acp.option.register_username_max_length"><![CDATA[Maximale Benutzernamenlänge]]></item>
                <item name="wcf.acp.option.register_username_force_ascii"><![CDATA[Benutzernamen auf ASCII-Zeichen beschränken]]></item>
@@ -1498,7 +1498,7 @@ ACHTUNG: Die oben genannten Meldungen sind stark gekürzt. Sie können Details z
                <item name="wcf.acp.option.sitemap_index_time_frame.description"><![CDATA[Maximales Alter der Objekte um in die Sitemap aufgenommen zu werden [0 um das Zeitfenster zu deaktivieren].]]></item>
                <item name="wcf.acp.option.user_title_max_length"><![CDATA[Maximale Länge des Benutzertitels]]></item>
                <item name="wcf.acp.option.user_forbidden_titles"><![CDATA[Reservierte Benutzertitel]]></item>
-               <item name="wcf.acp.option.user_forbidden_titles.description"><![CDATA[Benutzertitel, die nicht verwendet werden dürfen. Ein Titel pro Zeile]]></item>
+               <item name="wcf.acp.option.user_forbidden_titles.description"><![CDATA[Benutzertitel, die nicht verwendet werden dürfen.]]></item>
                <item name="wcf.acp.option.profile_show_old_username"><![CDATA[Alten Namen anzeigen]]></item>
                <item name="wcf.acp.option.profile_show_old_username.description"><![CDATA[Zeitraum in dem bei Änderungen des Benutzernamens zusätzlich der alte Benutzername im Profil angezeigt wird.]]></item>
                <item name="wcf.acp.option.members_list_users_per_page"><![CDATA[Mitglieder pro Seite]]></item>
@@ -1552,7 +1552,7 @@ ACHTUNG: Die oben genannten Meldungen sind stark gekürzt. Sie können Details z
                <item name="wcf.acp.option.footer_code.description"><![CDATA[Der hier angegebene Code wird im Fußbereich jeder Seite ausgegeben. Der Footer-Code eignet sich z.B. sehr gut für die Einbindung von Diensten wie „Google Analytics“ oder „Matomo“.]]></item>
                <item name="wcf.acp.option.profile_enable_visitors"><![CDATA[Profil-Besucher anzeigen]]></item>
                <item name="wcf.acp.option.url_title_component_replacement"><![CDATA[URL-Ersetzungen]]></item>
-               <item name="wcf.acp.option.url_title_component_replacement.description"><![CDATA[{if LANGUAGE_USE_INFORMAL_VARIANT}Du kannst{else}Sie können{/if} den in URLs enthaltenen Titel durch eigene Ersetzungen verändern. Dies kann z.B. zum Ersetzen von Umlauten oder zum Expandieren von Abkürzungen genutzt werden. (Eine Ersetzung pro Zeile)<br>
+               <item name="wcf.acp.option.url_title_component_replacement.description"><![CDATA[{if LANGUAGE_USE_INFORMAL_VARIANT}Du kannst{else}Sie können{/if} den in URLs enthaltenen Titel durch eigene Ersetzungen verändern. Dies kann z.B. zum Ersetzen von Umlauten oder zum Expandieren von Abkürzungen genutzt werden.<br>
 Beispiele:<br>
 WBB=WoltLab Burning Board<br>
 GmbH=Gesellschaft mit beschränkter Haftung]]></item>
@@ -1667,7 +1667,7 @@ Als Benachrichtigungs-URL in der Konfiguration der sofortigen Zahlungsbestätigu
                <item name="wcf.acp.option.image_proxy_insecure_only"><![CDATA[Nur Bilder aus unverschlüsselten Quellen zwischenspeichern]]></item>
                <item name="wcf.acp.option.image_proxy_enable_prune"><![CDATA[Zwischengespeicherte Bilder regelmäßig löschen]]></item>
                <item name="wcf.acp.option.image_proxy_host_whitelist"><![CDATA[Ausnahmen von der Zwischenspeicherung]]></item>
-               <item name="wcf.acp.option.image_proxy_host_whitelist.description"><![CDATA[Die aufgeführten Domains werden von der Zwischenspeicherung ausgenommen, die eigene Domain ist implizit enthalten. Der Abgleich erfolgt auf Basis der strikten Übereinstimmung, optional können Subdomains mit einem Platzhalter berücksichtigt werden: <kbd>*.example.com</kbd> umfasst sowohl <kbd>example.com</kbd> als auch Subdomains wie <kbd>foo.example.com</kbd> oder <kbd>www.example.com</kbd>.<br>Bitte nur eine Domain pro Zeile eingeben.]]></item>
+               <item name="wcf.acp.option.image_proxy_host_whitelist.description"><![CDATA[Die aufgeführten Domains werden von der Zwischenspeicherung ausgenommen, die eigene Domain ist implizit enthalten. Der Abgleich erfolgt auf Basis der strikten Übereinstimmung, optional können Subdomains mit einem Platzhalter berücksichtigt werden: <kbd>*.example.com</kbd> umfasst sowohl <kbd>example.com</kbd> als auch Subdomains wie <kbd>foo.example.com</kbd> oder <kbd>www.example.com</kbd>.]]></item>
                <item name="wcf.acp.option.share_buttons_providers"><![CDATA[Anbieter zum Teilen von Inhalten]]></item>
                <item name="wcf.acp.option.show_style_changer"><![CDATA[Stil-Auswahl anzeigen]]></item>
                <item name="wcf.acp.option.language_use_informal_variant"><![CDATA[Informelle Anrede verwenden]]></item>
@@ -1715,7 +1715,7 @@ Als Benachrichtigungs-URL in der Konfiguration der sofortigen Zahlungsbestätigu
                <item name="wcf.acp.option.page_logo_link_to_app_default.description"><![CDATA[Deaktiviere{if !LANGUAGE_USE_INFORMAL_VARIANT}n Sie{/if} diese Option, damit das Logo stets auf die globale Startseite verlinkt. Die Deaktivierung entspricht dem Verhalten in früheren Versionen.]]></item>
                <item name="wcf.acp.option.image_allow_external_source"><![CDATA[Bilder von externen Seiten erlauben]]></item>
                <item name="wcf.acp.option.image_external_source_whitelist"><![CDATA[Erlaubte Bilder von externen Seiten]]></item>
-               <item name="wcf.acp.option.image_external_source_whitelist.description"><![CDATA[Die aufgeführten Domains sind von der Blockade ausgenommen. Der Abgleich erfolgt auf Basis der strikten Übereinstimmung, optional können Subdomains mit einem Platzhalter berücksichtigt werden: <kbd>*.example.com</kbd> umfasst sowohl <kbd>example.com</kbd> als auch Subdomains wie <kbd>foo.example.com</kbd> oder <kbd>www.example.com</kbd>.<br>Bitte nur eine Domain pro Zeile eingeben.]]></item>
+               <item name="wcf.acp.option.image_external_source_whitelist.description"><![CDATA[Die aufgeführten Domains sind von der Blockade ausgenommen. Der Abgleich erfolgt auf Basis der strikten Übereinstimmung, optional können Subdomains mit einem Platzhalter berücksichtigt werden: <kbd>*.example.com</kbd> umfasst sowohl <kbd>example.com</kbd> als auch Subdomains wie <kbd>foo.example.com</kbd> oder <kbd>www.example.com</kbd>.]]></item>
                <item name="wcf.acp.option.message_enable_toc"><![CDATA[Inhaltsverzeichnisse aktivieren]]></item>
                <item name="wcf.acp.option.search_enable_articles"><![CDATA[Artikel sind durchsuchbar]]></item>
                <item name="wcf.acp.option.search_enable_pages"><![CDATA[Seiten sind durchsuchbar]]></item>
@@ -1768,7 +1768,10 @@ Die Datenbestände werden sorgfältig gepflegt, aber es ist nicht ausgeschlossen
                <item name="wcf.acp.option.modification_log_expiration"><![CDATA[Speicherzeit für Änderungen]]></item>
                <item name="wcf.acp.option.modification_log_expiration.description"><![CDATA[Zeitraum, nachdem alte Änderungen aus dem Änderungsprotokoll entfernt werden [0, um die Entfernung gänzlich zu deaktivieren]]]></item>
                <item name="wcf.acp.option.internal_hostnames"><![CDATA[Zusätzliche interne Domains]]></item>
-               <item name="wcf.acp.option.internal_hostnames.description"><![CDATA[Die aufgeführten Domains werden, neben den Domains der installierten Apps, als interne Domains angesehen. Der Abgleich erfolgt auf Basis der strikten Übereinstimmung, optional können Subdomains mit einem Platzhalter berücksichtigt werden: <kbd>*.example.com</kbd> umfasst sowohl <kbd>example.com</kbd> als auch Subdomains wie <kbd>foo.example.com</kbd> oder <kbd>www.example.com</kbd>.<br>Bitte nur eine Domain pro Zeile eingeben.]]></item>
+               <item name="wcf.acp.option.internal_hostnames.description"><![CDATA[Die aufgeführten Domains werden, neben den Domains der installierten Apps, als interne Domains angesehen. Der Abgleich erfolgt auf Basis der strikten Übereinstimmung, optional können Subdomains mit einem Platzhalter berücksichtigt werden: <kbd>*.example.com</kbd> umfasst sowohl <kbd>example.com</kbd> als auch Subdomains wie <kbd>foo.example.com</kbd> oder <kbd>www.example.com</kbd>]]></item>
+               <item name="wcf.acp.option.type.lineBreakSeparatedText.placeholder"><![CDATA[Neuen Eintrag hinzufügen]]></item>
+               <item name="wcf.acp.option.type.lineBreakSeparatedText.error.duplicate"><![CDATA[Der Eintrag <strong>{$item}</strong> existiert bereits.]]></item>
+               <item name="wcf.acp.option.type.lineBreakSeparatedText.clearList.confirmMessage"><![CDATA[{if LANGUAGE_USE_INFORMAL_VARIANT}Willst du{else}Wollen Sie{/if} alle Einträge wirklich löschen?]]></item>
        </category>
        <category name="wcf.acp.customOption">
                <item name="wcf.acp.customOption.list"><![CDATA[Eingabefelder]]></item>
index 49950a1d6250ea95e406ddc76aecfd557ddc5b11..01039fe4cb17a77ed610687d519b025878ef7e0c 100644 (file)
@@ -1401,7 +1401,7 @@ ATTENTION: The messages listed above are greatly shortened. You can view details
                <item name="wcf.acp.option.module_smiley"><![CDATA[Smilies]]></item>
                <item name="wcf.acp.option.category.message.censorship"><![CDATA[Censorship]]></item>
                <item name="wcf.acp.option.censored_words"><![CDATA[Censored Words]]></item>
-               <item name="wcf.acp.option.censored_words.description"><![CDATA[You can specify which words will be censored. Using at least one of these words within a message causes an immediate rejection. Enter one word per line. Examples:
+               <item name="wcf.acp.option.censored_words.description"><![CDATA[You can specify which words will be censored. Using at least one of these words within a message causes an immediate rejection. Examples:
 <ul class="nativeList">
 <li>“*” to match parts: “wolt*” matches “woltlab”</li>
 <li>“~” to find splitted parts: “wolt~” matches “wolt-lab”</li>
@@ -1454,11 +1454,11 @@ ATTENTION: The messages listed above are greatly shortened. You can view details
                <item name="wcf.acp.option.password_min_score.1"><![CDATA[1: Very guessable (1 million attempts)]]></item>
                <item name="wcf.acp.option.password_min_score.2"><![CDATA[2: Somewhat guessable (100 million attempts)]]></item>
                <item name="wcf.acp.option.register_forbidden_usernames"><![CDATA[Reserved Usernames]]></item>
-               <item name="wcf.acp.option.register_forbidden_usernames.description"><![CDATA[You can specify which usernames are unavailable for registration. Enter one username per line.]]></item>
+               <item name="wcf.acp.option.register_forbidden_usernames.description"><![CDATA[You can specify which usernames are unavailable for registration.]]></item>
                <item name="wcf.acp.option.register_forbidden_emails"><![CDATA[Reserved Email Addresses]]></item>
-               <item name="wcf.acp.option.register_forbidden_emails.description"><![CDATA[You can specify which email addresses are unavailable for registration. Enter one email address per line.]]></item>
+               <item name="wcf.acp.option.register_forbidden_emails.description"><![CDATA[You can specify which email addresses are unavailable for registration.]]></item>
                <item name="wcf.acp.option.register_allowed_emails"><![CDATA[Allowed Email Addresses]]></item>
-               <item name="wcf.acp.option.register_allowed_emails.description"><![CDATA[You can specify which email addresses are allowed for registration. Enter one email address per line.]]></item>
+               <item name="wcf.acp.option.register_allowed_emails.description"><![CDATA[You can specify which email addresses are allowed for registration.]]></item>
                <item name="wcf.acp.option.register_username_min_length"><![CDATA[Minimum Username Length]]></item>
                <item name="wcf.acp.option.register_username_max_length"><![CDATA[Maximum Username Length]]></item>
                <item name="wcf.acp.option.register_username_force_ascii"><![CDATA[Require ASCII characters for usernames]]></item>
@@ -1478,7 +1478,7 @@ ATTENTION: The messages listed above are greatly shortened. You can view details
                <item name="wcf.acp.option.sitemap_index_time_frame.description"><![CDATA[Maximum age of the objects to be included in the sitemap. Use 0 to disable the time frame.]]></item>
                <item name="wcf.acp.option.user_title_max_length"><![CDATA[Maximum User Title Length]]></item>
                <item name="wcf.acp.option.user_forbidden_titles"><![CDATA[Reserved User Titles]]></item>
-               <item name="wcf.acp.option.user_forbidden_titles.description"><![CDATA[You can specify which user titles are unavailable for users. Enter one user title per line.]]></item>
+               <item name="wcf.acp.option.user_forbidden_titles.description"><![CDATA[You can specify which user titles are unavailable for users.]]></item>
                <item name="wcf.acp.option.profile_show_old_username"><![CDATA[Display Previous Username]]></item>
                <item name="wcf.acp.option.profile_show_old_username.description"><![CDATA[Previous usernames will be displayed for the following days.]]></item>
                <item name="wcf.acp.option.members_list_users_per_page"><![CDATA[Members per Page]]></item>
@@ -1533,7 +1533,7 @@ ATTENTION: The messages listed above are greatly shortened. You can view details
                <item name="wcf.acp.option.footer_code.description"><![CDATA[The entered code will be appended to the page footer of your site. You can use it to embed services like “Google Analytics” or “Matomo”.]]></item>
                <item name="wcf.acp.option.profile_enable_visitors"><![CDATA[Display user’s profile visitors in profile sidebar]]></item>
                <item name="wcf.acp.option.url_title_component_replacement"><![CDATA[URL Replacements]]></item>
-               <item name="wcf.acp.option.url_title_component_replacement.description"><![CDATA[You can replace parts of the title within the URL. You could use it to replace special characters or to expand abbreviations. Enter one replacement per line. Examples:
+               <item name="wcf.acp.option.url_title_component_replacement.description"><![CDATA[You can replace parts of the title within the URL. You could use it to replace special characters or to expand abbreviations. Examples:
 <ul class="nativeList">
 <li>“WBB=WoltLab Burning Board”</li>
 <li>“GmbH=Gesellschaft mit beschränkter Haftung”</li>
@@ -1652,7 +1652,7 @@ When prompted for the notification URL for the instant payment notifications, pl
                <item name="wcf.acp.option.image_proxy_insecure_only"><![CDATA[Store images from insecure sources only]]></item>
                <item name="wcf.acp.option.image_proxy_enable_prune"><![CDATA[Remove Cached Images Regularly]]></item>
                <item name="wcf.acp.option.image_proxy_host_whitelist"><![CDATA[Image proxy whitelist]]></item>
-               <item name="wcf.acp.option.image_proxy_host_whitelist.description"><![CDATA[The listed domains will not be handled by the image proxy, the current domain is implicitly added. Hostnames are exact matches only, a leading wildcard can be used to exclude an entire domain: <kbd>*.example.com</kbd> matches <kbd>example.com</kbd> and subdomains such as <kbd>foo.example.com</kbd> or <kbd>www.example.com</kbd>.<br>Enter one domain per line only.]]></item>
+               <item name="wcf.acp.option.image_proxy_host_whitelist.description"><![CDATA[The listed domains will not be handled by the image proxy, the current domain is implicitly added. Hostnames are exact matches only, a leading wildcard can be used to exclude an entire domain: <kbd>*.example.com</kbd> matches <kbd>example.com</kbd> and subdomains such as <kbd>foo.example.com</kbd> or <kbd>www.example.com</kbd>.]]></item>
                <item name="wcf.acp.option.share_buttons_providers"><![CDATA[Share Button Providers]]></item>
                <item name="wcf.acp.option.show_style_changer"><![CDATA[Enable style changer]]></item>
                <item name="wcf.acp.option.language_use_informal_variant"><![CDATA[Use informal language variant]]></item>
@@ -1700,7 +1700,7 @@ When prompted for the notification URL for the instant payment notifications, pl
                <item name="wcf.acp.option.page_logo_link_to_app_default.description"><![CDATA[Disabling this option will cause the link to always point to the global landing page instead. This option enforces the behavior known from previous versions when disabled.]]></item>
                <item name="wcf.acp.option.image_allow_external_source"><![CDATA[Allow images from external sites]]></item>
                <item name="wcf.acp.option.image_external_source_whitelist"><![CDATA[Allowed images from external sites]]></item>
-               <item name="wcf.acp.option.image_external_source_whitelist.description"><![CDATA[The listed domains will be exempt from blocking. Hostnames are exact matches only, a leading wildcard can be used to exclude an entire domain: <kbd>*.example.com</kbd> matches <kbd>example.com</kbd> and subdomains such as <kbd>foo.example.com</kbd> or <kbd>www.example.com</kbd>.<br>Enter one domain per line only.]]></item>
+               <item name="wcf.acp.option.image_external_source_whitelist.description"><![CDATA[The listed domains will be exempt from blocking. Hostnames are exact matches only, a leading wildcard can be used to exclude an entire domain: <kbd>*.example.com</kbd> matches <kbd>example.com</kbd> and subdomains such as <kbd>foo.example.com</kbd> or <kbd>www.example.com</kbd>.]]></item>
                <item name="wcf.acp.option.message_enable_toc"><![CDATA[Enable the table of contents]]></item>
                <item name="wcf.acp.option.search_enable_articles"><![CDATA[Articles are searchable]]></item>
                <item name="wcf.acp.option.search_enable_pages"><![CDATA[Pages are searchable]]></item>
@@ -1753,7 +1753,10 @@ The database is carefully maintained, but there will be always be a margin of er
                <item name="wcf.acp.option.modification_log_expiration"><![CDATA[Storage Time for Modification Logs]]></item>
                <item name="wcf.acp.option.modification_log_expiration.description"><![CDATA[Modification logs will be deleted after the entered amount of days. To disable deleting old modification logs, enter “0”.]]></item>
                <item name="wcf.acp.option.internal_hostnames"><![CDATA[Additional Internal Domains]]></item>
-               <item name="wcf.acp.option.internal_hostnames.description"><![CDATA[The listed domains will be considered as internal domains in addition to the domains in use by installed apps. Hostnames are exact matches only, a leading wildcard can be used to exclude an entire domain: <kbd>*.example.com</kbd> matches <kbd>example.com</kbd> and subdomains such as <kbd>foo.example.com</kbd> or <kbd>www.example.com</kbd>.<br>Enter one domain per line only.]]></item>
+               <item name="wcf.acp.option.internal_hostnames.description"><![CDATA[The listed domains will be considered as internal domains in addition to the domains in use by installed apps. Hostnames are exact matches only, a leading wildcard can be used to exclude an entire domain: <kbd>*.example.com</kbd> matches <kbd>example.com</kbd> and subdomains such as <kbd>foo.example.com</kbd> or <kbd>www.example.com</kbd>.]]></item>
+               <item name="wcf.acp.option.type.lineBreakSeparatedText.placeholder"><![CDATA[Add new entry]]></item>
+               <item name="wcf.acp.option.type.lineBreakSeparatedText.error.duplicate"><![CDATA[The entry <strong>{$item}</strong> already exists.]]></item>
+               <item name="wcf.acp.option.type.lineBreakSeparatedText.clearList.confirmMessage"><![CDATA[Do you really want to delete all entries?]]></item>
        </category>
        <category name="wcf.acp.customOption">
                <item name="wcf.acp.customOption.list"><![CDATA[Option Fields]]></item>