snoopdave commented on code in PR #233: URL: https://github.com/apache/roller/pull/233#discussion_r4438613210
########## 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: 🤖Claude: fixed by refusing new files while an upload runs (input and drop zone disabled), and redirecting only when every file uploaded. Test: refusesFilesWhileUploading. ########## 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: 🤖Claude: fixed by loading the media file in myPrepare(), so the INPUT result after failed field validation still shows the form. Test: correctsInvalidDetails. ########## 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: 🤖Claude: fixed by setting the directory, its ID and its name from the uploaded files in done(). Test: uploadMoreKeepsTheFolder. ########## 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: 🤖Claude: fixed by hiding only the folder list on narrow screens, not the card that holds the new-folder form. Test: createsAFolderOnANarrowScreen. ########## app/src/main/java/org/apache/roller/weblogger/ui/struts2/editor/MediaFileSearchBean.java: ########## @@ -173,37 +245,34 @@ public void copyTo(MediaFileFilter dataHolder) { } dataHolder.setSizeFilterType(sftype); - long filterSize = this.size ; + long filterSize = this.size; if ("mediaFileView.kb".equals(this.sizeUnit)) { filterSize = this.size * RollerConstants.ONE_KB_IN_BYTES; } else if ("mediaFileView.mb".equals(this.sizeUnit)) { - filterSize = this.size * RollerConstants.ONE_MB_IN_BYTES; + filterSize = this.size * RollerConstants.ONE_MB_IN_BYTES; } dataHolder.setSize(filterSize); } - if (!StringUtils.isEmpty(this.tags)) { - dataHolder.setTags(Arrays.asList(this.tags.split(" "))); + if (StringUtils.isNotBlank(this.tags)) { + dataHolder.setTags(Arrays.asList(StringUtils.split(this.tags.trim()))); } - dataHolder.setStartIndex(pageNum * PAGE_SIZE); - + dataHolder.setStartIndex(page * PAGE_SIZE); Review Comment: 🤖Claude: fixed by capping the page in setPage() at Integer.MAX_VALUE / PAGE_SIZE. Test: hugePageStillGivesAPositiveStartIndex. ########## app/src/main/java/org/apache/roller/weblogger/ui/struts2/editor/MediaFileImageChooser.java: ########## @@ -76,55 +86,73 @@ public List<String> requiredWeblogPermissionActions() { * * @return String The result of the action. */ + /** Review Comment: 🤖Claude: fixed by removing the old Javadoc block. ########## app/src/main/java/org/apache/roller/weblogger/ui/struts2/editor/MediaFileEdit.java: ########## @@ -181,6 +210,37 @@ public String save() { return INPUT; } + public MediaFile getMediaFile() { + return mediaFile; + } + + public String getReturnQuery() { + return returnQuery; + } + + /** + * The library page to return to. Only the query string comes from the + * request, and only URL-safe characters are kept, so the link always + * points at the library of this weblog. Review Comment: 🤖Claude: fixed by building the link from this weblog's handle and dropping any weblog parameter from returnQuery, so the comment is now true. -- 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]
