snoopdave commented on code in PR #245:
URL: https://github.com/apache/roller/pull/245#discussion_r4569387806


##########
app/src/main/webapp/roller-ui/scripts/categories.js:
##########
@@ -0,0 +1,428 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one or more
+*  contributor license agreements.  The ASF licenses this file to You
+* under the Apache License, Version 2.0 (the "License"); you may not
+* use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*     http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.  For additional information regarding
+* copyright in this work, please see the NOTICE file in the top level
+* directory of this distribution.
+*/
+
+/*
+ * Categories page (Categories.jsp): add, edit and delete dialogs that post 
with
+ * AJAX, and drag (or arrow-key) reordering. After a change the page reloads 
and
+ * shows a short confirmation, carried across the reload in sessionStorage.
+ */
+(function ($) {
+    "use strict";
+
+    var page = document.getElementById("categoriesPage");
+    if (!page) {
+        return;
+    }
+
+    var FLASH_KEY = "roller.categories.flash";
+    var list = document.getElementById("categoryList");
+    var editModal = new 
bootstrap.Modal(document.getElementById("category-edit-modal"));
+    var deleteModal = new 
bootstrap.Modal(document.getElementById("delete-category-modal"));
+    var editForm = document.getElementById("categoryEditForm");
+    var removeForm = document.getElementById("categoryRemoveForm");
+    var nameField = document.getElementById("categoryEditForm_bean_name");
+    var descField = 
document.getElementById("categoryEditForm_bean_description");
+    var imageField = document.getElementById("categoryEditForm_bean_image");
+    var imagePreview = document.getElementById("categoryImagePreview");
+    var saveButton = document.getElementById("categorySaveButton");
+    var deleteButton = document.getElementById("categoryDeleteButton");
+    var targetSelect = 
document.getElementById("categoryRemove_targetCategoryId");
+
+    function text(name) {
+        return page.dataset[name] || "";
+    }
+
+    // messages come from resource bundles, which write a parameter as {0}
+    function format(message, value) {
+        // a function replacement, so a "$&" in the name is not expanded
+        return message.replace("{0}", function () {
+            return value;
+        });
+    }
+
+    function rowOf(element) {
+        return element.closest(".category-row");
+    }
+
+    // --- confirmation after a change -------------------------------------
+
+    function showToast(message, isError) {
+        var toast = document.getElementById("categoryToast");
+        toast.querySelector(".toast-body").textContent = message;
+        toast.classList.toggle("text-bg-success", !isError);
+        toast.classList.toggle("text-bg-danger", !!isError);
+        bootstrap.Toast.getOrCreateInstance(toast).show();
+    }
+
+    function reloadWith(message, categoryName) {
+        try {
+            sessionStorage.setItem(FLASH_KEY, JSON.stringify({ message: 
message, name: categoryName }));
+        } catch (e) {
+            // storage unavailable: reload without the confirmation
+        }
+        location.reload();
+    }
+
+    function showFlash() {
+        var flash = null;
+        try {
+            flash = JSON.parse(sessionStorage.getItem(FLASH_KEY));
+            sessionStorage.removeItem(FLASH_KEY);
+        } catch (e) {
+            return;
+        }
+        if (!flash) {
+            return;
+        }
+        showToast(flash.message);
+        list.querySelectorAll(".category-row").forEach(function (row) {
+            if (flash.name && row.dataset.categoryName === flash.name) {
+                row.classList.add("category-row-changed");
+                row.scrollIntoView({ block: "nearest" });
+            }
+        });
+    }
+
+    // --- posting -----------------------------------------------------------
+
+    function post(form, extra) {
+        var data = $(form).serialize() + (extra || "");
+        return $.ajax({ method: "post", url: form.action, data: data 
}).done(function (html) {
+            refreshSalt(html);
+        }).fail(function (xhr) {
+            // the salt just sent is spent whether or not the post worked
+            if (xhr && xhr.responseText) {
+                refreshSalt(xhr.responseText);
+            }
+        });
+    }
+
+    function showErrors(box, errors) {
+        box.textContent = errors.join(" ");
+        box.classList.toggle("d-none", errors.length === 0);
+    }
+
+    // --- add and edit 
--------------------------------------------------------
+
+    function setInvalid(field, feedbackId, message) {
+        field.classList.toggle("is-invalid", !!message);
+        document.getElementById(feedbackId).textContent = message || "";
+    }
+
+    function imageUrl() {
+        var value = imageField.value.trim();
+        if (!value) {
+            return "";
+        }
+        try {
+            var url = new URL(value);
+            return url.protocol === "http:" || url.protocol === "https:" ? 
url.href : null;
+        } catch (e) {
+            return null;
+        }
+    }
+
+    function validate() {
+        var nameOk = nameField.value.trim() !== "";
+        var image = imageUrl();
+        setInvalid(nameField, "categoryNameFeedback", nameOk ? "" : 
text("msgNameRequired"));
+        setInvalid(imageField, "categoryImageFeedback", image === null ? 
text("msgBadUrl") : "");
+        imagePreview.classList.toggle("d-none", !image);
+        if (image && imagePreview.getAttribute("src") !== image) {
+            imagePreview.setAttribute("src", image);
+        }
+        saveButton.disabled = !nameOk || image === null;
+        return !saveButton.disabled;
+    }
+
+    function openEditor(row) {
+        var adding = !row;
+        document.getElementById("category-edit-title").textContent = adding ? 
text("titleAdd") : text("titleEdit");
+        document.getElementById("categoryEditForm_bean_id").value = adding ? 
"" : row.dataset.categoryId;
+        nameField.value = adding ? "" : row.dataset.categoryName;
+        descField.value = adding ? "" : row.dataset.categoryDesc;
+        imageField.value = adding ? "" : row.dataset.categoryImage;
+        showErrors(document.getElementById("categoryEditErrors"), []);
+        setInvalid(nameField, "categoryNameFeedback", "");
+        setInvalid(imageField, "categoryImageFeedback", "");
+        imagePreview.classList.add("d-none");
+        imagePreview.removeAttribute("src");
+        saveButton.disabled = adding;
+        showChooser(false);
+        if (!adding) {
+            validate();
+        }
+        editModal.show();
+    }
+
+    function saveCategory() {
+        if (!validate()) {
+            return;
+        }
+        var adding = document.getElementById("categoryEditForm_bean_id").value 
=== "";
+        var name = nameField.value.trim();
+        saveButton.disabled = true;
+        post(editForm).done(function (html) {
+            var errors = actionErrors(html);
+            if (errors.length > 0) {
+                showErrors(document.getElementById("categoryEditErrors"), 
errors);
+                saveButton.disabled = false;
+            } else {
+                reloadWith(format(adding ? text("msgAdded") : 
text("msgSaved"), name), name);
+            }
+        }).fail(function () {
+            showErrors(document.getElementById("categoryEditErrors"), 
[text("msgError")]);
+            saveButton.disabled = false;
+        });
+    }
+
+    nameField.addEventListener("input", validate);
+    imageField.addEventListener("input", validate);
+    imagePreview.addEventListener("error", function () {
+        imagePreview.classList.add("d-none");
+    });
+    saveButton.addEventListener("click", saveCategory);
+    // Enter in a field saves; the form has no submit button and is never 
posted directly
+    editForm.addEventListener("submit", function (event) {
+        event.preventDefault();
+    });
+    editForm.addEventListener("keydown", function (event) {
+        if (event.key === "Enter" && event.target.matches("input")) {
+            event.preventDefault();
+            saveCategory();
+        }
+    });
+    document.getElementById("addCategoryButton").addEventListener("click", 
function () {
+        openEditor(null);
+    });
+    
document.getElementById("category-edit-modal").addEventListener("shown.bs.modal",
 function () {
+        nameField.focus();
+    });
+
+    // --- choosing an image from the media library ---------------------------
+    // Only when uploads are on; otherwise the page has no chooser and these 
are null.
+
+    var chooser = document.getElementById("categoryImageChooser");
+    var chooserFrame = document.getElementById("categoryImageChooserFrame");
+    var editTitle = document.getElementById("category-edit-title");
+    var formTitle = "";
+
+    // the dialog shows either the form or the chooser, which needs a wider 
dialog
+    function showChooser(on) {
+        if (!chooser) {
+            return;
+        }
+        var showing = !chooser.classList.contains("d-none");
+        if (on === showing) {
+            return;
+        }
+        if (on) {
+            formTitle = editTitle.textContent;
+            editTitle.textContent = chooser.dataset.title;
+            chooserFrame.setAttribute("src", chooser.dataset.src);
+        } else {
+            editTitle.textContent = formTitle || editTitle.textContent;
+            chooserFrame.setAttribute("src", "about:blank");
+        }
+        chooser.classList.toggle("d-none", !on);
+        editForm.classList.toggle("d-none", on);
+        document.querySelector("#category-edit-modal 
.modal-dialog").classList.toggle("modal-lg", on);
+        
document.getElementById("categoryChooserBackButton").classList.toggle("d-none", 
!on);
+        
document.getElementById("categoryCancelButton").classList.toggle("d-none", on);
+        saveButton.classList.toggle("d-none", on);
+    }
+
+    if (chooser) {
+        
document.getElementById("categoryImageChooseButton").addEventListener("click", 
function () {
+            showChooser(true);
+        });
+        
document.getElementById("categoryChooserBackButton").addEventListener("click", 
function () {
+            showChooser(false);
+            document.getElementById("categoryImageChooseButton").focus();
+        });
+        
document.getElementById("category-edit-modal").addEventListener("hidden.bs.modal",
 function () {
+            showChooser(false);
+        });
+
+        // called by the chooser in the frame (MediaFileImageChooser.jsp); it 
lists only images
+        window.onSelectMediaFile = function (name, url, isImage) {
+            if (isImage !== "true") {
+                return;
+            }
+            imageField.value = url;
+            showChooser(false);
+            validate();
+            imageField.focus();
+        };
+    }
+
+    // --- delete 
----------------------------------------------------------------
+
+    function openDelete(row) {
+        var id = row.dataset.categoryId;
+        var inUse = row.dataset.inUse === "true";
+        document.getElementById("category-delete-title").textContent = 
format(text("titleDelete"), row.dataset.categoryName);
+        document.getElementById("categoryRemove_removeId").value = id;
+        document.getElementById("categoryDeleteText").textContent = 
row.dataset.deleteText;
+        showErrors(document.getElementById("categoryDeleteErrors"), []);
+
+        // the other categories, in display order, after the "choose" option
+        while (targetSelect.options.length > 1) {
+            targetSelect.remove(1);
+        }
+        list.querySelectorAll(".category-row").forEach(function (other) {
+            if (other.dataset.categoryId !== id) {
+                targetSelect.add(new Option(other.dataset.categoryName, 
other.dataset.categoryId));
+            }
+        });
+        targetSelect.value = "";
+
+        // a category nothing uses needs no target; a disabled select is not 
posted
+        
document.getElementById("categoryMoveTarget").classList.toggle("d-none", 
!inUse);
+        targetSelect.disabled = !inUse;
+        deleteButton.disabled = inUse;
+        deleteButton.dataset.categoryName = row.dataset.categoryName;
+        deleteModal.show();
+    }
+
+    targetSelect.addEventListener("change", function () {
+        deleteButton.disabled = targetSelect.value === "";
+    });
+
+    deleteButton.addEventListener("click", function () {
+        var name = deleteButton.dataset.categoryName;
+        deleteButton.disabled = true;
+        post(removeForm).done(function (html) {
+            var errors = actionErrors(html);
+            if (errors.length > 0) {
+                showErrors(document.getElementById("categoryDeleteErrors"), 
errors);
+                deleteButton.disabled = false;
+            } else {
+                reloadWith(format(text("msgRemoved"), name), null);
+            }
+        }).fail(function () {
+            showErrors(document.getElementById("categoryDeleteErrors"), 
[text("msgError")]);
+            deleteButton.disabled = false;
+        });
+    });
+
+    // --- row actions 
-----------------------------------------------------------
+
+    list.addEventListener("click", function (event) {
+        var edit = event.target.closest(".category-edit-link");
+        var remove = event.target.closest(".category-delete-link");
+        if (edit) {
+            event.preventDefault();
+            openEditor(rowOf(edit));
+        } else if (remove) {
+            openDelete(rowOf(remove));
+        }
+    });
+
+    // --- reordering 
------------------------------------------------------------
+
+    var saveTimer = null;
+    var saving = false;
+    var saveAgain = false;
+
+    // Each post spends the page's salt and returns a new one, so only one save
+    // can be in flight. A change made meanwhile is sent afterwards, from the
+    // list as it is then.
+    function saveOrder() {

Review Comment:
   🤖Claude: fixed in 76c13ffc8: `post()` now sends all posts one at a time 
through one queue, and reads the form when its turn comes so it carries the 
latest salt. This covers the edit, delete and order forms.



##########
app/src/main/webapp/roller-ui/scripts/categories.js:
##########
@@ -0,0 +1,428 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one or more
+*  contributor license agreements.  The ASF licenses this file to You
+* under the Apache License, Version 2.0 (the "License"); you may not
+* use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*     http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.  For additional information regarding
+* copyright in this work, please see the NOTICE file in the top level
+* directory of this distribution.
+*/
+
+/*
+ * Categories page (Categories.jsp): add, edit and delete dialogs that post 
with
+ * AJAX, and drag (or arrow-key) reordering. After a change the page reloads 
and
+ * shows a short confirmation, carried across the reload in sessionStorage.
+ */
+(function ($) {
+    "use strict";
+
+    var page = document.getElementById("categoriesPage");
+    if (!page) {
+        return;
+    }
+
+    var FLASH_KEY = "roller.categories.flash";
+    var list = document.getElementById("categoryList");
+    var editModal = new 
bootstrap.Modal(document.getElementById("category-edit-modal"));
+    var deleteModal = new 
bootstrap.Modal(document.getElementById("delete-category-modal"));
+    var editForm = document.getElementById("categoryEditForm");
+    var removeForm = document.getElementById("categoryRemoveForm");
+    var nameField = document.getElementById("categoryEditForm_bean_name");
+    var descField = 
document.getElementById("categoryEditForm_bean_description");
+    var imageField = document.getElementById("categoryEditForm_bean_image");
+    var imagePreview = document.getElementById("categoryImagePreview");
+    var saveButton = document.getElementById("categorySaveButton");
+    var deleteButton = document.getElementById("categoryDeleteButton");
+    var targetSelect = 
document.getElementById("categoryRemove_targetCategoryId");
+
+    function text(name) {
+        return page.dataset[name] || "";
+    }
+
+    // messages come from resource bundles, which write a parameter as {0}
+    function format(message, value) {
+        // a function replacement, so a "$&" in the name is not expanded
+        return message.replace("{0}", function () {
+            return value;
+        });
+    }
+
+    function rowOf(element) {
+        return element.closest(".category-row");
+    }
+
+    // --- confirmation after a change -------------------------------------
+
+    function showToast(message, isError) {
+        var toast = document.getElementById("categoryToast");
+        toast.querySelector(".toast-body").textContent = message;
+        toast.classList.toggle("text-bg-success", !isError);
+        toast.classList.toggle("text-bg-danger", !!isError);
+        bootstrap.Toast.getOrCreateInstance(toast).show();
+    }
+
+    function reloadWith(message, categoryName) {
+        try {
+            sessionStorage.setItem(FLASH_KEY, JSON.stringify({ message: 
message, name: categoryName }));
+        } catch (e) {
+            // storage unavailable: reload without the confirmation
+        }
+        location.reload();
+    }
+
+    function showFlash() {
+        var flash = null;
+        try {
+            flash = JSON.parse(sessionStorage.getItem(FLASH_KEY));
+            sessionStorage.removeItem(FLASH_KEY);
+        } catch (e) {
+            return;
+        }
+        if (!flash) {
+            return;
+        }
+        showToast(flash.message);
+        list.querySelectorAll(".category-row").forEach(function (row) {
+            if (flash.name && row.dataset.categoryName === flash.name) {
+                row.classList.add("category-row-changed");
+                row.scrollIntoView({ block: "nearest" });
+            }
+        });
+    }
+
+    // --- posting -----------------------------------------------------------
+
+    function post(form, extra) {
+        var data = $(form).serialize() + (extra || "");
+        return $.ajax({ method: "post", url: form.action, data: data 
}).done(function (html) {
+            refreshSalt(html);
+        }).fail(function (xhr) {
+            // the salt just sent is spent whether or not the post worked
+            if (xhr && xhr.responseText) {
+                refreshSalt(xhr.responseText);
+            }
+        });
+    }
+
+    function showErrors(box, errors) {
+        box.textContent = errors.join(" ");
+        box.classList.toggle("d-none", errors.length === 0);
+    }
+
+    // --- add and edit 
--------------------------------------------------------
+
+    function setInvalid(field, feedbackId, message) {
+        field.classList.toggle("is-invalid", !!message);
+        document.getElementById(feedbackId).textContent = message || "";
+    }
+
+    function imageUrl() {
+        var value = imageField.value.trim();
+        if (!value) {
+            return "";
+        }
+        try {
+            var url = new URL(value);
+            return url.protocol === "http:" || url.protocol === "https:" ? 
url.href : null;
+        } catch (e) {
+            return null;
+        }
+    }
+
+    function validate() {
+        var nameOk = nameField.value.trim() !== "";
+        var image = imageUrl();
+        setInvalid(nameField, "categoryNameFeedback", nameOk ? "" : 
text("msgNameRequired"));
+        setInvalid(imageField, "categoryImageFeedback", image === null ? 
text("msgBadUrl") : "");
+        imagePreview.classList.toggle("d-none", !image);
+        if (image && imagePreview.getAttribute("src") !== image) {
+            imagePreview.setAttribute("src", image);
+        }
+        saveButton.disabled = !nameOk || image === null;
+        return !saveButton.disabled;
+    }
+
+    function openEditor(row) {
+        var adding = !row;
+        document.getElementById("category-edit-title").textContent = adding ? 
text("titleAdd") : text("titleEdit");
+        document.getElementById("categoryEditForm_bean_id").value = adding ? 
"" : row.dataset.categoryId;
+        nameField.value = adding ? "" : row.dataset.categoryName;
+        descField.value = adding ? "" : row.dataset.categoryDesc;
+        imageField.value = adding ? "" : row.dataset.categoryImage;
+        showErrors(document.getElementById("categoryEditErrors"), []);
+        setInvalid(nameField, "categoryNameFeedback", "");
+        setInvalid(imageField, "categoryImageFeedback", "");
+        imagePreview.classList.add("d-none");
+        imagePreview.removeAttribute("src");
+        saveButton.disabled = adding;
+        showChooser(false);
+        if (!adding) {
+            validate();
+        }
+        editModal.show();
+    }
+
+    function saveCategory() {
+        if (!validate()) {
+            return;
+        }
+        var adding = document.getElementById("categoryEditForm_bean_id").value 
=== "";
+        var name = nameField.value.trim();
+        saveButton.disabled = true;
+        post(editForm).done(function (html) {
+            var errors = actionErrors(html);
+            if (errors.length > 0) {
+                showErrors(document.getElementById("categoryEditErrors"), 
errors);
+                saveButton.disabled = false;
+            } else {
+                reloadWith(format(adding ? text("msgAdded") : 
text("msgSaved"), name), name);
+            }
+        }).fail(function () {
+            showErrors(document.getElementById("categoryEditErrors"), 
[text("msgError")]);
+            saveButton.disabled = false;
+        });
+    }
+
+    nameField.addEventListener("input", validate);
+    imageField.addEventListener("input", validate);
+    imagePreview.addEventListener("error", function () {
+        imagePreview.classList.add("d-none");
+    });
+    saveButton.addEventListener("click", saveCategory);
+    // Enter in a field saves; the form has no submit button and is never 
posted directly
+    editForm.addEventListener("submit", function (event) {
+        event.preventDefault();
+    });
+    editForm.addEventListener("keydown", function (event) {
+        if (event.key === "Enter" && event.target.matches("input")) {

Review Comment:
   🤖Claude: fixed in 76c13ffc8: `saveCategory()` returns early while a save is 
in flight (a separate flag, since `validate()` re-enables the button), and the 
Enter handler ignores `event.isComposing`.



##########
app/src/main/webapp/roller-ui/scripts/categories.js:
##########
@@ -0,0 +1,428 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one or more
+*  contributor license agreements.  The ASF licenses this file to You
+* under the Apache License, Version 2.0 (the "License"); you may not
+* use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*     http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.  For additional information regarding
+* copyright in this work, please see the NOTICE file in the top level
+* directory of this distribution.
+*/
+
+/*
+ * Categories page (Categories.jsp): add, edit and delete dialogs that post 
with
+ * AJAX, and drag (or arrow-key) reordering. After a change the page reloads 
and
+ * shows a short confirmation, carried across the reload in sessionStorage.
+ */
+(function ($) {
+    "use strict";
+
+    var page = document.getElementById("categoriesPage");
+    if (!page) {
+        return;
+    }
+
+    var FLASH_KEY = "roller.categories.flash";
+    var list = document.getElementById("categoryList");
+    var editModal = new 
bootstrap.Modal(document.getElementById("category-edit-modal"));
+    var deleteModal = new 
bootstrap.Modal(document.getElementById("delete-category-modal"));
+    var editForm = document.getElementById("categoryEditForm");
+    var removeForm = document.getElementById("categoryRemoveForm");
+    var nameField = document.getElementById("categoryEditForm_bean_name");
+    var descField = 
document.getElementById("categoryEditForm_bean_description");
+    var imageField = document.getElementById("categoryEditForm_bean_image");
+    var imagePreview = document.getElementById("categoryImagePreview");
+    var saveButton = document.getElementById("categorySaveButton");
+    var deleteButton = document.getElementById("categoryDeleteButton");
+    var targetSelect = 
document.getElementById("categoryRemove_targetCategoryId");
+
+    function text(name) {
+        return page.dataset[name] || "";
+    }
+
+    // messages come from resource bundles, which write a parameter as {0}
+    function format(message, value) {
+        // a function replacement, so a "$&" in the name is not expanded
+        return message.replace("{0}", function () {
+            return value;
+        });
+    }
+
+    function rowOf(element) {
+        return element.closest(".category-row");
+    }
+
+    // --- confirmation after a change -------------------------------------
+
+    function showToast(message, isError) {
+        var toast = document.getElementById("categoryToast");
+        toast.querySelector(".toast-body").textContent = message;
+        toast.classList.toggle("text-bg-success", !isError);
+        toast.classList.toggle("text-bg-danger", !!isError);
+        bootstrap.Toast.getOrCreateInstance(toast).show();
+    }
+
+    function reloadWith(message, categoryName) {
+        try {
+            sessionStorage.setItem(FLASH_KEY, JSON.stringify({ message: 
message, name: categoryName }));
+        } catch (e) {
+            // storage unavailable: reload without the confirmation
+        }
+        location.reload();
+    }
+
+    function showFlash() {
+        var flash = null;
+        try {
+            flash = JSON.parse(sessionStorage.getItem(FLASH_KEY));
+            sessionStorage.removeItem(FLASH_KEY);
+        } catch (e) {
+            return;
+        }
+        if (!flash) {
+            return;
+        }
+        showToast(flash.message);
+        list.querySelectorAll(".category-row").forEach(function (row) {
+            if (flash.name && row.dataset.categoryName === flash.name) {
+                row.classList.add("category-row-changed");
+                row.scrollIntoView({ block: "nearest" });
+            }
+        });
+    }
+
+    // --- posting -----------------------------------------------------------
+
+    function post(form, extra) {
+        var data = $(form).serialize() + (extra || "");
+        return $.ajax({ method: "post", url: form.action, data: data 
}).done(function (html) {
+            refreshSalt(html);
+        }).fail(function (xhr) {
+            // the salt just sent is spent whether or not the post worked
+            if (xhr && xhr.responseText) {
+                refreshSalt(xhr.responseText);
+            }
+        });
+    }
+
+    function showErrors(box, errors) {
+        box.textContent = errors.join(" ");
+        box.classList.toggle("d-none", errors.length === 0);
+    }
+
+    // --- add and edit 
--------------------------------------------------------
+
+    function setInvalid(field, feedbackId, message) {
+        field.classList.toggle("is-invalid", !!message);
+        document.getElementById(feedbackId).textContent = message || "";
+    }
+
+    function imageUrl() {
+        var value = imageField.value.trim();
+        if (!value) {
+            return "";
+        }
+        try {
+            var url = new URL(value);
+            return url.protocol === "http:" || url.protocol === "https:" ? 
url.href : null;
+        } catch (e) {
+            return null;
+        }
+    }
+
+    function validate() {
+        var nameOk = nameField.value.trim() !== "";
+        var image = imageUrl();
+        setInvalid(nameField, "categoryNameFeedback", nameOk ? "" : 
text("msgNameRequired"));
+        setInvalid(imageField, "categoryImageFeedback", image === null ? 
text("msgBadUrl") : "");
+        imagePreview.classList.toggle("d-none", !image);
+        if (image && imagePreview.getAttribute("src") !== image) {
+            imagePreview.setAttribute("src", image);
+        }
+        saveButton.disabled = !nameOk || image === null;
+        return !saveButton.disabled;
+    }
+
+    function openEditor(row) {
+        var adding = !row;
+        document.getElementById("category-edit-title").textContent = adding ? 
text("titleAdd") : text("titleEdit");
+        document.getElementById("categoryEditForm_bean_id").value = adding ? 
"" : row.dataset.categoryId;
+        nameField.value = adding ? "" : row.dataset.categoryName;
+        descField.value = adding ? "" : row.dataset.categoryDesc;
+        imageField.value = adding ? "" : row.dataset.categoryImage;
+        showErrors(document.getElementById("categoryEditErrors"), []);
+        setInvalid(nameField, "categoryNameFeedback", "");
+        setInvalid(imageField, "categoryImageFeedback", "");
+        imagePreview.classList.add("d-none");
+        imagePreview.removeAttribute("src");
+        saveButton.disabled = adding;
+        showChooser(false);
+        if (!adding) {
+            validate();
+        }
+        editModal.show();
+    }
+
+    function saveCategory() {
+        if (!validate()) {
+            return;
+        }
+        var adding = document.getElementById("categoryEditForm_bean_id").value 
=== "";
+        var name = nameField.value.trim();
+        saveButton.disabled = true;
+        post(editForm).done(function (html) {
+            var errors = actionErrors(html);
+            if (errors.length > 0) {
+                showErrors(document.getElementById("categoryEditErrors"), 
errors);
+                saveButton.disabled = false;
+            } else {
+                reloadWith(format(adding ? text("msgAdded") : 
text("msgSaved"), name), name);
+            }
+        }).fail(function () {
+            showErrors(document.getElementById("categoryEditErrors"), 
[text("msgError")]);
+            saveButton.disabled = false;
+        });
+    }
+
+    nameField.addEventListener("input", validate);
+    imageField.addEventListener("input", validate);
+    imagePreview.addEventListener("error", function () {
+        imagePreview.classList.add("d-none");
+    });
+    saveButton.addEventListener("click", saveCategory);
+    // Enter in a field saves; the form has no submit button and is never 
posted directly
+    editForm.addEventListener("submit", function (event) {
+        event.preventDefault();
+    });
+    editForm.addEventListener("keydown", function (event) {
+        if (event.key === "Enter" && event.target.matches("input")) {
+            event.preventDefault();
+            saveCategory();
+        }
+    });
+    document.getElementById("addCategoryButton").addEventListener("click", 
function () {
+        openEditor(null);
+    });
+    
document.getElementById("category-edit-modal").addEventListener("shown.bs.modal",
 function () {
+        nameField.focus();
+    });
+
+    // --- choosing an image from the media library ---------------------------
+    // Only when uploads are on; otherwise the page has no chooser and these 
are null.
+
+    var chooser = document.getElementById("categoryImageChooser");
+    var chooserFrame = document.getElementById("categoryImageChooserFrame");
+    var editTitle = document.getElementById("category-edit-title");
+    var formTitle = "";
+
+    // the dialog shows either the form or the chooser, which needs a wider 
dialog
+    function showChooser(on) {
+        if (!chooser) {
+            return;
+        }
+        var showing = !chooser.classList.contains("d-none");
+        if (on === showing) {
+            return;
+        }
+        if (on) {
+            formTitle = editTitle.textContent;
+            editTitle.textContent = chooser.dataset.title;
+            chooserFrame.setAttribute("src", chooser.dataset.src);
+        } else {
+            editTitle.textContent = formTitle || editTitle.textContent;
+            chooserFrame.setAttribute("src", "about:blank");
+        }
+        chooser.classList.toggle("d-none", !on);
+        editForm.classList.toggle("d-none", on);
+        document.querySelector("#category-edit-modal 
.modal-dialog").classList.toggle("modal-lg", on);
+        
document.getElementById("categoryChooserBackButton").classList.toggle("d-none", 
!on);
+        
document.getElementById("categoryCancelButton").classList.toggle("d-none", on);
+        saveButton.classList.toggle("d-none", on);
+    }
+
+    if (chooser) {
+        
document.getElementById("categoryImageChooseButton").addEventListener("click", 
function () {
+            showChooser(true);
+        });
+        
document.getElementById("categoryChooserBackButton").addEventListener("click", 
function () {
+            showChooser(false);
+            document.getElementById("categoryImageChooseButton").focus();
+        });
+        
document.getElementById("category-edit-modal").addEventListener("hidden.bs.modal",
 function () {
+            showChooser(false);
+        });
+
+        // called by the chooser in the frame (MediaFileImageChooser.jsp); it 
lists only images
+        window.onSelectMediaFile = function (name, url, isImage) {
+            if (isImage !== "true") {
+                return;
+            }
+            imageField.value = url;
+            showChooser(false);
+            validate();
+            imageField.focus();
+        };
+    }
+
+    // --- delete 
----------------------------------------------------------------
+
+    function openDelete(row) {
+        var id = row.dataset.categoryId;
+        var inUse = row.dataset.inUse === "true";
+        document.getElementById("category-delete-title").textContent = 
format(text("titleDelete"), row.dataset.categoryName);
+        document.getElementById("categoryRemove_removeId").value = id;
+        document.getElementById("categoryDeleteText").textContent = 
row.dataset.deleteText;
+        showErrors(document.getElementById("categoryDeleteErrors"), []);
+
+        // the other categories, in display order, after the "choose" option
+        while (targetSelect.options.length > 1) {
+            targetSelect.remove(1);
+        }
+        list.querySelectorAll(".category-row").forEach(function (other) {
+            if (other.dataset.categoryId !== id) {
+                targetSelect.add(new Option(other.dataset.categoryName, 
other.dataset.categoryId));
+            }
+        });
+        targetSelect.value = "";
+
+        // a category nothing uses needs no target; a disabled select is not 
posted
+        
document.getElementById("categoryMoveTarget").classList.toggle("d-none", 
!inUse);
+        targetSelect.disabled = !inUse;

Review Comment:
   🤖Claude: fixed in 76c13ffc8: if the delete fails while no target was sent, 
the dialog now shows the server's error, reveals the target select and keeps 
Delete disabled until a target is chosen.



##########
app/src/main/webapp/roller-ui/scripts/categories.js:
##########
@@ -0,0 +1,428 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one or more
+*  contributor license agreements.  The ASF licenses this file to You
+* under the Apache License, Version 2.0 (the "License"); you may not
+* use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*     http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.  For additional information regarding
+* copyright in this work, please see the NOTICE file in the top level
+* directory of this distribution.
+*/
+
+/*
+ * Categories page (Categories.jsp): add, edit and delete dialogs that post 
with
+ * AJAX, and drag (or arrow-key) reordering. After a change the page reloads 
and
+ * shows a short confirmation, carried across the reload in sessionStorage.
+ */
+(function ($) {
+    "use strict";
+
+    var page = document.getElementById("categoriesPage");
+    if (!page) {
+        return;
+    }
+
+    var FLASH_KEY = "roller.categories.flash";
+    var list = document.getElementById("categoryList");
+    var editModal = new 
bootstrap.Modal(document.getElementById("category-edit-modal"));
+    var deleteModal = new 
bootstrap.Modal(document.getElementById("delete-category-modal"));
+    var editForm = document.getElementById("categoryEditForm");
+    var removeForm = document.getElementById("categoryRemoveForm");
+    var nameField = document.getElementById("categoryEditForm_bean_name");
+    var descField = 
document.getElementById("categoryEditForm_bean_description");
+    var imageField = document.getElementById("categoryEditForm_bean_image");
+    var imagePreview = document.getElementById("categoryImagePreview");
+    var saveButton = document.getElementById("categorySaveButton");
+    var deleteButton = document.getElementById("categoryDeleteButton");
+    var targetSelect = 
document.getElementById("categoryRemove_targetCategoryId");
+
+    function text(name) {
+        return page.dataset[name] || "";
+    }
+
+    // messages come from resource bundles, which write a parameter as {0}
+    function format(message, value) {
+        // a function replacement, so a "$&" in the name is not expanded
+        return message.replace("{0}", function () {
+            return value;
+        });
+    }
+
+    function rowOf(element) {
+        return element.closest(".category-row");
+    }
+
+    // --- confirmation after a change -------------------------------------
+
+    function showToast(message, isError) {
+        var toast = document.getElementById("categoryToast");
+        toast.querySelector(".toast-body").textContent = message;
+        toast.classList.toggle("text-bg-success", !isError);
+        toast.classList.toggle("text-bg-danger", !!isError);
+        bootstrap.Toast.getOrCreateInstance(toast).show();
+    }
+
+    function reloadWith(message, categoryName) {
+        try {
+            sessionStorage.setItem(FLASH_KEY, JSON.stringify({ message: 
message, name: categoryName }));
+        } catch (e) {
+            // storage unavailable: reload without the confirmation
+        }
+        location.reload();
+    }
+
+    function showFlash() {
+        var flash = null;
+        try {
+            flash = JSON.parse(sessionStorage.getItem(FLASH_KEY));
+            sessionStorage.removeItem(FLASH_KEY);
+        } catch (e) {
+            return;
+        }
+        if (!flash) {
+            return;
+        }
+        showToast(flash.message);
+        list.querySelectorAll(".category-row").forEach(function (row) {
+            if (flash.name && row.dataset.categoryName === flash.name) {
+                row.classList.add("category-row-changed");
+                row.scrollIntoView({ block: "nearest" });
+            }
+        });
+    }
+
+    // --- posting -----------------------------------------------------------
+
+    function post(form, extra) {
+        var data = $(form).serialize() + (extra || "");
+        return $.ajax({ method: "post", url: form.action, data: data 
}).done(function (html) {
+            refreshSalt(html);
+        }).fail(function (xhr) {
+            // the salt just sent is spent whether or not the post worked
+            if (xhr && xhr.responseText) {
+                refreshSalt(xhr.responseText);
+            }
+        });
+    }
+
+    function showErrors(box, errors) {
+        box.textContent = errors.join(" ");
+        box.classList.toggle("d-none", errors.length === 0);
+    }
+
+    // --- add and edit 
--------------------------------------------------------
+
+    function setInvalid(field, feedbackId, message) {
+        field.classList.toggle("is-invalid", !!message);
+        document.getElementById(feedbackId).textContent = message || "";
+    }
+
+    function imageUrl() {
+        var value = imageField.value.trim();
+        if (!value) {
+            return "";
+        }
+        try {
+            var url = new URL(value);
+            return url.protocol === "http:" || url.protocol === "https:" ? 
url.href : null;
+        } catch (e) {
+            return null;
+        }
+    }
+
+    function validate() {
+        var nameOk = nameField.value.trim() !== "";
+        var image = imageUrl();
+        setInvalid(nameField, "categoryNameFeedback", nameOk ? "" : 
text("msgNameRequired"));
+        setInvalid(imageField, "categoryImageFeedback", image === null ? 
text("msgBadUrl") : "");
+        imagePreview.classList.toggle("d-none", !image);

Review Comment:
   🤖Claude: fixed in 76c13ffc8: the page remembers the address that failed to 
load and keeps the preview hidden until the address changes.



##########
app/src/main/java/org/apache/roller/weblogger/ui/struts2/editor/Categories.java:
##########
@@ -18,62 +18,144 @@
 
 package org.apache.roller.weblogger.ui.struts2.editor;
 
+import java.util.ArrayList;
 import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
+
 import org.apache.commons.logging.Log;
 import org.apache.commons.logging.LogFactory;
 import org.apache.roller.weblogger.WebloggerException;
 import org.apache.roller.weblogger.business.WebloggerFactory;
 import org.apache.roller.weblogger.business.WeblogEntryManager;
+import org.apache.roller.weblogger.config.WebloggerRuntimeConfig;
 import org.apache.roller.weblogger.pojos.WeblogCategory;
 import org.apache.roller.weblogger.pojos.WeblogPermission;
 import org.apache.roller.weblogger.ui.struts2.util.UIAction;
-import org.apache.struts2.convention.annotation.AllowedMethods;
+import org.apache.roller.weblogger.util.cache.CacheManager;
+import org.apache.struts2.interceptor.parameter.StrutsParameter;
 
 /**
- * Manage weblog categories.
+ * Manage weblog categories: list them with their entry counts, and change
+ * their order. Adding, editing and removing are done by CategoryEdit and
+ * CategoryRemove.
  */
-// TODO: make this work @AllowedMethods({"execute","move"})
 public class Categories extends UIAction {
 
-       private static Log log = LogFactory.getLog(Categories.class);
+    private static final Log log = LogFactory.getLog(Categories.class);
+
+    // all categories from the action weblog, in display order
+    private List<WeblogCategory> allCategories = Collections.emptyList();
 
-       // all categories from the action weblog
-       private List<WeblogCategory> allCategories;
+    // number of entries in each category, by category id
+    private Map<String, Long> entryCounts = Collections.emptyMap();
 
-       public Categories() {
-               this.actionName = "categories";
-               this.desiredMenu = "editor";
-               this.pageTitle = "categoriesForm.rootTitle";
-       }
+    // category ids in their new order, posted by reorder
+    private List<String> categoryIds = new ArrayList<>();
 
-       @Override
-       public List<String> requiredWeblogPermissionActions() {
-               return Collections.singletonList(WeblogPermission.POST);
-       }
+    public Categories() {
+        this.actionName = "categories";
+        this.desiredMenu = "editor";
+        this.pageTitle = "categoriesForm.rootTitle";
+    }
 
-       @Override
-       public String execute() {
-               try {
+    @Override
+    public List<String> requiredWeblogPermissionActions() {
+        return Collections.singletonList(WeblogPermission.POST);
+    }
+
+    @Override
+    public String execute() {
+        try {
             WeblogEntryManager wmgr = 
WebloggerFactory.getWeblogger().getWeblogEntryManager();
-                       allCategories = 
wmgr.getWeblogCategories(getActionWeblog());
-               } catch (WebloggerException ex) {
-                       log.error("Error building categories list", ex);
-                       addError("Error building categories list");
-               }
-
-               return LIST;
-       }
-
-       public String move() {
-               return execute();
-       }
-
-       public List<WeblogCategory> getAllCategories() {
-               return allCategories;
-       }
-
-       public void setAllCategories(List<WeblogCategory> allCategories) {
-               this.allCategories = allCategories;
-       }
+            allCategories = wmgr.getWeblogCategories(getActionWeblog());
+            entryCounts = wmgr.getWeblogCategoryEntryCounts(getActionWeblog());
+        } catch (WebloggerException ex) {
+            log.error("Error building categories list", ex);
+            addError("generic.error.check.logs");
+        }
+        return LIST;
+    }
+
+    /**
+     * Save a new display order. The posted ids must name every category of
+     * the weblog exactly once.
+     */
+    public String reorder() {
+        try {
+            WeblogEntryManager wmgr = 
WebloggerFactory.getWeblogger().getWeblogEntryManager();
+            List<WeblogCategory> current = 
wmgr.getWeblogCategories(getActionWeblog());
+
+            Map<String, WeblogCategory> byId = new HashMap<>();
+            for (WeblogCategory category : current) {
+                byId.put(category.getId(), category);
+            }
+            if (categoryIds.size() != current.size()
+                    || !byId.keySet().equals(new HashSet<>(categoryIds))) {
+                addError("categoriesForm.error.reorder");
+                return execute();
+            }
+
+            for (int position = 0; position < categoryIds.size(); position++) {
+                WeblogCategory category = byId.get(categoryIds.get(position));
+                if (category.getPosition() != position) {
+                    category.setPosition(position);
+                    wmgr.saveWeblogCategory(category);
+                }
+            }
+            // the weblog's own list is sorted only when it is loaded, and 
themes
+            // and new categories read it, so put it in the new order too
+            getActionWeblog().getWeblogCategories()
+                    
.sort(Comparator.comparingInt(WeblogCategory::getPosition));

Review Comment:
   🤖Claude: partly fixed in 76c13ffc8: a new test in `WeblogCategoryCRUDTest` 
reorders, ends the session and reads the weblog's category list in a new 
session, and it sees the new order. That runs in one JVM with the shared cache. 
I have not checked the public site on a running Roller.



##########
app/src/main/java/org/apache/roller/weblogger/ui/struts2/editor/Categories.java:
##########
@@ -18,62 +18,144 @@
 
 package org.apache.roller.weblogger.ui.struts2.editor;
 
+import java.util.ArrayList;
 import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
+
 import org.apache.commons.logging.Log;
 import org.apache.commons.logging.LogFactory;
 import org.apache.roller.weblogger.WebloggerException;
 import org.apache.roller.weblogger.business.WebloggerFactory;
 import org.apache.roller.weblogger.business.WeblogEntryManager;
+import org.apache.roller.weblogger.config.WebloggerRuntimeConfig;
 import org.apache.roller.weblogger.pojos.WeblogCategory;
 import org.apache.roller.weblogger.pojos.WeblogPermission;
 import org.apache.roller.weblogger.ui.struts2.util.UIAction;
-import org.apache.struts2.convention.annotation.AllowedMethods;
+import org.apache.roller.weblogger.util.cache.CacheManager;
+import org.apache.struts2.interceptor.parameter.StrutsParameter;
 
 /**
- * Manage weblog categories.
+ * Manage weblog categories: list them with their entry counts, and change
+ * their order. Adding, editing and removing are done by CategoryEdit and
+ * CategoryRemove.
  */
-// TODO: make this work @AllowedMethods({"execute","move"})
 public class Categories extends UIAction {
 
-       private static Log log = LogFactory.getLog(Categories.class);
+    private static final Log log = LogFactory.getLog(Categories.class);
+
+    // all categories from the action weblog, in display order
+    private List<WeblogCategory> allCategories = Collections.emptyList();
 
-       // all categories from the action weblog
-       private List<WeblogCategory> allCategories;
+    // number of entries in each category, by category id
+    private Map<String, Long> entryCounts = Collections.emptyMap();
 
-       public Categories() {
-               this.actionName = "categories";
-               this.desiredMenu = "editor";
-               this.pageTitle = "categoriesForm.rootTitle";
-       }
+    // category ids in their new order, posted by reorder
+    private List<String> categoryIds = new ArrayList<>();
 
-       @Override
-       public List<String> requiredWeblogPermissionActions() {
-               return Collections.singletonList(WeblogPermission.POST);
-       }
+    public Categories() {
+        this.actionName = "categories";
+        this.desiredMenu = "editor";
+        this.pageTitle = "categoriesForm.rootTitle";
+    }
 
-       @Override
-       public String execute() {
-               try {
+    @Override
+    public List<String> requiredWeblogPermissionActions() {
+        return Collections.singletonList(WeblogPermission.POST);
+    }
+
+    @Override
+    public String execute() {
+        try {
             WeblogEntryManager wmgr = 
WebloggerFactory.getWeblogger().getWeblogEntryManager();
-                       allCategories = 
wmgr.getWeblogCategories(getActionWeblog());
-               } catch (WebloggerException ex) {
-                       log.error("Error building categories list", ex);
-                       addError("Error building categories list");
-               }
-
-               return LIST;
-       }
-
-       public String move() {
-               return execute();
-       }
-
-       public List<WeblogCategory> getAllCategories() {
-               return allCategories;
-       }
-
-       public void setAllCategories(List<WeblogCategory> allCategories) {
-               this.allCategories = allCategories;
-       }
+            allCategories = wmgr.getWeblogCategories(getActionWeblog());
+            entryCounts = wmgr.getWeblogCategoryEntryCounts(getActionWeblog());
+        } catch (WebloggerException ex) {
+            log.error("Error building categories list", ex);
+            addError("generic.error.check.logs");
+        }
+        return LIST;
+    }
+
+    /**
+     * Save a new display order. The posted ids must name every category of
+     * the weblog exactly once.
+     */
+    public String reorder() {
+        try {
+            WeblogEntryManager wmgr = 
WebloggerFactory.getWeblogger().getWeblogEntryManager();
+            List<WeblogCategory> current = 
wmgr.getWeblogCategories(getActionWeblog());
+
+            Map<String, WeblogCategory> byId = new HashMap<>();
+            for (WeblogCategory category : current) {
+                byId.put(category.getId(), category);
+            }
+            if (categoryIds.size() != current.size()
+                    || !byId.keySet().equals(new HashSet<>(categoryIds))) {
+                addError("categoriesForm.error.reorder");
+                return execute();
+            }
+
+            for (int position = 0; position < categoryIds.size(); position++) {
+                WeblogCategory category = byId.get(categoryIds.get(position));
+                if (category.getPosition() != position) {
+                    category.setPosition(position);
+                    wmgr.saveWeblogCategory(category);

Review Comment:
   🤖Claude: fixed in 76c13ffc8: `reorder()` sets the positions on the managed 
categories and saves the weblog once. The unit test checks that 
`saveWeblogCategory` is not called.



##########
app/src/main/resources/ApplicationResources_ja.properties:
##########
@@ -96,23 +96,15 @@ categoryForm.image=\u753B\u50CF
 # ----------------------------------------------------------- 
CategoriesForm.jsp
 
 
categoriesForm.subtitle=<span>{0}</span>\u306E\u30AB\u30C6\u30B4\u30EA\u3092\u7BA1\u7406\u3057\u307E\u3059
-categoriesForm.root=\u30EB\u30FC\u30C8
 categoriesForm.rootTitle=\u30AB\u30C6\u30B4\u30EA
 
categoriesForm.rootPrompt=\u30AB\u30C6\u30B4\u30EA\u306E\u6700\u4E0A\u4F4D\u3067\u3059\u3002\u6700\u4F4E\u3067\u3082\u4E00\u3064\u306E\u30AB\u30C6\u30B4\u30EA\u304C\u5FC5\u8981\u3067\u3059\u3002\u30AB\u30C6\u30B4\u30EA\u306F\u30D6\u30ED\u30B0\u30A8\u30F3\u30C8\u30EA\u3092\u5206\u985E\u3059\u308B\u305F\u3081\u306B\u4F7F\u308F\u308C\u3001\u30D6\u30ED\u30B0\u306E\u8AAD\u8005\u306F\u30AB\u30C6\u30B4\u30EA\u6BCE\u306B\u5206\u304B\u308C\u305F\u30CB\u30E5\u30FC\u30B9\u30D5\u30A3\u30FC\u30C9\u306E\u4E2D\u304B\u3089\u597D\u304D\u306A\u3082\u306E\u3092\u8CFC\u8AAD\u3059\u308B\u3053\u3068\u304C\u3067\u304D\u307E\u3059\u3002
 categoriesForm.addCategory=\u30AB\u30C6\u30B4\u30EA\u306E\u8FFD\u52A0
-categoriesForm.move=\u79FB\u52D5
-categoriesForm.move.confirm=\u9078\u629E\u3057\u305F\u30AB\u30C6\u30B4\u30EA\u3092\u79FB\u52D5\u3057\u307E\u3059\u304B\uFF1F
-categoriesForm.remove=\u524A\u9664
 
 # --------------------------------------------------------- 
CategoryDeleteOK.jsp
 
-categoryDeleteOK.removeCategory=\u30AB\u30C6\u30B4\u30EA\u3092\u524A\u9664\u3057\u307E\u3059
 
categoryDeleteOK.warningCatInUse=<strong>\u8B66\u544A\uFF1A</strong>\u3053\u306E\u30AB\u30C6\u30B4\u30EA\u306F\u4F7F\u7528\u3055\u308C\u3066\u3044\u307E\u3059\u3002

Review Comment:
   🤖Claude: fixed in 76c13ffc8: removed `categoryDeleteOK.warningCatInUse` and 
`categoryDeleteOK.noEntriesInCat` from the de, es, fr, ja, ko, ru and zh_CN 
bundles. They fall back to English.



##########
app/src/main/webapp/WEB-INF/jsps/tiles/messages.jsp:
##########
@@ -19,8 +19,9 @@
 
 <script>
     $(document).ready(function () {
-        // Remove the alert box after 10 seconds
-        $(".alert").delay(10000).slideUp(200, function() {
+        // Remove the action message boxes below after 10 seconds. Only these: 
other
+        // alerts on the page, such as error boxes in dialogs, must stay.
+        $("#messages, #errors").delay(10000).slideUp(200, function() {

Review Comment:
   🤖Claude: fixed in 76c13ffc8: auto-hide now applies to `.alert` except alerts 
inside a `.modal`, so other pages keep their old behavior. #246 has the same 
edit.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to