Phase 5: ship the catalog ActiveAdmin resources from the engine
- the 7 catalog admin files (softwares, releases, external_links, platform_links, images, files page, downloads) move to the engine's app/admin; the host ActiveAdmin instance loads them via ActiveAdmin.application.load_paths (single admin, URLs unchanged) - engine initializers: Zeitwerk ignore for app/admin (production eager load) and load_paths + watchable_dirs registration, guarded by defined?(ActiveAdmin) so the admin-less dummy app boots - images admin reads image owners from WarpEngine.config.image_owners; the host registers the Member owner in config/initializers/warp_engine.rb; the interim ImageUsage registry is gone - fix: SoftwareImage.distinct.pluck clashed with its order(:position) default scope on MySQL (unscope(:order)) — introduced in phase 0, caught by the first authenticated /admin/images smoke test - files page: download links use the public /file/ URL instead of the raw container path; the picker's stored path comes from config Verified: both suites green, zeitwerk:check (production) clean, JSON baselines intact, authenticated admin smoke test on every page incl. the 3-level nested software form and Files picker mode. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
ActiveAdmin.register WarpEngine::Download, as: "Download" do
|
||||
actions :index, :show
|
||||
|
||||
menu priority: 7, label: "📊 Download Stats"
|
||||
|
||||
scope :all, default: true
|
||||
scope("Today") { |scope| scope.where("downloads.created_at >= ?", Date.current.beginning_of_day) }
|
||||
scope("This week") { |scope| scope.where("downloads.created_at >= ?", 1.week.ago) }
|
||||
scope("This month") { |scope| scope.where("downloads.created_at >= ?", 1.month.ago) }
|
||||
scope("With release") { |scope| scope.where.not(release_id: nil) }
|
||||
scope("Without release") { |scope| scope.where(release_id: nil) }
|
||||
|
||||
config.sort_order = "created_at_desc"
|
||||
|
||||
sidebar "Summary", only: :index do
|
||||
dl style: "display:grid;grid-template-columns:1fr auto;gap:6px 12px;margin:0;" do
|
||||
dt { strong "Total" }
|
||||
dd { b WarpEngine::Download.count.to_s }
|
||||
|
||||
dt { strong "Today" }
|
||||
dd { b WarpEngine::Download.where("created_at >= ?", Date.current.beginning_of_day).count.to_s }
|
||||
|
||||
dt { strong "This week" }
|
||||
dd { b WarpEngine::Download.where("created_at >= ?", 1.week.ago).count.to_s }
|
||||
|
||||
dt { strong "This month" }
|
||||
dd { b WarpEngine::Download.where("created_at >= ?", 1.month.ago).count.to_s }
|
||||
end
|
||||
end
|
||||
|
||||
sidebar "Top 10 Files", only: :index do
|
||||
table_for WarpEngine::Download.group(:file_path)
|
||||
.select("file_path, COUNT(*) as dl_count, MAX(created_at) as last_dl")
|
||||
.order("dl_count DESC")
|
||||
.limit(10) do
|
||||
column("File") { |row| span row.file_path.split("/").last, style: "font-family:monospace;font-size:11px;" }
|
||||
column("DL") { |row| row.dl_count }
|
||||
end
|
||||
end
|
||||
|
||||
sidebar "Top 5 Software", only: :index do
|
||||
rows = WarpEngine::Download.joins(release: :software)
|
||||
.group("softwares.title")
|
||||
.order("count_all DESC")
|
||||
.limit(5)
|
||||
.count
|
||||
if rows.any?
|
||||
table_for rows do
|
||||
column("Software") { |title, _| title }
|
||||
column("DL") { |_, count| count }
|
||||
end
|
||||
else
|
||||
para "No data", style: "color:#999;font-style:italic;"
|
||||
end
|
||||
end
|
||||
|
||||
index do
|
||||
id_column
|
||||
column(:file_path) { |d| span d.file_path.truncate(50), style: "font-family:monospace;font-size:12px;" }
|
||||
column("Software") { |d| d.release&.software ? link_to(d.release.software.title, admin_software_path(d.release.software)) : "–" }
|
||||
column("Release") { |d| d.release&.version || "–" }
|
||||
column :ip_address
|
||||
column("User-Agent") { |d| d.user_agent&.truncate(40) }
|
||||
column("Referer") { |d| d.referer&.truncate(40) }
|
||||
column :created_at
|
||||
end
|
||||
|
||||
filter :file_path_cont, label: "File path"
|
||||
filter :release_software_title_cont, label: "Software"
|
||||
filter :ip_address_cont, label: "IP address"
|
||||
filter :user_agent_cont, label: "User-Agent"
|
||||
filter :referer_cont, label: "Referer"
|
||||
filter :created_at
|
||||
|
||||
show do
|
||||
attributes_table do
|
||||
row :id
|
||||
row(:file_path) { |d| span d.file_path, style: "font-family:monospace;" }
|
||||
row("Software") do |d|
|
||||
if d.release&.software
|
||||
link_to d.release.software.title, admin_software_path(d.release.software)
|
||||
else
|
||||
"–"
|
||||
end
|
||||
end
|
||||
row("Release") do |d|
|
||||
if d.release
|
||||
link_to d.release.version, admin_software_release_path(d.release.software, d.release)
|
||||
else
|
||||
"–"
|
||||
end
|
||||
end
|
||||
row :ip_address
|
||||
row :user_agent
|
||||
row :referer
|
||||
row :created_at
|
||||
row :updated_at
|
||||
end
|
||||
end
|
||||
|
||||
controller do
|
||||
def scoped_collection
|
||||
super.includes(release: :software)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,34 @@
|
||||
ActiveAdmin.register WarpEngine::ExternalLink, as: "External Link" do
|
||||
permit_params :software_id, :label, :url
|
||||
|
||||
menu false
|
||||
|
||||
belongs_to :software
|
||||
|
||||
index do
|
||||
selectable_column
|
||||
id_column
|
||||
column :label
|
||||
column(:url) { |el| link_to el.url, el.url, target: "_blank" }
|
||||
column :created_at
|
||||
actions
|
||||
end
|
||||
|
||||
form do |f|
|
||||
f.inputs do
|
||||
f.input :label
|
||||
f.input :url
|
||||
end
|
||||
f.actions
|
||||
end
|
||||
|
||||
show do
|
||||
attributes_table do
|
||||
row :id
|
||||
row :label
|
||||
row(:url) { |el| link_to el.url, el.url, target: "_blank" }
|
||||
row :created_at
|
||||
row :updated_at
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,255 @@
|
||||
ActiveAdmin.register_page "Files" do
|
||||
menu priority: 6, label: "📁 Files"
|
||||
|
||||
content do
|
||||
service = WarpEngine::FileManagerService.new
|
||||
current_dir = params[:dir].to_s.presence || ""
|
||||
picker_mode = params[:picker].present?
|
||||
picker_field = params[:field].to_s
|
||||
|
||||
begin
|
||||
entries = service.list(current_dir)
|
||||
rescue ArgumentError
|
||||
entries = []
|
||||
end
|
||||
|
||||
# Picker mode: hide admin header/menu/footer
|
||||
if picker_mode
|
||||
text_node "<style>#header,#tabs,.footer,#title_bar,#utility_nav{display:none!important}#active_admin_content{margin:0;padding:0}#wrapper{margin:0}body{min-height:auto}</style>".html_safe
|
||||
end
|
||||
|
||||
div class: "file-manager" do
|
||||
h2 do
|
||||
text_node "File Manager"
|
||||
if picker_mode
|
||||
span " — select a file or folder", style: "font-size:14px; color:#888; font-weight:normal;"
|
||||
end
|
||||
end
|
||||
|
||||
# Breadcrumbs
|
||||
div class: "fm-breadcrumbs" do
|
||||
parts = current_dir.split("/").reject(&:blank?)
|
||||
picker_params = picker_mode ? { picker: 1, field: picker_field } : {}
|
||||
|
||||
a "softwares/", href: admin_files_path(picker_params)
|
||||
accumulated = ""
|
||||
parts.each do |part|
|
||||
accumulated = [ accumulated, part ].reject(&:blank?).join("/")
|
||||
a "#{part}/", href: admin_files_path(picker_params.merge(dir: accumulated))
|
||||
end
|
||||
end
|
||||
|
||||
# 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
|
||||
input type: "file", name: "file", required: true
|
||||
input type: "submit", value: "Upload", class: "fm-btn"
|
||||
end
|
||||
|
||||
form action: admin_files_mkdir_path, method: "post", class: "fm-inline-form" do |_f|
|
||||
input type: "hidden", name: "authenticity_token", value: form_authenticity_token
|
||||
input type: "hidden", name: "dir", value: current_dir
|
||||
input type: "text", name: "name", placeholder: "New folder name", required: true, class: "fm-text-input"
|
||||
input type: "submit", value: "Create Folder", class: "fm-btn"
|
||||
end
|
||||
end
|
||||
|
||||
# File listing
|
||||
table class: "fm-table" do
|
||||
# Pre-fetch download counts for files in current directory
|
||||
file_entries = entries.select { |e| e[:type] != :directory }
|
||||
dl_counts = if file_entries.any?
|
||||
WarpEngine::Download.where(file_path: file_entries.map { |e| e[:path] })
|
||||
.group(:file_path)
|
||||
.count
|
||||
else
|
||||
{}
|
||||
end
|
||||
|
||||
# File icon helper
|
||||
file_icon = ->(name) do
|
||||
ext = File.extname(name).downcase
|
||||
case ext
|
||||
when ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg" then "🖼️"
|
||||
when ".mp3", ".ogg", ".wav", ".flac", ".aac" then "🔊"
|
||||
when ".mp4", ".avi", ".mkv", ".webm", ".mov" then "🎬"
|
||||
when ".zip", ".gz", ".tar", ".rar", ".7z", ".bz2" then "📦"
|
||||
when ".pdf" then "📕"
|
||||
when ".doc", ".docx", ".odt", ".txt", ".md", ".rtf" then "📄"
|
||||
when ".xls", ".xlsx", ".csv", ".ods" then "📊"
|
||||
when ".html", ".htm", ".css", ".js", ".ts", ".json", ".xml" then "📝"
|
||||
when ".rb", ".py", ".lua", ".c", ".cpp", ".h", ".rs", ".go" then "💻"
|
||||
when ".tic", ".rom", ".bin", ".prg", ".crt", ".d64", ".t64" then "🎮"
|
||||
when ".love" then "🎮"
|
||||
when ".exe", ".dmg", ".appimage", ".msi" then "⚙️"
|
||||
when ".wasm" then "⚙️"
|
||||
else "📄"
|
||||
end
|
||||
end
|
||||
|
||||
thead do
|
||||
tr do
|
||||
th "Name"
|
||||
th "Size", style: "width:100px"
|
||||
th "DL", style: "width:50px"
|
||||
th "Modified", style: "width:160px"
|
||||
th "", style: "width:#{picker_mode ? '160' : '120'}px"
|
||||
end
|
||||
end
|
||||
|
||||
tbody do
|
||||
# Parent directory link
|
||||
if current_dir.present?
|
||||
parent = File.dirname(current_dir)
|
||||
parent = "" if parent == "."
|
||||
picker_params = picker_mode ? { picker: 1, field: picker_field } : {}
|
||||
|
||||
tr do
|
||||
td do
|
||||
span "📁 ", style: "font-size:15px;"
|
||||
a "..", href: admin_files_path(picker_params.merge(dir: parent))
|
||||
end
|
||||
td ""
|
||||
td ""
|
||||
td ""
|
||||
td ""
|
||||
end
|
||||
end
|
||||
|
||||
entries.each do |entry|
|
||||
tr do
|
||||
td do
|
||||
picker_params = picker_mode ? { picker: 1, field: picker_field } : {}
|
||||
if entry[:type] == :directory
|
||||
span "📁 ", style: "font-size:15px;"
|
||||
a entry[:name], href: admin_files_path(picker_params.merge(dir: entry[:path]))
|
||||
else
|
||||
span "#{file_icon.call(entry[:name])} ", style: "font-size:15px;"
|
||||
span entry[:name]
|
||||
end
|
||||
end
|
||||
td do
|
||||
text_node(entry[:size] ? number_to_human_size(entry[:size]) : "")
|
||||
end
|
||||
td do
|
||||
if entry[:type] != :directory
|
||||
count = dl_counts[entry[:path]] || 0
|
||||
span count.to_s, style: count > 0 ? "font-weight:bold;" : "color:#999;"
|
||||
end
|
||||
end
|
||||
td do
|
||||
text_node(entry[:mtime]&.strftime("%Y-%m-%d %H:%M") || "")
|
||||
end
|
||||
td class: "fm-entry-actions" do
|
||||
entry_id = entry[:path].parameterize
|
||||
|
||||
# Download (files only)
|
||||
if entry[:type] != :directory
|
||||
a "⬇️", href: "/file/#{entry[:path]}", class: "fm-icon-btn", title: "Download", download: entry[:name]
|
||||
end
|
||||
|
||||
# Rename
|
||||
a "✏️", href: "#", class: "fm-icon-btn", title: "Rename",
|
||||
onclick: "var n=prompt('New name:','#{j entry[:name]}');if(n){var f=document.getElementById('rename-#{entry_id}');f.querySelector('[name=new_name]').value=n;f.submit();}return false;"
|
||||
|
||||
# Delete
|
||||
a "🗑️", href: "#", class: "fm-icon-btn fm-icon-danger", title: "Delete",
|
||||
onclick: "if(confirm('Delete \\'#{j entry[:name]}\\'?')){document.getElementById('delete-#{entry_id}').submit();}return false;"
|
||||
|
||||
# Stats (files only)
|
||||
if entry[:type] != :directory
|
||||
a "📊", href: admin_downloads_path(q: { file_path_cont: entry[:path] }), class: "fm-icon-btn", title: "Stats"
|
||||
end
|
||||
|
||||
# Picker mode: Select button
|
||||
if picker_mode
|
||||
abs_path = File.join(WarpEngine.config.file_container_path, entry[:path])
|
||||
a "Select", href: "#", class: "fm-btn fm-btn-select",
|
||||
onclick: "var inp=window.parent.document.getElementById('#{j picker_field}');if(inp){inp.value='#{j abs_path}';}var ov=window.parent.document.querySelector('.fm-modal-overlay');if(ov){ov.remove();window.parent.document.body.style.overflow='';}return false;"
|
||||
end
|
||||
|
||||
# Hidden rename form
|
||||
form action: admin_files_rename_path, method: "post", id: "rename-#{entry_id}", style: "display:none" do
|
||||
input type: "hidden", name: "authenticity_token", value: form_authenticity_token
|
||||
input type: "hidden", name: "path", value: entry[:path]
|
||||
input type: "hidden", name: "new_name", value: ""
|
||||
input type: "hidden", name: "dir", value: current_dir
|
||||
end
|
||||
|
||||
# Hidden delete form
|
||||
form action: admin_files_delete_path, method: "post", id: "delete-#{entry_id}", style: "display:none" do
|
||||
input type: "hidden", name: "authenticity_token", value: form_authenticity_token
|
||||
input type: "hidden", name: "_method", value: "delete"
|
||||
input type: "hidden", name: "path", value: entry[:path]
|
||||
input type: "hidden", name: "dir", value: current_dir
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if entries.empty? && current_dir.blank?
|
||||
para "No files found in /softwares", style: "color:#999; text-align:center; padding:40px 0;"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# JSON API for AJAX file picker
|
||||
page_action :list, method: :get do
|
||||
service = WarpEngine::FileManagerService.new
|
||||
dir = params[:dir].to_s.presence || ""
|
||||
begin
|
||||
entries = service.list(dir)
|
||||
render json: { entries: entries, dir: dir }
|
||||
rescue ArgumentError => e
|
||||
render json: { entries: [], dir: dir, error: e.message }
|
||||
end
|
||||
end
|
||||
|
||||
page_action :ajax_upload, method: :post do
|
||||
service = WarpEngine::FileManagerService.new
|
||||
path = service.upload(params[:dir].to_s, params[:file])
|
||||
render json: { ok: true, path: path }
|
||||
rescue => e
|
||||
render json: { ok: false, error: e.message }, status: 422
|
||||
end
|
||||
|
||||
page_action :upload, method: :post do
|
||||
service = WarpEngine::FileManagerService.new
|
||||
service.upload(params[:dir].to_s, params[:file])
|
||||
redirect_to admin_files_path(dir: params[:dir], picker: params[:picker], field: params[:field]), notice: "File uploaded."
|
||||
rescue => e
|
||||
redirect_to admin_files_path(dir: params[:dir], picker: params[:picker], field: params[:field]), alert: "Upload failed: #{e.message}"
|
||||
end
|
||||
|
||||
page_action :mkdir, method: :post do
|
||||
service = WarpEngine::FileManagerService.new
|
||||
service.mkdir(params[:dir].to_s, params[:name].to_s)
|
||||
redirect_to admin_files_path(dir: params[:dir], picker: params[:picker], field: params[:field]), notice: "Folder created."
|
||||
rescue => e
|
||||
redirect_to admin_files_path(dir: params[:dir], picker: params[:picker], field: params[:field]), alert: "Failed: #{e.message}"
|
||||
end
|
||||
|
||||
page_action :rename, method: :post do
|
||||
service = WarpEngine::FileManagerService.new
|
||||
service.rename(params[:path].to_s, params[:new_name].to_s)
|
||||
redirect_to admin_files_path(dir: params[:dir], picker: params[:picker], field: params[:field]), notice: "Renamed."
|
||||
rescue => e
|
||||
redirect_to admin_files_path(dir: params[:dir], picker: params[:picker], field: params[:field]), alert: "Rename failed: #{e.message}"
|
||||
end
|
||||
|
||||
page_action :delete, method: :delete do
|
||||
service = WarpEngine::FileManagerService.new
|
||||
dir = params[:dir].to_s.presence || begin
|
||||
d = File.dirname(params[:path].to_s)
|
||||
d == "." ? "" : d
|
||||
end
|
||||
service.delete(params[:path].to_s)
|
||||
redirect_to admin_files_path(dir: dir, picker: params[:picker], field: params[:field]), notice: "Deleted."
|
||||
rescue => e
|
||||
redirect_to admin_files_path(dir: params[:dir], picker: params[:picker], field: params[:field]), alert: "Delete failed: #{e.message}"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,79 @@
|
||||
ActiveAdmin.register WarpEngine::Image, as: "Image" do
|
||||
permit_params :file_upload
|
||||
|
||||
menu priority: 5, label: "🖼️ Images"
|
||||
|
||||
# SoftwareImage a natív használó; a hoston regisztrált image_owners
|
||||
# (WarpEngine.config) további használókat adhat hozzá (pl. TTG Member).
|
||||
used_ids = -> {
|
||||
WarpEngine::SoftwareImage.unscope(:order).distinct.pluck(:image_id) +
|
||||
WarpEngine.config.image_owners.flat_map { |o| o[:image_ids].call }
|
||||
}
|
||||
|
||||
scope :all, default: true
|
||||
scope("In use") { |scope| scope.where(id: used_ids.call) }
|
||||
scope("Orphan") { |scope| scope.where.not(id: used_ids.call) }
|
||||
|
||||
batch_action :delete_orphans, confirm: "Delete all selected orphan images and their files?" do |ids|
|
||||
orphan_ids = ids.map(&:to_i) - used_ids.call
|
||||
WarpEngine::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
|
||||
column :original_filename
|
||||
column :content_type
|
||||
column(:preview) do |img|
|
||||
if File.exist?(img.file_path)
|
||||
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.concat(WarpEngine.config.image_owners.filter_map { |o| o[:usage_label].call(img) })
|
||||
uses.any? ? uses.join(", ") : status_tag("orphan", class: "warning")
|
||||
end
|
||||
column :created_at
|
||||
actions
|
||||
end
|
||||
|
||||
filter :original_filename
|
||||
filter :content_type
|
||||
|
||||
show do
|
||||
attributes_table do
|
||||
row :id
|
||||
row :original_filename
|
||||
row :filename
|
||||
row :content_type
|
||||
row(:preview) do |img|
|
||||
if File.exist?(img.file_path)
|
||||
image_tag("/api/image/#{img.id}", style: "max-height:300px;max-width:100%;object-fit:contain;")
|
||||
else
|
||||
"File not found on disk"
|
||||
end
|
||||
end
|
||||
row :created_at
|
||||
row :updated_at
|
||||
end
|
||||
end
|
||||
|
||||
form html: { multipart: true } do |f|
|
||||
f.inputs do
|
||||
f.input :file_upload, as: :file, label: "Image File"
|
||||
end
|
||||
f.actions
|
||||
end
|
||||
|
||||
controller do
|
||||
def scoped_collection
|
||||
super.includes(:software_images)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,52 @@
|
||||
ActiveAdmin.register WarpEngine::PlatformLink, as: "Platform Link" do
|
||||
permit_params :name, :url, :platform, :position
|
||||
|
||||
menu priority: 5, label: "🔗 Platform Links"
|
||||
|
||||
config.sort_order = "platform_asc"
|
||||
|
||||
scope :all, default: true
|
||||
scope("TIC-80") { |s| s.where(platform: "tic80") }
|
||||
scope("Ebitengine") { |s| s.where(platform: "ebitengine") }
|
||||
scope("LOVE") { |s| s.where(platform: "love") }
|
||||
scope("C64") { |s| s.where(platform: "c64") }
|
||||
scope("Godot") { |s| s.where(platform: "godot") }
|
||||
scope("Bevy") { |s| s.where(platform: "bevy") }
|
||||
scope("Phaser") { |s| s.where(platform: "phaser") }
|
||||
|
||||
index do
|
||||
selectable_column
|
||||
id_column
|
||||
column :platform
|
||||
column :name
|
||||
column(:url) { |pl| link_to pl.url, pl.url, target: "_blank" }
|
||||
column :position
|
||||
column :created_at
|
||||
actions
|
||||
end
|
||||
|
||||
filter :platform, as: :select, collection: %w[tic80 ebitengine love c64 godot bevy phaser]
|
||||
filter :name
|
||||
|
||||
form do |f|
|
||||
f.inputs do
|
||||
f.input :platform, as: :select, collection: %w[tic80 ebitengine love c64 godot bevy phaser]
|
||||
f.input :name
|
||||
f.input :url
|
||||
f.input :position, as: :number, input_html: { min: 0 }
|
||||
end
|
||||
f.actions
|
||||
end
|
||||
|
||||
show do
|
||||
attributes_table do
|
||||
row :id
|
||||
row :platform
|
||||
row :name
|
||||
row(:url) { |pl| link_to pl.url, pl.url, target: "_blank" }
|
||||
row :position
|
||||
row :created_at
|
||||
row :updated_at
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,62 @@
|
||||
ActiveAdmin.register WarpEngine::Release, as: "Release" do
|
||||
actions :index, :show
|
||||
|
||||
menu false
|
||||
|
||||
belongs_to :software, optional: true
|
||||
|
||||
index do
|
||||
selectable_column
|
||||
id_column
|
||||
column(:software) { |r| link_to r.software.title, admin_software_path(r.software) }
|
||||
column :version
|
||||
column(:assets) { |r| r.release_assets.map(&:kind).sort.join(", ") }
|
||||
column :created_at
|
||||
actions only: [ :show ]
|
||||
end
|
||||
|
||||
filter :version
|
||||
|
||||
show do
|
||||
attributes_table do
|
||||
row :id
|
||||
row(:software) { |r| link_to r.software.title, admin_software_path(r.software) }
|
||||
row :version
|
||||
row :created_at
|
||||
row :updated_at
|
||||
end
|
||||
|
||||
panel "Assets" do
|
||||
if resource.release_assets.any?
|
||||
table_for resource.release_assets.order(:kind) do
|
||||
column :kind
|
||||
column(:path) { |a| span a.path, style: "font-family:monospace;font-size:12px;" }
|
||||
end
|
||||
else
|
||||
para "No assets for this release.", style: "color:#999;font-style:italic;"
|
||||
end
|
||||
end
|
||||
|
||||
panel "Downloads by File" do
|
||||
file_stats = WarpEngine::Download.where(release_id: resource.id)
|
||||
.group(:file_path)
|
||||
.select("file_path, COUNT(*) as dl_count, MAX(created_at) as last_dl")
|
||||
.order("dl_count DESC")
|
||||
|
||||
if file_stats.any?
|
||||
div style: "margin-bottom:8px;" do
|
||||
strong "Total downloads: "
|
||||
b file_stats.sum(&:dl_count).to_s
|
||||
end
|
||||
|
||||
table_for file_stats do
|
||||
column("File") { |row| span row.file_path, style: "font-family:monospace;font-size:12px;" }
|
||||
column("Downloads") { |row| row.dl_count }
|
||||
column("Last download") { |row| row.last_dl&.strftime("%Y-%m-%d %H:%M") }
|
||||
end
|
||||
else
|
||||
para "No downloads for this release.", style: "color:#999;font-style:italic;"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,150 @@
|
||||
ActiveAdmin.register WarpEngine::Software, as: "Software" do
|
||||
permit_params :name, :title, :author, :desc, :story,
|
||||
:license, :platform, :status, :highlighted,
|
||||
software_images_attributes: [ :id, :image_id, :is_default, :position, :file_upload, :_destroy ],
|
||||
external_links_attributes: [ :id, :label, :url, :_destroy ],
|
||||
releases_attributes: [ :id, :version, :_destroy,
|
||||
{ release_assets_attributes: [ :id, :kind, :path, :_destroy ] } ]
|
||||
|
||||
menu priority: 2, label: "🎮 Softwares"
|
||||
|
||||
actions :all, except: [ :edit ]
|
||||
|
||||
config.sort_order = "title_asc"
|
||||
|
||||
index do
|
||||
selectable_column
|
||||
id_column
|
||||
column :name do |sw|
|
||||
link_to sw.name, admin_software_path(sw)
|
||||
end
|
||||
column :title
|
||||
column :platform
|
||||
column :status do |sw|
|
||||
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)
|
||||
image_tag "/api/image/#{si.image_id}", style: "max-height:40px;max-width:80px;object-fit:contain;"
|
||||
end
|
||||
end
|
||||
column :created_at
|
||||
actions
|
||||
end
|
||||
|
||||
filter :name
|
||||
filter :title
|
||||
filter :author
|
||||
filter :platform, as: :select, collection: %w[tic80 ebitengine love c64 godot bevy phaser]
|
||||
filter :status, as: :select, collection: %w[development demo released archived]
|
||||
filter :highlighted
|
||||
|
||||
sidebar "Quick Links", only: :show, priority: 0 do
|
||||
site_url = ENV.fetch("SITE_URL", "https://teletypegames.org")
|
||||
catalog_url = "#{site_url}/catalog/#{resource.name}"
|
||||
|
||||
div style: "margin-bottom:8px;" do
|
||||
a href: catalog_url, target: "_blank", style: "display:inline-flex;align-items:center;gap:6px;font-weight:bold;color:#5850ec;" do
|
||||
span "🌐", style: "font-size:16px;"
|
||||
text_node "View on site"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
sidebar "Download Statistics", only: :show do
|
||||
sw = resource
|
||||
total = WarpEngine::Download.joins(:release).where(releases: { software_id: sw.id }).count
|
||||
|
||||
div style: "margin-bottom:12px;" do
|
||||
strong "Total downloads: "
|
||||
span total.to_s, style: "font-size:18px;font-weight:bold;"
|
||||
end
|
||||
|
||||
sw.releases.each do |rel|
|
||||
file_stats = WarpEngine::Download.where(release_id: rel.id)
|
||||
.group(:file_path)
|
||||
.select("file_path, COUNT(*) as dl_count, MAX(created_at) as last_dl")
|
||||
.order("dl_count DESC")
|
||||
rel_total = file_stats.sum(&:dl_count)
|
||||
|
||||
div style: "margin-bottom:16px;" do
|
||||
h4 do
|
||||
link_to "v#{rel.version}", admin_software_release_path(sw, rel)
|
||||
span " (#{rel_total})", style: "color:#666;"
|
||||
end
|
||||
if file_stats.any?
|
||||
table_for file_stats do
|
||||
column("File") { |row| span row.file_path.split("/").last, style: "font-family:monospace;font-size:11px;" }
|
||||
column("DL") { |row| row.dl_count }
|
||||
column("Last") { |row| row.last_dl&.strftime("%m-%d") }
|
||||
end
|
||||
else
|
||||
para "No downloads", style: "color:#999;font-style:italic;"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
show title: proc { |sw| sw.title } do
|
||||
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
|
||||
f.input :author
|
||||
f.input :platform, as: :select, collection: %w[tic80 ebitengine love c64 godot bevy phaser]
|
||||
f.input :status, as: :select, collection: %w[development demo released archived]
|
||||
f.input :highlighted
|
||||
f.input :license
|
||||
f.input :desc, as: :text, input_html: { rows: 4 }
|
||||
f.input :story, as: :text, input_html: { rows: 8 }
|
||||
end
|
||||
|
||||
f.inputs "Images" do
|
||||
f.has_many :software_images, allow_destroy: true, new_record: true do |si|
|
||||
img_hint = if si.object.image_id.present?
|
||||
si.template.image_tag("/api/image/#{si.object.image_id}", style: "max-height:80px;max-width:160px;object-fit:contain;margin-top:6px;").html_safe
|
||||
end
|
||||
si.input :image_id, as: :select, label: "Image",
|
||||
collection: WarpEngine::Image.order(:original_filename).map { |img| [ img.original_filename, img.id ] },
|
||||
include_blank: "— select —",
|
||||
hint: img_hint
|
||||
si.input :file_upload, as: :file, label: "Upload new image"
|
||||
si.input :is_default, as: :boolean
|
||||
si.input :position, as: :number, input_html: { min: 0 }
|
||||
end
|
||||
end
|
||||
|
||||
f.inputs "External Links" do
|
||||
f.has_many :external_links, allow_destroy: true, new_record: true do |el|
|
||||
el.input :label
|
||||
el.input :url
|
||||
end
|
||||
end
|
||||
|
||||
f.inputs "Releases" do
|
||||
f.has_many :releases, allow_destroy: true, new_record: true do |r|
|
||||
r.input :version
|
||||
r.has_many :release_assets, allow_destroy: true, new_record: true do |ra|
|
||||
ra.input :kind, as: :select, collection: WarpEngine::ReleaseAsset::KINDS, include_blank: false
|
||||
ra.input :path
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
f.actions
|
||||
end
|
||||
end
|
||||
|
||||
controller do
|
||||
def scoped_collection
|
||||
super.includes({ releases: :release_assets }, software_images: :image)
|
||||
end
|
||||
|
||||
def find_resource
|
||||
scoped_collection.find(params[:id])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,22 @@
|
||||
module WarpEngine
|
||||
class Engine < ::Rails::Engine
|
||||
isolate_namespace WarpEngine
|
||||
|
||||
# Az app/admin fájlok ActiveAdmin DSL-t tartalmaznak, nem definiálnak a
|
||||
# fájlnévnek megfelelő konstansokat — a Zeitwerk (eager load) nem nyúlhat hozzájuk.
|
||||
initializer "warp_engine.ignore_admin_dir", before: :setup_main_autoloader do
|
||||
Rails.autoloaders.main.ignore(Engine.root.join("app/admin"))
|
||||
end
|
||||
|
||||
# Az admin erőforrásokat a HOST ActiveAdmin példánya tölti be; az engine csak
|
||||
# regisztrálja a saját app/admin könyvtárát. Auth/téma/route-ok a host dolga.
|
||||
initializer "warp_engine.active_admin" do |app|
|
||||
if defined?(ActiveAdmin)
|
||||
admin_dir = Engine.root.join("app/admin").to_s
|
||||
ActiveAdmin.application.load_paths << admin_dir
|
||||
# dev módban az engine admin-fájljainak szerkesztése is triggerel reloadot
|
||||
app.config.watchable_dirs[admin_dir] = [ :rb ]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user