admin tweaks

This commit is contained in:
2026-07-27 12:20:34 +02:00
parent 1a6abc95f5
commit 43e6c7e00b
8 changed files with 162 additions and 56 deletions
+13 -1
View File
@@ -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
+2 -2
View File
@@ -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
+26
View File
@@ -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
+18 -16
View File
@@ -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
+5 -2
View File
@@ -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
+92 -35
View File
@@ -101,10 +101,11 @@
}
html += '</div>';
// Upload bar
html += '<div class="fm-actions">';
// Upload bar (also a drop zone)
html += '<div class="fm-actions fm-dropzone" id="fm-picker-dropzone">';
html += '<input type="file" id="fm-picker-file" />';
html += '<button class="fm-btn" id="fm-picker-upload-btn">Upload</button>';
html += '<span class="fm-drop-hint">or drag &amp; drop files here</span>';
html += '</div>';
// 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') {
@@ -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 {
@@ -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?