admin tweaks
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 & 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?
|
||||
|
||||
|
||||
Reference in New Issue
Block a user