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


##########
app/src/main/webapp/roller-ui/scripts/media-upload.js:
##########
@@ -0,0 +1,342 @@
+/*
+* 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.
+*/
+
+/*
+ * Upload page (MediaFileAdd.jsp). Chosen or dropped files go into a list; 
Upload
+ * sends the files that are not yet uploaded in one request with format=json, 
and
+ * MediaFileAddResult.jsp answers with a result per file and a fresh salt. 
Failed
+ * files stay in the list for a retry; uploaded files are never sent again. If 
the
+ * connection drops after sending, the outcome is unknown and nothing is 
retried
+ * automatically. Localized strings come from data-* attributes on 
#mediaUpload;
+ * file names are only written with text APIs.
+ */
+(function () {
+    var config = document.getElementById("mediaUpload");
+    var form = document.getElementById("uploadForm");
+    if (!config || !form || !window.fetch || !window.FormData) {
+        return; // the form posts normally
+    }
+
+    var input = document.getElementById("fileInput");
+    var dropZone = document.getElementById("dropZone");
+    var list = document.getElementById("fileList");
+    var button = document.getElementById("uploadButton");
+    var buttonLabel = document.getElementById("uploadButtonLabel");
+    var status = document.getElementById("uploadStatus");
+    var continueLink = document.getElementById("continueLink");
+    var folder = document.getElementById("uploadFolder");
+    var uploadLabel = buttonLabel.textContent;
+
+    var maxFiles = parseInt(config.getAttribute("data-max-files"), 10) || 20;
+    var maxFileMB = parseFloat(config.getAttribute("data-max-file-mb")) || 0;
+
+    // entries: {file, state: queued|uploading|uploaded|failed|unknown, error, 
item}
+    var entries = [];
+    var uploadedIds = [];
+    var busy = false;
+
+    function text(name) {
+        return config.getAttribute("data-" + name) || "";
+    }
+
+    function format(message) {
+        var args = Array.prototype.slice.call(arguments, 1);
+        return message.replace(/\{(\d)\}/g, function (match, i) {
+            return args[i] !== undefined ? args[i] : match;
+        });
+    }
+
+    function formatSize(bytes) {
+        if (bytes < 1024) {
+            return bytes + " bytes";
+        }
+        var units = ["KB", "MB", "GB"];
+        var value = bytes;
+        var unit = -1;
+        do {
+            value /= 1024;
+            unit++;
+        } while (value >= 1024 && unit < units.length - 1);
+        return (value < 10 ? Math.round(value * 10) / 10 : Math.round(value)) 
+ " " + units[unit];
+    }
+
+    function tooLarge(file) {
+        return maxFileMB > 0 && file.size > maxFileMB * 1024 * 1024;
+    }
+
+    function pending() {
+        return entries.filter(function (e) {
+            return e.state === "queued" || e.state === "failed";
+        });
+    }
+
+    // ----- the file list ----------------------------------------------------
+
+    function addFiles(files) {
+        Array.prototype.forEach.call(files, function (file) {
+            var duplicate = entries.some(function (e) {
+                return e.state !== "uploaded" && e.file.name === file.name
+                    && e.file.size === file.size && e.file.lastModified === 
file.lastModified;
+            });
+            if (!duplicate) {
+                var entry = {file: file, state: "queued", error: null};
+                if (tooLarge(file)) {
+                    entry.state = "failed";
+                    entry.error = format(text("too-large"), maxFileMB);
+                }
+                entries.push(entry);
+                entry.item = renderEntry(entry);
+                list.appendChild(entry.item);
+            }
+        });
+        input.value = "";
+        update();
+    }
+
+    function renderEntry(entry) {
+        var item = document.createElement("li");
+        item.className = "media-upload-item";
+
+        var preview = document.createElement("span");
+        preview.className = "media-upload-preview";
+        if (/^image\//.test(entry.file.type) && entry.file.size < 20 * 1024 * 
1024) {
+            var img = document.createElement("img");
+            img.alt = "";
+            img.src = URL.createObjectURL(entry.file);
+            img.onload = function () {
+                URL.revokeObjectURL(img.src);
+            };
+            preview.appendChild(img);
+        } else {
+            var icon = document.createElement("span");
+            icon.className = "bi bi-file-earmark";
+            icon.setAttribute("aria-hidden", "true");
+            preview.appendChild(icon);
+        }
+
+        var name = document.createElement("span");
+        name.className = "media-upload-name";
+        name.textContent = entry.file.name;
+
+        var size = document.createElement("span");
+        size.className = "media-upload-size";
+        size.textContent = formatSize(entry.file.size);
+
+        var state = document.createElement("span");
+        state.className = "media-upload-state";
+
+        var remove = document.createElement("button");
+        remove.type = "button";
+        remove.className = "btn btn-sm btn-outline-secondary 
media-upload-remove";
+        remove.setAttribute("aria-label", format(text("remove"), 
entry.file.name));
+        remove.innerHTML = '<span class="bi bi-x-lg" 
aria-hidden="true"></span>';
+        remove.addEventListener("click", function () {
+            entries.splice(entries.indexOf(entry), 1);
+            item.remove();
+            update();
+            input.focus();
+        });
+
+        item.appendChild(preview);
+        item.appendChild(name);
+        item.appendChild(size);
+        item.appendChild(state);
+        item.appendChild(remove);
+        return item;
+    }
+
+    function update() {
+        entries.forEach(function (entry) {
+            var state = entry.item.querySelector(".media-upload-state");
+            var label = text("status-" + entry.state);
+            state.textContent = entry.error ? label + ": " + entry.error : 
label;
+            entry.item.setAttribute("data-state", entry.state);
+            entry.item.querySelector(".media-upload-remove").hidden =
+                busy || entry.state === "uploaded" || entry.state === 
"uploading";
+        });
+
+        var sendable = pending().filter(function (e) {
+            return !tooLarge(e.file);
+        });
+        var anyFailed = entries.some(function (e) {
+            return e.state === "failed";
+        });
+        var anyUploaded = uploadedIds.length > 0;
+
+        button.disabled = busy || sendable.length === 0 || sendable.length > 
maxFiles;
+        buttonLabel.textContent = anyUploaded && anyFailed ? text("retry") : 
uploadLabel;
+
+        continueLink.hidden = busy || !anyUploaded;
+        continueLink.href = config.getAttribute("data-done-url")
+            + "&uploadedIds=" + encodeURIComponent(uploadedIds.join(","));
+
+        if (!busy && !anyUploaded) {
+            status.textContent = sendable.length > maxFiles
+                ? format(text("too-many"), maxFiles)
+                : (sendable.length > 0 ? format(text("selected-count"), 
sendable.length) : "");
+        }
+    }
+
+    input.addEventListener("change", function () {
+        addFiles(input.files);
+    });
+
+    ["dragenter", "dragover"].forEach(function (type) {
+        dropZone.addEventListener(type, function (e) {
+            e.preventDefault();
+            dropZone.classList.add("dragging");
+        });
+    });
+    ["dragleave", "drop"].forEach(function (type) {
+        dropZone.addEventListener(type, function (e) {
+            e.preventDefault();
+            dropZone.classList.remove("dragging");
+        });
+    });
+    dropZone.addEventListener("drop", function (e) {
+        if (e.dataTransfer && e.dataTransfer.files.length) {
+            addFiles(e.dataTransfer.files);
+        }
+    });
+
+    // ----- sending ----------------------------------------------------------
+
+    function folderLink() {
+        return config.getAttribute("data-folder-url") + "&directoryId="
+            + encodeURIComponent(folder.value);
+    }
+
+    function showStatus(message, linkText, linkHref) {
+        status.textContent = message;
+        if (linkText) {
+            var link = document.createElement("a");
+            link.href = linkHref;
+            link.textContent = linkText;
+            status.appendChild(document.createTextNode(" "));
+            status.appendChild(link);
+        }
+    }
+
+    form.addEventListener("submit", function (e) {
+        e.preventDefault();
+        var sending = pending().filter(function (entry) {
+            return !tooLarge(entry.file);
+        });
+        if (busy || sending.length === 0 || sending.length > maxFiles) {
+            return;
+        }
+
+        var data = new FormData();
+        Array.prototype.forEach.call(form.elements, function (field) {
+            if (!field.name || field.type === "file" || field.type === 
"submit" || field.disabled) {
+                return;
+            }
+            if ((field.type === "checkbox" || field.type === "radio") && 
!field.checked) {
+                return;
+            }
+            data.append(field.name, field.value);
+        });
+        data.append("format", "json");
+        sending.forEach(function (entry) {
+            entry.state = "uploading";
+            entry.error = null;
+            data.append("uploadedFiles", entry.file, entry.file.name);
+        });
+
+        busy = true;
+        update();
+        status.textContent = format(text("uploading"), sending.length);
+
+        fetch(form.action, {method: "POST", body: data, credentials: 
"same-origin"})
+            .then(function (response) {
+                var type = response.headers.get("Content-Type") || "";
+                if (response.status >= 500) {
+                    // the server may have saved files before it failed
+                    throw {unknown: true};
+                }
+                if (!response.ok || type.indexOf("json") === -1) {
+                    // refused before saving: too large, bad salt, session 
expired
+                    throw {server: String(response.status)};
+                }
+                return response.json();
+            })
+            .then(function (result) {
+                if (result.salt) {
+                    form.querySelector("input[name='salt']").value = 
result.salt;
+                }
+                // match results to the files sent, by name, in order
+                result.files.forEach(function (r) {
+                    var entry = sending.filter(function (s) {
+                        return s.state === "uploading" && s.file.name === 
r.name;
+                    })[0];
+                    if (!entry) {
+                        return;
+                    }
+                    if (r.uploaded) {
+                        entry.state = "uploaded";
+                        uploadedIds.push(r.id);
+                    } else {
+                        entry.state = "failed";
+                        entry.error = r.error;
+                    }
+                });
+                var formError = (result.errors || []).join(" ");
+                sending.forEach(function (entry) {
+                    if (entry.state === "uploading") {
+                        entry.state = "failed";
+                        entry.error = formError || null;
+                    }
+                });
+                busy = false;
+                update();
+
+                var failed = entries.filter(function (e) {
+                    return e.state === "failed";
+                }).length;
+                if (failed === 0 && uploadedIds.length > 0) {

Review Comment:
   🐞Codex Issue: **Blocking:** Do not leave the upload page while queued files 
remain. The picker and drop handler still accept files while `busy`, but this 
completion condition counts only failed entries. Reproduced by holding the 
first upload response, selecting `queued-later.png` while `inflight.png` was 
uploading, then releasing the response: the second file appeared as Ready, the 
page redirected to Upload Complete, and only `inflight.png` was stored. 
Disable/reject additions while busy, or remain on the queue until every entry 
is uploaded (also preserve unknown outcomes). Add a browser regression that 
adds a file during an outstanding request and verifies it is either explicitly 
rejected or retained for upload.



##########
app/src/main/webapp/WEB-INF/jsps/editor/MediaFileEdit.jsp:
##########
@@ -17,106 +17,145 @@
 --%>
 <%@ include file="/WEB-INF/jsps/taglibs-struts2.jsp" %>
 
-<p class="pagetip">
-    <s:text name="mediaFileEdit.pagetip"/>
-</p>
-
-<s:form id="entry" action="mediaFileEdit!save" method="POST" 
enctype="multipart/form-data"
-        theme="bootstrap">
-
-    <s:hidden name="salt"/>
-    <s:hidden name="weblog"/>
-    <s:hidden name="mediaFileId" id="mediaFileId"/>
-    <s:hidden name="bean.permalink"/>
-
-    <s:if test="bean.isImage">
-        <div class="row mb-3">
-            <label class="col-form-label col-sm-3">Thumbnail</label>
-            <div class="controls col-sm-9">
-                <a href='<s:property value="bean.permalink" />' 
target="_blank">
-                    <img alt="thumbnail" src='<s:property 
value="bean.thumbnailURL" />'
-                         title='<s:text name="mediaFileEdit.clickToView" />'/>
+<%-- Media file details: preview, facts and actions first, then the metadata 
form.
+     Links back to the library page the user came from (returnQuery). --%>
+
+<s:if test="mediaFile != null">

Review Comment:
   🐞Codex Issue: **Important:** Keep the details form available after field 
validation fails. `mediaFile` is loaded in `execute()` or `save()`, but Struts 
validation/workflow returns INPUT before calling `save()` for an empty name or 
description over 255 characters. This new guard then hides the entire form. 
Reproduced by clearing a file's name and saving: the page shows 'Name is a 
required field' and a back link, with zero `#mediaFileEditForm` elements, so 
the user cannot correct the submitted values. Load the weblog-scoped media file 
during preparation without overwriting the submitted bean, or render an 
editable validation-error state. Add a browser test that corrects invalid 
metadata and successfully resubmits.



##########
app/src/main/java/org/apache/roller/weblogger/ui/struts2/editor/MediaFileAdd.java:
##########
@@ -191,50 +218,105 @@ public String save() {
                         if 
(!WebloggerFactory.getWeblogger().getFileContentManager().canSave(
                                 getActionWeblog(), fileName, declaredType,
                                 uploadFile.length(), errors)) {
+                            failed(fileName, messageText(errors));
                             continue;
                         }
                         mediaFile.setContentType(MediaTypePolicy.storedTypeFor(
                                 mediaFile.getName(), declaredType,
                                 
RollerContext.getServletContext()::getMimeType));
 
                         manager.createMediaFile(getActionWeblog(), mediaFile, 
errors);
+                        if (errors.getErrorCount() > 0) {
+                            failed(fileName, messageText(errors));
+                            continue;
+                        }
                         WebloggerFactory.getWeblogger().flush();
 
                         if (mediaFile.isImageFile()) {
                             newImages.add(mediaFile);
                         } else {
                             newFiles.add(mediaFile);
                         }
-
-                        uploaded.add(mediaFile);
+                        uploadResults.add(UploadResult.uploaded(mediaFile));
 
                     } catch (Exception e) {
-                        log.error("Error uploading media file", e);
-                        addError("mediaFileAdd.errorUploading", 
bean.getName());
+                        log.error("Error uploading media file " + fileName, e);
+                        failed(fileName, getText("mediaFileAdd.errorUploading",
+                                Collections.singletonList(fileName)));
                     }
                 }
 
-                for (Iterator<RollerMessage> it = errors.getErrors(); 
it.hasNext();) {
-                    RollerMessage msg = it.next();
-                    addError(msg.getKey(), Arrays.asList(msg.getArgs()));
+                if (newImages.isEmpty() && newFiles.isEmpty()) {
+                    // nothing was saved; the errors are on the upload form
+                    return result(INPUT);
                 }
 
-                if (!uploaded.isEmpty() && !this.errorsExist()) {
-                    addMessage("uploadFiles.uploadedFiles");
-                    for (MediaFile upload : uploaded) {
-                        addMessage("uploadFiles.uploadedFile",
-                                upload.getPermalink());
-                    }
+                // Files that were saved are listed on the success page, with
+                // any that failed, so a partial upload is never hidden.
+                this.pageTitle = "mediaFileAddSuccess.title";
+                return result(SUCCESS);
 
-                } else {
-                    return INPUT;
-                }
+            } else {
+                addError("error.upload.file");
+            }
+        }
+        return result(INPUT);
+    }
 
-                this.pageTitle = "mediaFileAddSuccess.title";
-                return SUCCESS;
+    private String result(String pageResult) {
+        return JSON.equals(format) ? JSON : pageResult;
+    }
+
+    /**
+     * Shows the success page for files already uploaded, after the upload page
+     * sent them with fetch(). Only files of this weblog are shown.
+     */
+    @SkipValidation
+    public String done() {
+        if (StringUtils.isNotBlank(uploadedIds)) {
+            MediaFileManager manager = 
WebloggerFactory.getWeblogger().getMediaFileManager();
+            for (String id : StringUtils.split(uploadedIds, ',')) {
+                try {
+                    MediaFile mediaFile = 
manager.getMediaFile(getActionWeblog(), id.trim());
+                    if (mediaFile == null) {
+                        continue;
+                    }
+                    if (mediaFile.isImageFile()) {
+                        newImages.add(mediaFile);
+                    } else {
+                        newFiles.add(mediaFile);
+                    }
+                    uploadResults.add(UploadResult.uploaded(mediaFile));
+                    bean.setDirectoryId(mediaFile.getDirectory().getId());

Review Comment:
   🐞Codex Issue: **Important:** Restore the folder name as well as its ID on 
the new `done()` path. The fetch completion URL supplies only weblog and 
uploaded IDs, so `myPrepare()` selects the default folder. This code updates 
`bean.directoryId` but leaves `directoryName` as `default`; 
MediaFileAddSuccess.jsp uses that name for 'Upload more'. Reproduced after 
uploading into a custom folder: Upload more linked to `directoryName=default` 
and the next upload form selected default. Carry the selected folder through 
the completion request or synchronize both folder fields when loading the 
uploaded files, and test two consecutive batches into a non-default folder.



##########
app/src/main/webapp/roller-ui/styles/roller.css:
##########
@@ -284,78 +284,844 @@ body {
     text-align: center;
 }
 
-/* media files */
-
-.align-images {
-    display:inline;
-    float:left;
-    margin:5px;
-    list-style-type:none;
-    width:140px;
-    max-height:200px;
-    border: 1px solid #ccc;
+/* media library (MediaFileView.jsp, MediaFileSidebar.jsp, MediaFileCard.jsp) 
*/
+
+.media-folders-header,
+.media-header {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    justify-content: space-between;
+    gap: 0.5rem;
+}
+
+.media-folders-header h3 {
+    font-size: 1.1rem;
+    margin: 0;
+}
+
+.media-new-folder {
+    margin-top: 0.75rem;
+}
+
+.media-folder-list {
+    list-style: none;
+    padding: 0;
+    margin: 0.75rem 0 0;
+}
+
+.media-folder-list a {
+    display: flex;
+    align-items: center;
+    gap: 0.5rem;
+    padding: 0.35rem 0.5rem;
+    border-radius: var(--bs-border-radius);
+    text-decoration: none;
+    color: var(--bs-body-color);
+}
+
+.media-folder-list a:hover,
+.media-folder-list a:focus-visible {
+    background-color: var(--bs-tertiary-bg);
+}
+
+.media-folder-list a.active {
+    background-color: var(--bs-primary-bg-subtle);
+    color: var(--bs-primary-text-emphasis);
+    font-weight: 600;
+}
+
+.media-folder-name {
+    overflow-wrap: anywhere;
+}
+
+.media-heading {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: baseline;
+    gap: 0.5rem;
+    font-size: 1.25rem;
+    margin: 0;
+    overflow-wrap: anywhere;
+}
+
+.media-count {
+    font-size: 0.875rem;
+    font-weight: normal;
+    color: var(--bs-secondary-color);
+}
+
+/* Search: one box holding the name, its scope and the button, then Filters */
+.media-search {
+    margin: 1rem 0 0.5rem;
+}
+
+.media-search-row {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 0.5rem;
+}
+
+.media-search-box {
+    flex: 1 1 20rem;
+    flex-wrap: nowrap;
+}
+
+.media-search-box .input-group-text {
+    background-color: var(--bs-body-bg);
+    color: var(--bs-secondary-color);
+}
+
+.media-search-box .form-control {
+    border-left: 0;
+    min-width: 6rem;
+}
+
+.media-search-box .media-search-scope {
+    flex: 0 1 auto;
+    width: auto;
+    max-width: 13rem;
+    background-color: var(--bs-tertiary-bg);
+}
+
+.media-filters-button {
+    position: relative;
+}
+
+/* a set filter that the collapsed panel would otherwise hide */
+.media-filters-dot {
+    position: absolute;
+    top: 0.3rem;
+    right: 0.3rem;
+    width: 0.5rem;
+    height: 0.5rem;
+    border-radius: 50%;
+    background-color: var(--bs-primary);
+}
+
+.media-filters {
+    margin-top: 0.75rem;
+}
+
+.media-filters.show,
+.media-filters.collapsing {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 0.75rem;
+}
+
+.media-filter-size {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: flex-end;
+    gap: 0.5rem;
+}
+
+.media-filter-size legend {
+    width: 100%;
+    float: none;
+    font-size: 1rem;
+    margin-bottom: 0.5rem;
+}
+
+.media-filter-size .form-select {
+    width: auto;
+}
+
+.media-filter-size .form-control {
+    width: 7rem;
+}
+
+.media-filter-tags {
+    flex: 1 1 14rem;
+}
+
+/* The bar above the files: type, sort and layout, or the selection's actions.
+   It stays in view while scrolling, so the actions are always at hand. */
+.media-bar {
+    position: sticky;
+    top: 0;
+    z-index: 2;
+    padding: 0.5rem 0;
+    background-color: var(--bs-body-bg);
+}
+
+.media-results-bar,
+.media-selection-bar {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    justify-content: space-between;
+    gap: 0.5rem 1rem;
+    min-height: 2.5rem;
+}
+
+.media-results-bar[hidden],
+.media-selection-bar[hidden] {
+    display: none;
+}
+
+.media-type-chips {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 0.375rem;
+}
+
+.media-chip {
+    border-radius: var(--bs-border-radius-pill);
+    padding: 0.2rem 0.85rem;
+    border: 1px solid var(--bs-border-color);
+    color: var(--bs-body-color);
+    background-color: var(--bs-body-bg);
+}
+
+.media-chip:hover {
+    background-color: var(--bs-tertiary-bg);
+}
+
+.btn-check:checked + .media-chip {
+    border-color: var(--bs-primary-bg-subtle);
+    background-color: var(--bs-primary-bg-subtle);
+    color: var(--bs-primary-text-emphasis);
+    font-weight: 600;
+}
+
+.btn-check:focus-visible + .media-chip {
+    box-shadow: var(--bs-focus-ring-x, 0) var(--bs-focus-ring-y, 0) 0 0.25rem 
rgba(var(--bs-primary-rgb), 0.25);
+}
+
+.media-view-options {
+    display: flex;
+    align-items: center;
+    gap: 0.25rem;
+    color: var(--bs-secondary-color);
+}
+
+.media-sort {
+    width: auto;
+    border-color: transparent;
+    background-color: transparent;
+    color: var(--bs-body-color);
+}
+
+.media-sort:hover {
+    background-color: var(--bs-tertiary-bg);
+}
+
+.media-view-options .btn-group {
+    margin-left: 0.5rem;
+}
+
+.media-selection-bar {
+    justify-content: flex-start;
+    padding: 0 0.5rem;
+    border-radius: var(--bs-border-radius-lg);
+    background-color: var(--bs-primary-bg-subtle);
+}
+
+.media-selection-close {
+    color: var(--bs-body-color);
+}
+
+.media-selected-count {
+    font-weight: 600;
+}
+
+.media-selection-actions {
+    display: flex;
+    gap: 0.5rem;
+    margin-left: auto;
+}
+
+.media-selection-actions .btn {
+    background-color: var(--bs-body-bg);
+}
+
+.media-selection-actions .btn-outline-danger:hover {
+    background-color: var(--bs-danger);
+}
+
+.media-selection-actions .btn-outline-primary:hover,
+.media-selection-actions .btn-outline-primary.show {
+    background-color: var(--bs-primary);
+}
+
+.media-result-summary {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: baseline;
+    gap: 0.25rem 0.75rem;
+    margin: 0.25rem 0 0.5rem;
+    color: var(--bs-secondary-color);
+}
+
+.media-criteria {
+    display: contents;
+    list-style: none;
+}
+
+.media-criteria li {
+    padding: 0 0.5rem;
+    border-radius: var(--bs-border-radius-pill);
+    background-color: var(--bs-secondary-bg);
+    color: var(--bs-body-color);
+    font-size: 0.875rem;
+    overflow-wrap: anywhere;
+}
+
+/* Grid: equal tiles whatever the image shape. The preview box has a fixed 
shape
+   and keeps the whole image inside it, so names line up across a row. */
+.media-grid {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
+    gap: 1.25rem 1rem;
+    list-style: none;
+    padding: 0;
+    margin: 0.5rem 0 1.5rem;
+}
+
+.media-card {
+    position: relative;
+    display: flex;
+    flex-direction: column;
+    min-width: 0;
+}
+
+.media-card-preview {
+    position: relative;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    aspect-ratio: 1;
+    width: 100%;
+    min-height: 0;
+    overflow: hidden;
+    padding: 0;
+    border: 0;
+    border-radius: var(--bs-border-radius-lg);
+    /* a tinted frame keeps white and transparent images visible */
+    background-color: var(--bs-tertiary-bg);
+    cursor: pointer;
+    transition: box-shadow 0.15s ease-in-out;
+}
+
+.media-card-preview img {
+    position: absolute;
+    inset: 0.5rem;
+    width: calc(100% - 1rem);
+    height: calc(100% - 1rem);
+    object-fit: contain;
+    transition: transform 0.15s ease-in-out;
+}
+
+.media-card-preview .bi {
+    font-size: 3rem;
+    color: var(--bs-secondary-color);
+}
+
+.media-card:hover .media-card-preview {
+    box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.15);
+}
+
+.media-card.selected .media-card-preview {
+    box-shadow: inset 0 0 0 3px var(--bs-primary);
+    background-color: var(--bs-primary-bg-subtle);
+}
+
+.media-card.selected .media-card-preview img {
+    transform: scale(0.88);
+}
+
+/* The checkbox sits over the preview, shown on hover or focus, and always
+   while files are selected or on touch screens */
+.media-card > .media-select {
+    position: absolute;
+    top: 0.5rem;
+    left: 0.5rem;
+    z-index: 1;
+    width: 1.35rem;
+    height: 1.35rem;
+    margin: 0;
+    border-radius: 50%;
+    box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.8);
+    opacity: 0;
+    transition: opacity 0.15s ease-in-out;
+    cursor: pointer;
+}
+
+.media-card:hover > .media-select,
+.media-card > .media-select:focus-visible,
+.media-card > .media-select:checked,
+.media-library.selecting .media-card > .media-select {
+    opacity: 1;
+}
+
+@media (hover: none) {
+    .media-card > .media-select {
+        opacity: 1;
+    }
+}
+
+.media-card-body {
+    display: flex;
+    padding: 0.4rem 0.125rem 0;
+    min-width: 0;
+}
+
+.media-card-text {
+    display: flex;
+    flex-direction: column;
+    min-width: 0;
+    width: 100%;
+}
+
+/* One line per name; the full name is in the tooltip and on the details page 
*/
+.media-card-name {
+    display: block;
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+    padding: 0;
+    border: 0;
+    background: none;
+    text-align: start;
+    font-size: 0.875rem;
+    color: var(--bs-body-color);
+    text-decoration: none;
+    cursor: pointer;
+}
+
+.media-card-name:hover {
+    color: var(--bs-link-color);
+    text-decoration: underline;
+}
+
+.media-card-meta {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 0 0.5rem;
+    font-size: 0.75rem;
+    color: var(--bs-secondary-color);
+}
+
+.media-card-date {
+    display: none;
+}
+
+.media-empty {
     text-align: center;
+    padding: 3rem 1rem;
+    color: var(--bs-secondary-color);
 }
 
-#sortByMenu {
-    width: 15em;
+.media-empty .bi {
+    font-size: 3rem;
 }
 
-#viewDirectoryMenu {
-    width: 15em;
+.media-folder-actions {
+    margin-top: 1rem;
+    text-align: end;
 }
 
-#moveTargetMenu {
-    width: 15em;
+.media-library .roller-pager {
+    justify-content: center;
 }
 
-.mediaObject {
-    width:120px;
-    max-height:120px;
-    margin-top: 10px;
-    margin-left: 10px;
+.media-list-header {
+    display: none;
 }
 
-.mediaObjectInfo {
-    font-size:11px;
+/* List view: one row per file, with name, type, size and upload date columns 
*/
+.media-grid.media-list {
+    grid-template-columns: minmax(0, 1fr);
+    gap: 0;
 }
 
-span.glyphicon-file {
-    font-size: 6em;
+.media-grid.media-list .media-list-header,
+.media-grid.media-list .media-card {
+    display: grid;
+    grid-template-columns: 1.35rem 3rem minmax(0, 1fr) 5rem 5rem 6rem;
+    align-items: center;
+    gap: 0.75rem;
+    padding: 0.35rem 0.5rem;
+    border-bottom: 1px solid var(--bs-border-color);
 }
 
-.highlight {
-    border: 3px solid #aaa;
+.media-grid.media-list .media-card:hover {
+    background-color: var(--bs-tertiary-bg);
 }
 
-span.button {
-    height:15px;
-    width:15px;
-    float:right;
+.media-grid.media-list .media-card.selected {
+    background-color: var(--bs-primary-bg-subtle);
 }
 
-.sortByMenu, .viewDirectoryMenu {
-    display: inline;
+.media-grid.media-list .media-list-header {
+    font-size: 0.875rem;
+    font-weight: 600;
+    color: var(--bs-secondary-color);
+}
+
+.media-grid.media-list .media-card-body,
+.media-grid.media-list .media-card-text,
+.media-grid.media-list .media-card-meta {
+    display: contents;
+}
+
+.media-grid.media-list .media-card > .media-select {
+    order: 1;
+    position: static;
+    width: 1.1rem;
+    height: 1.1rem;
+    border-radius: 0.25em;
+    box-shadow: none;
+    opacity: 1;
+}
+
+.media-grid.media-list .media-card-preview {
+    order: 2;
+    width: 3rem;
+    border-radius: var(--bs-border-radius-sm);
+    box-shadow: none;
 }
 
-#imageGrid {
+.media-grid.media-list .media-card-preview img {
+    inset: 2px;
+    width: calc(100% - 4px);
+    height: calc(100% - 4px);
+    transform: none;
+}
+
+.media-grid.media-list .media-card-preview .bi {
+    font-size: 1.5rem;
+}
+
+.media-grid.media-list .media-card-name {
+    order: 3;
+}
+
+.media-grid.media-list .media-card-type {
+    order: 4;
+}
+
+.media-grid.media-list .media-card-size {
+    order: 5;
+}
+
+.media-grid.media-list .media-card-date {
+    order: 6;
     display: block;
-    overflow: auto;
-    margin: 1em 0 1em 0;
 }
 
-#imageGrid > div > ul {
-    padding-left:0;
+.media-grid.media-list .media-card-type,
+.media-grid.media-list .media-card-size,
+.media-grid.media-list .media-card-date {
+    font-size: 0.875rem;
 }
 
-div.image-controls,
-div.image-controls div,
-div.image-controls span,
-div.image-controls select,
-div.image-controls input {
-    display: inline;
+@media (max-width: 575.98px) {
+    .media-grid {
+        grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));
+    }
+
+    .media-grid.media-list .media-list-header,
+    .media-grid.media-list .media-card {
+        grid-template-columns: 1.35rem 3rem minmax(0, 1fr) 4.5rem;
+    }
+
+    .media-grid.media-list .media-list-header span:nth-child(4),
+    .media-grid.media-list .media-list-header span:nth-child(6),
+    .media-grid.media-list .media-card-type,
+    .media-grid.media-list .media-card-date {
+        display: none;
+    }
+}
+
+/* media file details (MediaFileEdit.jsp) */
+
+.media-back {
+    margin-bottom: 1rem;
+}
+
+.media-details-top {
+    display: grid;
+    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+    gap: 1.5rem;
+}
+
+.media-details-preview {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    min-height: 12rem;
+    padding: 0.75rem;
+    border: 1px solid var(--bs-border-color);
+    border-radius: var(--bs-border-radius);
+    background-color: var(--bs-tertiary-bg);
+}
+
+.media-details-preview img {
+    max-width: 100%;
+    max-height: 24rem;
+    object-fit: contain;
+}
+
+.media-details-preview .bi {
+    font-size: 5rem;
+    color: var(--bs-secondary-color);
+}
+
+.media-details-name {
+    font-size: 1.25rem;
+    overflow-wrap: anywhere;
+}
+
+.media-facts {
+    display: grid;
+    grid-template-columns: max-content minmax(0, 1fr);
+    gap: 0.25rem 1rem;
+    margin: 1rem 0;
+}
+
+.media-facts dt {
+    font-weight: 600;
+}
+
+.media-facts dd {
+    margin: 0;
+    overflow-wrap: anywhere;
+}
+
+.media-copy-status {
+    min-height: 1.5rem;
+    margin: 0.25rem 0 0;
+    font-size: 0.875rem;
+    color: var(--bs-success-text-emphasis);
+}
+
+.media-details-actions {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 0.5rem;
+}
+
+.media-details-actions form {
+    margin: 0;
+}
+
+.media-edit-heading {
+    font-size: 1.1rem;
+    margin: 2rem 0 1rem;
+    padding-top: 1rem;
+    border-top: 1px solid var(--bs-border-color);
+}
+
+/* The bootstrap theme adds no spacing to form-vertical groups */
+.media-edit-form > fieldset > div,
+.media-edit-form > fieldset > details {
+    margin-bottom: 1rem;
+}
+
+.media-advanced summary {
+    margin-bottom: 0.5rem;
+    cursor: pointer;
 }
 
+.media-edit-buttons {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 0.5rem;
+}
+
+@media (max-width: 767.98px) {
+    .media-details-top {
+        grid-template-columns: minmax(0, 1fr);
+    }
+}
+
+/* media upload (MediaFileAdd.jsp) */
+
+.media-upload > * + * {
+    margin-top: 1rem;
+}
+
+.media-upload-folder .form-select {
+    max-width: 20rem;
+}
+
+.media-drop-zone {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    gap: 0.5rem;
+    padding: 2rem 1rem;
+    border: 2px dashed var(--bs-border-color);
+    border-radius: var(--bs-border-radius-lg);
+    background-color: var(--bs-tertiary-bg);
+    text-align: center;
+}
+
+.media-drop-zone.dragging {
+    border-color: var(--bs-primary);
+    background-color: var(--bs-primary-bg-subtle);
+}
+
+.media-drop-zone > .bi {
+    font-size: 2.5rem;
+    color: var(--bs-secondary-color);
+}
+
+.media-drop-zone .form-control {
+    max-width: 28rem;
+}
+
+.media-upload-list {
+    list-style: none;
+    padding: 0;
+    margin: 0;
+}
+
+.media-upload-list:empty {
+    display: none;
+}
+
+.media-upload-item {
+    display: grid;
+    grid-template-columns: 3rem minmax(0, 1fr) auto auto;
+    grid-template-areas: "preview name size remove" "preview state state 
remove";
+    align-items: center;
+    gap: 0 0.75rem;
+    padding: 0.5rem;
+    border-bottom: 1px solid var(--bs-border-color);
+}
+
+.media-upload-preview {
+    grid-area: preview;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 3rem;
+    height: 3rem;
+    background-color: var(--bs-tertiary-bg);
+    border-radius: var(--bs-border-radius-sm);
+}
+
+.media-upload-preview img {
+    max-width: 100%;
+    max-height: 100%;
+    object-fit: contain;
+}
+
+.media-upload-preview .bi {
+    font-size: 1.5rem;
+    color: var(--bs-secondary-color);
+}
+
+.media-upload-name {
+    grid-area: name;
+    overflow-wrap: anywhere;
+}
+
+.media-upload-size {
+    grid-area: size;
+    font-size: 0.875rem;
+    color: var(--bs-secondary-color);
+}
+
+.media-upload-state {
+    grid-area: state;
+    font-size: 0.875rem;
+    color: var(--bs-secondary-color);
+}
+
+.media-upload-remove {
+    grid-area: remove;
+}
+
+.media-upload-item[data-state="uploaded"] .media-upload-state {
+    color: var(--bs-success-text-emphasis);
+}
+
+.media-upload-item[data-state="failed"] .media-upload-state,
+.media-upload-item[data-state="unknown"] .media-upload-state {
+    color: var(--bs-danger-text-emphasis);
+}
+
+.media-upload-shared summary {
+    cursor: pointer;
+    font-weight: 600;
+}
+
+.media-upload-shared .form-label,
+.media-upload-shared .form-check {
+    margin-top: 0.75rem;
+}
+
+.media-upload-buttons {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 0.5rem;
+}
+
+.media-upload-status:empty {
+    display: none;
+}
+
+.media-upload-failed {
+    list-style: none;
+    padding: 0;
+}
+
+@media (prefers-reduced-motion: no-preference) {
+    .media-drop-zone {
+        transition: border-color 0.15s, background-color 0.15s;
+    }
+}
+
+/* Narrow screens (media pages set a device-width viewport): the sidebar cards
+   give way to the folder picker in the page header */
+.media-folder-picker {
+    display: none;
+}
+
+@media (max-width: 767.98px) {
+    .roller-column-left,
+    .roller-column-right {
+        padding-left: 0.75rem;
+        padding-right: 0.75rem;
+    }
+
+    .roller-column-left .card:has(.media-folders),

Review Comment:
   🐞Codex Issue: **Important:** Keep folder creation accessible when hiding the 
sidebar. The only New folder button and creation form are inside 
`.media-folders`, so this rule removes them below 768px. The replacement 
`#folderPicker` only selects existing folders. Confirmed at 390px width: the 
picker is visible and the New folder control is hidden, with no replacement 
action. Put a New folder action/form in the narrow-screen header or retain an 
accessible way to open the sidebar, and cover folder creation at a mobile 
viewport.



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