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


##########
app/src/main/webapp/roller-ui/scripts/categories.js:
##########
@@ -0,0 +1,389 @@
+/*
+* 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) {
+        return message.replace("{0}", 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);
+        });
+    }
+
+    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;
+
+    function saveOrder() {

Review Comment:
   🤖Claude: fixed in e799935f7: `saveOrder` now sends one save at a time; a 
change made while one is in flight is queued and sent afterwards from the list 
as it is then. `post()` also refreshes the salt when a post fails.



##########
app/src/main/java/org/apache/roller/weblogger/ui/struts2/editor/Categories.java:
##########
@@ -18,62 +18,134 @@
 
 package org.apache.roller.weblogger.ui.struts2.editor;
 
+import java.util.ArrayList;
 import java.util.Collections;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
 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());
+
+            Set<String> currentIds = new HashSet<>();
+            for (WeblogCategory category : current) {
+                currentIds.add(category.getId());
+            }
+            if (categoryIds.size() != current.size()
+                    || !currentIds.equals(new HashSet<>(categoryIds))) {
+                addError("categoriesForm.error.reorder");
+                return execute();
+            }
+
+            for (int position = 0; position < categoryIds.size(); position++) {
+                WeblogCategory category = 
wmgr.getWeblogCategory(getActionWeblog(), categoryIds.get(position));
+                if (category.getPosition() != position) {
+                    category.setPosition(position);

Review Comment:
   🤖Claude: fixed in e799935f7: after saving, `reorder()` sorts 
`getActionWeblog().getWeblogCategories()` by position, so themes and 
`calculatePosition()` see the new order. Added a test.



##########
app/src/main/java/org/apache/roller/weblogger/ui/struts2/editor/Categories.java:
##########
@@ -18,62 +18,134 @@
 
 package org.apache.roller.weblogger.ui.struts2.editor;
 
+import java.util.ArrayList;
 import java.util.Collections;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
 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());
+
+            Set<String> currentIds = new HashSet<>();
+            for (WeblogCategory category : current) {
+                currentIds.add(category.getId());
+            }
+            if (categoryIds.size() != current.size()
+                    || !currentIds.equals(new HashSet<>(categoryIds))) {
+                addError("categoriesForm.error.reorder");
+                return execute();
+            }
+
+            for (int position = 0; position < categoryIds.size(); position++) {
+                WeblogCategory category = 
wmgr.getWeblogCategory(getActionWeblog(), categoryIds.get(position));

Review Comment:
   🤖Claude: fixed in e799935f7: `reorder()` builds a map from the categories it 
already loaded and uses it for both the id check and the position updates, so 
there are no per-id queries. Added a test that `getWeblogCategory` is not 
called.



##########
app/src/main/webapp/roller-ui/scripts/categories.js:
##########
@@ -0,0 +1,389 @@
+/*
+* 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) {
+        return message.replace("{0}", 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);
+        });
+    }
+
+    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;
+
+    function saveOrder() {
+        var ids = 
Array.prototype.map.call(list.querySelectorAll(".category-row"), function (row) 
{
+            return "&categoryIds=" + 
encodeURIComponent(row.dataset.categoryId);
+        }).join("");
+        post(document.getElementById("categoriesReorderForm"), 
ids).done(function (html) {
+            var errors = actionErrors(html);
+            if (errors.length > 0) {
+                showToast(errors.join(" "), true);
+            } else {
+                document.getElementById("categoryStatus").textContent = 
text("msgOrderSaved");
+                showToast(text("msgOrderSaved"));
+            }
+        }).fail(function () {
+            showToast(text("msgError"), true);
+        });
+    }
+
+    // keyboard moves can come quickly, one after another; save once they stop
+    function saveOrderSoon() {
+        clearTimeout(saveTimer);
+        saveTimer = setTimeout(saveOrder, 600);
+    }
+
+    if ($.fn.sortable && list.children.length > 1) {
+        $(list).sortable({

Review Comment:
   🤖Claude: fixed in e799935f7: on touch screens (`pointer: coarse`) each row 
shows up and down buttons instead of the handle, using the same move code as 
the arrow keys. I have not tried it on a real phone.



##########
app/src/main/webapp/roller-ui/scripts/categories.js:
##########
@@ -0,0 +1,389 @@
+/*
+* 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) {
+        return message.replace("{0}", value);

Review Comment:
   🤖Claude: fixed in e799935f7: `format` uses a function replacement, so `$&` 
and similar in a name are kept as typed.



##########
app/src/main/webapp/WEB-INF/jsps/editor/Categories.jsp:
##########
@@ -17,319 +17,232 @@
 --%>
 <%@ include file="/WEB-INF/jsps/taglibs-struts2.jsp" %>
 
-<p class="subtitle">
-    <s:text name="categoriesForm.subtitle">
-        <s:param value="weblog"/>
-    </s:text>
-</p>
-<p class="pagetip">
-    <s:text name="categoriesForm.rootPrompt"/>
-</p>
+<%-- Categories: one list in display order. Rows are dragged (or moved with 
the arrow keys on
+     their handle) to reorder; Add, Edit and Delete open dialogs that post 
with AJAX and reload
+     the page, which then shows a short confirmation. Text the script needs is 
in data-* attributes. --%>
+
+<div id="categoriesPage" class="categories-page"
+     data-msg-added="<s:text escapeHtml="true" name='categoryForm.created'/>"
+     data-msg-saved="<s:text escapeHtml="true" 
name='categoryForm.changesSaved'/>"
+     data-msg-removed="<s:text escapeHtml="true" name='categoryForm.removed'/>"
+     data-msg-order-saved="<s:text escapeHtml="true" 
name='categoriesForm.orderSaved'/>"
+     data-msg-error="<s:text escapeHtml="true" 
name='generic.error.check.logs'/>"
+     data-msg-name-required="<s:text escapeHtml="true" 
name='categoryForm.requiredFields'/>"
+     data-msg-bad-url="<s:text escapeHtml="true" name='categoryForm.badURL'/>"
+     data-title-add="<s:text escapeHtml="true" name='categoryForm.add.title'/>"
+     data-title-edit="<s:text escapeHtml="true" 
name='categoryForm.edit.title'/>"
+     data-title-delete="<s:text escapeHtml="true" 
name='categoryDeleteOK.heading'/>">
+
+<div class="categories-header">
+    <p class="subtitle mb-0">
+        <s:text name="categoriesForm.subtitle">
+            <s:param value="weblog"/>
+        </s:text>
+    </p>
+    <button type="button" id="addCategoryButton" class="btn btn-primary">
+        <span class="bi bi-plus-lg" aria-hidden="true"></span>
+        <s:text escapeHtml="true" name="categoriesForm.addCategory"/>
+    </button>
+</div>
+
+<p class="pagetip" id="categoriesTip"><s:text escapeHtml="true" 
name="categoriesForm.rootPrompt"/></p>
 
-<%-- Form is a table of categories each with checkbox --%>
-<s:form action="categories!move">
+<%-- carries the salt for the reorder post; the category ids are added by the 
script --%>
+<s:form id="categoriesReorderForm" action="categories!reorder" theme="simple">
     <s:hidden name="salt"/>
     <s:hidden name="weblog"/>
-    <s:hidden name="categoryId"/>
-
-    <table class="rollertable table table-striped" width="100%">
-
-        <tr class="rollertable">
-            <th width="30%"><s:text name="generic.name"/></th>
-            <th width="50%"><s:text name="generic.description"/></th>
-            <th width="10%"><s:text name="generic.edit"/></th>
-            <th width="10%"><s:text name="categoriesForm.remove"/></th>
-        </tr>
-
-        <s:if test="AllCategories != null && !AllCategories.isEmpty">
-
-            <s:iterator var="category" value="AllCategories" 
status="rowstatus">
-                <tr>
-                    <td><s:property value="#category.name"/></td>
-
-                    <td><s:property value="#category.description"/></td>
-
-                    <td align="center">
-
-                        <s:set var="categoryId"    value="#category.id" />
-                        <s:set var="categoryName"  value="#category.name" />
-                        <s:set var="categoryDesc"  
value="#category.description" />
-                        <s:set var="categoryImage" value="#category.image" />
-                        <a href="#" class="category-edit-link"
-                           data-category-id="<s:property value="categoryId" />"
-                           data-category-name="<s:property 
value="categoryName"/>"
-                           data-category-desc="<s:property 
value="categoryDesc"/>"
-                           data-category-image="<s:property 
value="categoryImage"/>">
-                            <span class="bi bi-pencil-square"></span>
-                        </a>
-
-                    </td>
-
-                    <td class="rollertable" align="center">
-                        <s:if test="AllCategories.size() > 1">
-
-                            <s:set var="categoryId"    value="#category.id" />
-                            <s:set var="categoryName"  value="#category.name" 
/>
-                            <s:set var="categoryInUse" 
value="#category.inUse.toString()" />
-                            <a href="#" class="category-delete-link"
-                               data-category-id="<s:property 
value="categoryId" />"
-                               data-category-name="<s:property 
value="categoryName" />"
-                               data-category-in-use="<s:property 
value="categoryInUse"/>">
-                                <span class="bi bi-trash"></span>
-                            </a>
-
-                        </s:if>
-                    </td>
-
-                </tr>
-            </s:iterator>
-
-        </s:if>
-
-        <s:else>
-            <tr>
-                <td style="vertical-align:middle" colspan="6"><s:text 
name="categoriesForm.noresults"/></td>
-            </tr>
-        </s:else>
-
-    </table>
-
 </s:form>
 
+<s:if test="allCategories.isEmpty()">
+    <p class="categories-empty"><s:text escapeHtml="true" 
name="categoriesForm.noresults"/></p>
+</s:if>
+
+<ul id="categoryList" class="category-list" aria-describedby="categoriesTip">
+    <s:iterator var="category" value="allCategories">
+        <s:url var="entriesURL" escapeAmp="false" action="entries">
+            <s:param name="weblog" value="%{actionWeblog.handle}"/>
+            <s:param name="bean.categoryName" value="%{#category.name}"/>
+        </s:url>
+        <li class="category-row"
+            data-category-id="<s:property value='#category.id'/>"
+            data-category-name="<s:property value='#category.name'/>"
+            data-category-desc="<s:property value='#category.description'/>"
+            data-category-image="<s:property value='#category.image'/>"
+            data-in-use="<s:property value='isInUse(#category.id)'/>"
+            data-delete-text="<s:if test='getEntryCount(#category.id) > 
0'><s:text escapeHtml="true" name='categoryDeleteOK.entriesInCategory'><s:param 
value='getEntryCount(#category.id)'/></s:text></s:if><s:elseif 
test='isInUse(#category.id)'><s:text escapeHtml="true" 
name='categoryDeleteOK.warningCatInUse'/></s:elseif><s:else><s:text 
escapeHtml="true" name='categoryDeleteOK.noEntriesInCat'/></s:else>">
+
+            <button type="button" class="category-handle"
+                    aria-label="<s:text escapeHtml="true" 
name='categoriesForm.moveHandle'><s:param value='#category.name'/></s:text>">
+                <span class="bi bi-grip-vertical" aria-hidden="true"></span>
+            </button>
+
+            <s:if test="#category.image != null && #category.image != ''">
+                <img class="category-image" src="<s:property 
value='#category.image'/>" alt="" loading="lazy">
+            </s:if>
+
+            <div class="category-main">
+                <a href="#" class="category-name 
category-edit-link"><s:property value="#category.name"/></a>
+                <s:if test="#category.description != null && 
#category.description != ''">
+                    <span class="category-desc"><s:property 
value="#category.description"/></span>
+                </s:if>
+            </div>
 
-<%-- ============================================================= --%>
-<%-- add/edit category modal --%>
-
-<div id="category-edit-modal" class="modal fade category-edit-modal" 
tabindex="-1" role="dialog">
+            <s:if test="getEntryCount(#category.id) > 0">
+                <a class="category-count" href="<s:property 
value='%{entriesURL}'/>"
+                   title="<s:text escapeHtml="true" 
name='categoriesForm.viewEntries'><s:param value='#category.name'/></s:text>">
+                    <s:text escapeHtml="true" 
name="categoriesForm.entryCount"><s:param 
value="getEntryCount(#category.id)"/></s:text>
+                </a>
+            </s:if>
+            <s:else>
+                <span class="category-count"><s:text escapeHtml="true" 
name="categoriesForm.entryCount"><s:param value="0"/></s:text></span>
+            </s:else>
+
+            <div class="category-actions">
+                <button type="button" class="btn btn-sm btn-outline-secondary 
category-edit-link">
+                    <span class="bi bi-pencil" aria-hidden="true"></span>
+                    <s:text escapeHtml="true" name="generic.edit"/>
+                </button>
+                <s:if test="allCategories.size() > 1">
+                    <button type="button" class="btn btn-sm btn-outline-danger 
category-delete-link">
+                        <span class="bi bi-trash" aria-hidden="true"></span>
+                        <s:text escapeHtml="true" name="generic.delete"/>
+                    </button>
+                </s:if>
+                <s:else>
+                    <%-- a disabled button gets no hover events, so the 
wrapper carries the tooltip --%>
+                    <span class="d-inline-block" tabindex="0" title="<s:text 
escapeHtml="true" name='categoriesForm.lastCategory'/>">
+                        <button type="button" class="btn btn-sm 
btn-outline-danger" disabled>
+                            <span class="bi bi-trash" 
aria-hidden="true"></span>
+                            <s:text escapeHtml="true" name="generic.delete"/>
+                        </button>
+                    </span>
+                </s:else>
+            </div>
+        </li>
+    </s:iterator>
+</ul>
+
+<div id="categoryStatus" class="visually-hidden" role="status" 
aria-live="polite"></div>
+
+<div class="toast-container position-fixed bottom-0 end-0 p-3">
+    <div id="categoryToast" class="toast align-items-center text-bg-success 
border-0" role="status" aria-atomic="true">
+        <div class="d-flex">
+            <div class="toast-body"></div>
+            <button type="button" class="btn-close btn-close-white me-2 
m-auto" data-bs-dismiss="toast"
+                    aria-label="<s:text escapeHtml="true" 
name='generic.close'/>"></button>
+        </div>
+    </div>
+</div>
 
-    <div class="modal-dialog modal-lg">
+<%-- add/edit dialog --%>
 
+<div id="category-edit-modal" class="modal fade" tabindex="-1" 
aria-labelledby="category-edit-title" aria-hidden="true">
+    <div class="modal-dialog">
         <div class="modal-content">
-
             <div class="modal-header">
-                <h3 id="category-edit-title"></h3>
+                <h3 class="modal-title fs-5" id="category-edit-title"></h3>
+                <button type="button" class="btn-close" 
data-bs-dismiss="modal" aria-label="<s:text escapeHtml="true" 
name='generic.close'/>"></button>
             </div>
-
             <div class="modal-body">
-                <s:form action="categoryEdit" id="categoryEditForm" 
theme="bootstrap" cssClass="form-horizontal">
+                <div id="categoryEditErrors" class="alert alert-danger d-none" 
role="alert"></div>
+                <s:form action="categoryEdit!save" id="categoryEditForm" 
theme="simple" novalidate="novalidate">
                     <s:hidden name="salt"/>
                     <s:hidden name="weblog"/>
-                    <s:hidden name="bean.id"/>
-
-                    <%-- action needed here because we are using AJAX to post 
this form --%>
-                    <s:hidden name="action:categoryEdit!save" value="save"/>
-
-                    <s:textfield name="bean.name" 
label="%{getText('generic.name')}" maxlength="255"
-                                 onchange="validateCategory()" 
onkeyup="validateCategory()" />
+                    <s:hidden name="bean.id" id="categoryEditForm_bean_id"/>
+
+                    <div class="mb-3">
+                        <label class="form-label" 
for="categoryEditForm_bean_name"><s:text escapeHtml="true" 
name="generic.name"/></label>
+                        <s:textfield name="bean.name" 
id="categoryEditForm_bean_name" value="" maxlength="255"
+                                     cssClass="form-control" 
required="required"
+                                     aria-describedby="categoryNameFeedback"/>
+                        <div id="categoryNameFeedback" 
class="invalid-feedback"></div>
+                    </div>
 
-                    <s:textfield name="bean.description" 
label="%{getText('generic.description')}"/>
+                    <div class="mb-3">
+                        <label class="form-label" 
for="categoryEditForm_bean_description"><s:text escapeHtml="true" 
name="generic.description"/></label>
+                        <s:textfield name="bean.description" 
id="categoryEditForm_bean_description" value=""
+                                     maxlength="255" cssClass="form-control"/>
+                    </div>
 
-                    <s:textfield name="bean.image" 
label="%{getText('categoryForm.image')}"
-                                 onchange="validateCategory()" 
onkeyup="validateCategory()" />
+                    <div class="mb-1">
+                        <label class="form-label" 
for="categoryEditForm_bean_image"><s:text escapeHtml="true" 
name="categoryForm.image"/></label>
+                        <div class="input-group has-validation">
+                            <s:textfield name="bean.image" 
id="categoryEditForm_bean_image" value="" maxlength="255"
+                                         cssClass="form-control" type="url"
+                                         aria-describedby="categoryImageHelp 
categoryImageFeedback"/>
+                            <s:if test="mediaLibraryEnabled">
+                                <button type="button" 
id="categoryImageChooseButton" class="btn btn-outline-secondary">
+                                    <span class="bi bi-images" 
aria-hidden="true"></span>
+                                    <s:text escapeHtml="true" 
name="categoryForm.chooseImage"/>
+                                </button>
+                            </s:if>
+                            <div id="categoryImageFeedback" 
class="invalid-feedback"></div>
+                        </div>
+                        <div id="categoryImageHelp" class="form-text"><s:text 
escapeHtml="true" name="%{mediaLibraryEnabled ? 'categoryForm.imageHelpMedia' : 
'categoryForm.imageHelp'}"/></div>
+                        <img id="categoryImagePreview" 
class="category-image-preview d-none" alt="">
+                    </div>
                 </s:form>
+                <s:if test="mediaLibraryEnabled">
+                    <%-- the media chooser, images only; shown in place of the 
form --%>
+                    <s:url var="imageChooserURL" 
action="mediaFileImageChooser" namespace="overlay" escapeAmp="false">
+                        <s:param name="weblog" value="%{actionWeblog.handle}"/>
+                        <s:param name="bean.type" 
value="'mediaFileView.image'"/>

Review Comment:
   🤖Claude: fixed in e799935f7: `Categories.getImageChooserType()` returns 
`MediaFileImageChooser.IMAGES_ONLY` and the JSP uses it. Added a test.



-- 
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