From 43e6c7e00bbbe1de133f23797aa5859f7be97383 Mon Sep 17 00:00:00 2001 From: Zsolt Tasnadi Date: Mon, 27 Jul 2026 12:20:34 +0200 Subject: [PATCH] admin tweaks --- apps/api/app/admin/events.rb | 14 +- apps/api/app/admin/files.rb | 4 +- apps/api/app/admin/images.rb | 26 ++++ apps/api/app/admin/members.rb | 34 ++--- apps/api/app/admin/softwares.rb | 7 +- .../app/assets/javascripts/active_admin.js | 127 +++++++++++++----- .../app/assets/stylesheets/active_admin.scss | 3 + apps/api/app/services/file_manager_service.rb | 3 + 8 files changed, 162 insertions(+), 56 deletions(-) diff --git a/apps/api/app/admin/events.rb b/apps/api/app/admin/events.rb index d78826f..5f91f01 100644 --- a/apps/api/app/admin/events.rb +++ b/apps/api/app/admin/events.rb @@ -3,11 +3,23 @@ ActiveAdmin.register Event do menu priority: 3 + scope :all, default: true + scope("Upcoming") { |scope| scope.where("date >= ?", Time.current) } + scope("Past") { |scope| scope.where("date < ?", Time.current) } + index do selectable_column id_column column :name - column :date + column :date do |event| + if event.date >= Time.current + status_tag "upcoming", class: "ok" + text_node " #{l event.date, format: :long}" + else + status_tag "past", class: "default" + text_node " #{l event.date, format: :long}" + end + end column :created_at actions end diff --git a/apps/api/app/admin/files.rb b/apps/api/app/admin/files.rb index 622661c..efe6d4b 100644 --- a/apps/api/app/admin/files.rb +++ b/apps/api/app/admin/files.rb @@ -39,8 +39,8 @@ ActiveAdmin.register_page "Files" do end end - # Action bar - div class: "fm-actions" do + # Action bar (also drop zone on standalone page) + div class: "fm-actions fm-dropzone", id: "fm-page-dropzone" do form action: admin_files_upload_path, method: "post", enctype: "multipart/form-data", class: "fm-inline-form" do |_f| input type: "hidden", name: "authenticity_token", value: form_authenticity_token input type: "hidden", name: "dir", value: current_dir diff --git a/apps/api/app/admin/images.rb b/apps/api/app/admin/images.rb index 39742a3..dddd6e9 100644 --- a/apps/api/app/admin/images.rb +++ b/apps/api/app/admin/images.rb @@ -3,6 +3,20 @@ ActiveAdmin.register Image do menu priority: 5 + scope :all, default: true + scope("In use") { |scope| scope.where(id: SoftwareImage.select(:image_id)).or(scope.where(id: Member.where.not(image_id: nil).select(:image_id))) } + scope("Orphan") { |scope| scope.where.not(id: SoftwareImage.select(:image_id)).where.not(id: Member.where.not(image_id: nil).select(:image_id)) } + + batch_action :delete_orphans, confirm: "Delete all selected orphan images and their files?" do |ids| + in_use_ids = SoftwareImage.where(image_id: ids).pluck(:image_id) + Member.where(image_id: ids).pluck(:image_id) + orphan_ids = ids.map(&:to_i) - in_use_ids + Image.where(id: orphan_ids).find_each do |img| + File.delete(img.file_path) if File.exist?(img.file_path) + img.destroy + end + redirect_to admin_images_path, notice: "Deleted #{orphan_ids.size} orphan image(s)." + end + index do selectable_column id_column @@ -13,6 +27,12 @@ ActiveAdmin.register Image do image_tag("/api/image/#{img.id}", style: "max-height:60px;max-width:120px;object-fit:contain;") end end + column(:usage) do |img| + uses = [] + uses << "#{img.software_images.size} software(s)" if img.software_images.any? + uses << "member" if Member.where(image_id: img.id).exists? + uses.any? ? uses.join(", ") : status_tag("orphan", class: "warning") + end column :created_at actions end @@ -44,4 +64,10 @@ ActiveAdmin.register Image do end f.actions end + + controller do + def scoped_collection + super.includes(:software_images) + end + end end diff --git a/apps/api/app/admin/members.rb b/apps/api/app/admin/members.rb index 51a9a48..6c21480 100644 --- a/apps/api/app/admin/members.rb +++ b/apps/api/app/admin/members.rb @@ -3,17 +3,18 @@ ActiveAdmin.register Member do menu priority: 4 + actions :all, except: [:edit] + index do selectable_column id_column column :nick do |m| - link_to m.nick, edit_admin_member_path(m) + link_to m.nick, admin_member_path(m) end column :real_nick column :motto do |m| truncate m.motto, length: 60 end - column :avatar_filename column(:image) do |m| if m.image && File.exist?(m.image.file_path) image_tag "/api/image/#{m.image.id}", style: "max-height:40px;max-width:80px;object-fit:contain;" @@ -33,26 +34,27 @@ ActiveAdmin.register Member do f.input :real_nick f.input :motto f.input :avatar_filename, - hint: "Fallback image filename (e.g. mr.zero.png) — only used when no image is selected above." + hint: "Fallback image filename (e.g. mr.zero.png) — only used when no image is selected." + + img_hint = if resource.image_id.present? && resource.image + f.template.image_tag("/api/image/#{resource.image_id}", style: "max-height:80px;max-width:160px;object-fit:contain;margin-top:6px;").html_safe + end f.input :image_id, as: :select, label: "Image", - collection: Image.order(:original_filename).map { |img| [ img.original_filename, img.id ] }, - include_blank: "— no image (use avatar_filename) —" + collection: Image.order(:original_filename).map { |img| [img.original_filename, img.id] }, + include_blank: "— no image (use avatar_filename) —", + hint: img_hint end f.actions end end - form do |f| - f.inputs do - f.input :nick - f.input :real_nick - f.input :motto - f.input :avatar_filename, - hint: "Fallback image filename (e.g. mr.zero.png) — only used when no image is selected above." - f.input :image_id, as: :select, label: "Image", - collection: Image.order(:original_filename).map { |img| [ img.original_filename, img.id ] }, - include_blank: "— no image (use avatar_filename) —" + controller do + def scoped_collection + super.includes(:image) + end + + def find_resource + scoped_collection.find(params[:id]) end - f.actions end end diff --git a/apps/api/app/admin/softwares.rb b/apps/api/app/admin/softwares.rb index f8d9d7e..838facb 100644 --- a/apps/api/app/admin/softwares.rb +++ b/apps/api/app/admin/softwares.rb @@ -9,6 +9,8 @@ ActiveAdmin.register Software do actions :all, except: [:edit] + config.sort_order = 'title_asc' + index do selectable_column id_column @@ -21,6 +23,7 @@ ActiveAdmin.register Software do status_tag sw.status end column :highlighted + column(:releases) { |sw| sw.releases.size } column(:image) do |sw| si = sw.software_images.detect(&:is_default?) || sw.software_images.first if si&.image && File.exist?(si.image.file_path) @@ -39,7 +42,7 @@ ActiveAdmin.register Software do filter :highlighted show title: proc { |sw| sw.title } do - active_admin_form_for [ :admin, resource ], url: admin_software_path(resource), html: { method: :put } do |f| + active_admin_form_for [ :admin, resource ], url: admin_software_path(resource), html: { method: :put, multipart: true } do |f| f.inputs "Details" do f.input :name f.input :title @@ -91,7 +94,7 @@ ActiveAdmin.register Software do controller do def scoped_collection - super.includes(software_images: :image) + super.includes(:releases, software_images: :image) end def find_resource diff --git a/apps/api/app/assets/javascripts/active_admin.js b/apps/api/app/assets/javascripts/active_admin.js index 7a0aac6..0c3ca1e 100644 --- a/apps/api/app/assets/javascripts/active_admin.js +++ b/apps/api/app/assets/javascripts/active_admin.js @@ -101,10 +101,11 @@ } html += ''; - // Upload bar - html += '
'; + // Upload bar (also a drop zone) + html += '
'; html += ''; html += ''; + html += 'or drag & drop files here'; html += '
'; // Table @@ -142,6 +143,22 @@ // Event delegation body.addEventListener('click', handlePickerClick); + + // Drag & drop + var dropzone = document.getElementById('fm-picker-dropzone'); + if (dropzone) { + ['dragenter', 'dragover'].forEach(function(evt) { + dropzone.addEventListener(evt, function(e) { e.preventDefault(); dropzone.classList.add('fm-dragover'); }); + }); + ['dragleave', 'drop'].forEach(function(evt) { + dropzone.addEventListener(evt, function(e) { e.preventDefault(); dropzone.classList.remove('fm-dragover'); }); + }); + dropzone.addEventListener('drop', function(e) { + var files = e.dataTransfer.files; + if (!files.length) return; + uploadFile(files[0], dir); + }); + } } function handlePickerClick(e) { @@ -166,42 +183,46 @@ e.preventDefault(); var fileInput = document.getElementById('fm-picker-file'); if (!fileInput || !fileInput.files.length) { alert('Select a file first'); return; } - - var breadcrumbs = document.querySelector('#fm-picker-body .fm-breadcrumbs'); - var lastLink = breadcrumbs ? breadcrumbs.querySelectorAll('a') : []; - var currentDir = lastLink.length ? lastLink[lastLink.length - 1].getAttribute('data-dir') || '' : ''; - - var csrfToken = document.querySelector('meta[name="csrf-token"]'); - var fd = new FormData(); - fd.append('file', fileInput.files[0]); - fd.append('dir', currentDir); - - t.textContent = 'Uploading...'; - t.disabled = true; - - fetch('/admin/files/ajax_upload', { - method: 'POST', - headers: csrfToken ? { 'X-CSRF-Token': csrfToken.getAttribute('content') } : {}, - body: fd - }) - .then(function(r) { return r.json(); }) - .then(function(data) { - if (data.ok) { - loadDir(currentDir); - } else { - alert('Upload failed: ' + (data.error || 'unknown error')); - t.textContent = 'Upload'; - t.disabled = false; - } - }) - .catch(function(err) { - alert('Upload error: ' + err.message); - t.textContent = 'Upload'; - t.disabled = false; - }); + var dir = getCurrentPickerDir(); + uploadFile(fileInput.files[0], dir); } } + function getCurrentPickerDir() { + var breadcrumbs = document.querySelector('#fm-picker-body .fm-breadcrumbs'); + var links = breadcrumbs ? breadcrumbs.querySelectorAll('a') : []; + return links.length ? links[links.length - 1].getAttribute('data-dir') || '' : ''; + } + + function uploadFile(file, dir) { + var csrfToken = document.querySelector('meta[name="csrf-token"]'); + var fd = new FormData(); + fd.append('file', file); + fd.append('dir', dir); + + var hint = document.querySelector('.fm-drop-hint'); + if (hint) hint.textContent = 'Uploading ' + file.name + '...'; + + fetch('/admin/files/ajax_upload', { + method: 'POST', + headers: csrfToken ? { 'X-CSRF-Token': csrfToken.getAttribute('content') } : {}, + body: fd + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (data.ok) { + loadDir(dir); + } else { + alert('Upload failed: ' + (data.error || 'unknown error')); + if (hint) hint.textContent = 'or drag & drop files here'; + } + }) + .catch(function(err) { + alert('Upload error: ' + err.message); + if (hint) hint.textContent = 'or drag & drop files here'; + }); + } + function esc(s) { var d = document.createElement('div'); d.textContent = s; @@ -236,9 +257,45 @@ }); } + // Standalone Files page drag & drop + function initPageDropzone() { + var dz = document.getElementById('fm-page-dropzone'); + if (!dz) return; + + ['dragenter', 'dragover'].forEach(function(evt) { + dz.addEventListener(evt, function(e) { e.preventDefault(); dz.classList.add('fm-dragover'); }); + }); + ['dragleave', 'drop'].forEach(function(evt) { + dz.addEventListener(evt, function(e) { e.preventDefault(); dz.classList.remove('fm-dragover'); }); + }); + dz.addEventListener('drop', function(e) { + var files = e.dataTransfer.files; + if (!files.length) return; + var dirInput = dz.querySelector('input[name="dir"]'); + var dir = dirInput ? dirInput.value : ''; + var csrfToken = document.querySelector('meta[name="csrf-token"]'); + var fd = new FormData(); + fd.append('file', files[0]); + fd.append('dir', dir); + + fetch('/admin/files/ajax_upload', { + method: 'POST', + headers: csrfToken ? { 'X-CSRF-Token': csrfToken.getAttribute('content') } : {}, + body: fd + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (data.ok) { window.location.reload(); } + else { alert('Upload failed: ' + (data.error || 'unknown error')); } + }) + .catch(function(err) { alert('Upload error: ' + err.message); }); + }); + } + document.addEventListener('DOMContentLoaded', function() { addBrowseButtons(); addImagePreviewListeners(); + initPageDropzone(); }); if (typeof jQuery !== 'undefined') { diff --git a/apps/api/app/assets/stylesheets/active_admin.scss b/apps/api/app/assets/stylesheets/active_admin.scss index 6393299..5578098 100644 --- a/apps/api/app/assets/stylesheets/active_admin.scss +++ b/apps/api/app/assets/stylesheets/active_admin.scss @@ -26,6 +26,9 @@ .fm-action-link { font-size: 12px; color: #5a6268; text-decoration: none; margin-right: 8px; &:hover { text-decoration: underline; } } .fm-danger { color: #dc3545; } .fm-entry-actions { white-space: nowrap; } +.fm-drop-hint { font-size: 12px; color: #999; margin-left: 8px; } +.fm-dropzone { transition: background 0.15s, border-color 0.15s; border: 2px dashed transparent; } +.fm-dropzone.fm-dragover { background: #e8f4fd !important; border-color: #4a90d9 !important; } // Browse button in release forms .fm-browse-btn { diff --git a/apps/api/app/services/file_manager_service.rb b/apps/api/app/services/file_manager_service.rb index 4735db7..7ee4d7f 100644 --- a/apps/api/app/services/file_manager_service.rb +++ b/apps/api/app/services/file_manager_service.rb @@ -18,7 +18,10 @@ class FileManagerService end end + MAX_UPLOAD_SIZE = 100 * 1024 * 1024 # 100MB + def upload(relative_dir, uploaded_file) + raise ArgumentError, "File too large (max 100MB)" if uploaded_file.size > MAX_UPLOAD_SIZE dir = safe_path!(relative_dir) raise ArgumentError, "Not a directory" unless dir.directory?