image admin
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
ActiveAdmin.register Image do
|
||||
permit_params :file_upload
|
||||
|
||||
menu priority: 5
|
||||
|
||||
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 :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
|
||||
end
|
||||
@@ -1,14 +1,24 @@
|
||||
ActiveAdmin.register Member do
|
||||
permit_params :nick, :real_nick, :motto, :avatar_filename
|
||||
permit_params :nick, :real_nick, :motto, :avatar_filename, :image_id
|
||||
|
||||
menu priority: 4
|
||||
|
||||
index do
|
||||
selectable_column
|
||||
id_column
|
||||
column :nick
|
||||
column :nick do |m|
|
||||
link_to m.nick, edit_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;"
|
||||
end
|
||||
end
|
||||
column :created_at
|
||||
actions
|
||||
end
|
||||
@@ -16,15 +26,19 @@ ActiveAdmin.register Member do
|
||||
filter :nick
|
||||
filter :real_nick
|
||||
|
||||
show do
|
||||
attributes_table do
|
||||
row :id
|
||||
row :nick
|
||||
row :real_nick
|
||||
row :motto
|
||||
row :avatar_filename
|
||||
row :created_at
|
||||
row :updated_at
|
||||
show title: proc { |m| m.nick } do
|
||||
active_admin_form_for [:admin, resource], url: admin_member_path(resource), html: { method: :put } do |f|
|
||||
f.inputs "Edit Member" 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, as: :select,
|
||||
collection: Image.order(:original_filename).map { |img| [img.original_filename, img.id] },
|
||||
include_blank: "— no image (use avatar_filename) —"
|
||||
end
|
||||
f.actions
|
||||
end
|
||||
end
|
||||
|
||||
@@ -33,7 +47,11 @@ ActiveAdmin.register Member do
|
||||
f.input :nick
|
||||
f.input :real_nick
|
||||
f.input :motto
|
||||
f.input :avatar_filename
|
||||
f.input :avatar_filename,
|
||||
hint: "Fallback image filename (e.g. mr.zero.png) — only used when no image is selected above."
|
||||
f.input :image, as: :select,
|
||||
collection: Image.order(:original_filename).map { |img| [img.original_filename, img.id] },
|
||||
include_blank: "— no image (use avatar_filename) —"
|
||||
end
|
||||
f.actions
|
||||
end
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
ActiveAdmin.register Software do
|
||||
permit_params :name, :title, :author, :desc, :story,
|
||||
:license, :platform, :status, :highlighted,
|
||||
:license, :platform, :status, :highlighted, :image_id,
|
||||
external_links_attributes: [:id, :label, :url, :_destroy],
|
||||
releases_attributes: [:id, :_destroy]
|
||||
|
||||
@@ -9,13 +9,20 @@ ActiveAdmin.register Software do
|
||||
index do
|
||||
selectable_column
|
||||
id_column
|
||||
column :name
|
||||
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(:image) do |sw|
|
||||
if sw.image && File.exist?(sw.image.file_path)
|
||||
image_tag "/api/image/#{sw.image.id}", style: "max-height:40px;max-width:80px;object-fit:contain;"
|
||||
end
|
||||
end
|
||||
column :created_at
|
||||
actions
|
||||
end
|
||||
@@ -27,37 +34,31 @@ ActiveAdmin.register Software do
|
||||
filter :status, as: :select, collection: %w[development demo released archived]
|
||||
filter :highlighted
|
||||
|
||||
show do
|
||||
attributes_table do
|
||||
row :id
|
||||
row :name
|
||||
row :title
|
||||
row :author
|
||||
row :platform
|
||||
row :status
|
||||
row :highlighted
|
||||
row :license
|
||||
row(:desc) { |sw| simple_format sw.desc }
|
||||
row(:story) { |sw| simple_format sw.story }
|
||||
row :created_at
|
||||
row :updated_at
|
||||
end
|
||||
show title: proc { |sw| sw.title } do
|
||||
active_admin_form_for [:admin, resource], url: admin_software_path(resource), html: { method: :put } 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]
|
||||
f.input :status, as: :select, collection: %w[development demo released archived]
|
||||
f.input :highlighted
|
||||
f.input :license
|
||||
f.input :image, as: :select,
|
||||
collection: Image.order(:original_filename).map { |img| [img.original_filename, img.id] },
|
||||
include_blank: "— no image —"
|
||||
f.input :desc, as: :text, input_html: { rows: 4 }
|
||||
f.input :story, as: :text, input_html: { rows: 8 }
|
||||
end
|
||||
|
||||
panel "External Links" do
|
||||
table_for resource.external_links do
|
||||
column :label
|
||||
column(:url) { |el| link_to el.url, el.url, target: "_blank" }
|
||||
column :created_at
|
||||
column do |el|
|
||||
link_to "Edit", edit_admin_software_external_link_path(resource, el)
|
||||
concat " "
|
||||
link_to "Delete", admin_software_external_link_path(resource, el),
|
||||
method: :delete, data: { confirm: "Are you sure?" }
|
||||
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
|
||||
br
|
||||
link_to "Add External Link",
|
||||
new_admin_software_external_link_path(resource)
|
||||
|
||||
f.actions
|
||||
end
|
||||
|
||||
panel "Releases" do
|
||||
@@ -81,6 +82,9 @@ ActiveAdmin.register Software do
|
||||
f.input :status, as: :select, collection: %w[development demo released archived]
|
||||
f.input :highlighted
|
||||
f.input :license
|
||||
f.input :image, as: :select,
|
||||
collection: Image.order(:original_filename).map { |img| [img.original_filename, img.id] },
|
||||
include_blank: "— no image —"
|
||||
f.input :desc, as: :text, input_html: { rows: 4 }
|
||||
f.input :story, as: :text, input_html: { rows: 8 }
|
||||
end
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
class Api::ImagesController < ApplicationController
|
||||
skip_before_action :verify_authenticity_token
|
||||
|
||||
def show
|
||||
image = Image.find(params[:id])
|
||||
path = image.file_path
|
||||
|
||||
return render plain: "Not Found", status: :not_found unless File.exist?(path)
|
||||
|
||||
send_file path, type: image.content_type, disposition: "inline"
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render plain: "Not Found", status: :not_found
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,13 @@
|
||||
class Api::MembersController < ApiController
|
||||
def index
|
||||
members = Member.order(:id).map do |m|
|
||||
{ nick: m.nick, real_nick: m.real_nick, motto: m.motto, avatar_filename: m.avatar_filename }
|
||||
members = Member.includes(:image).order(:id).map do |m|
|
||||
{
|
||||
nick: m.nick,
|
||||
real_nick: m.real_nick,
|
||||
motto: m.motto,
|
||||
avatar_filename: m.avatar_filename,
|
||||
image_url: m.image_id ? "/api/image/#{m.image_id}" : nil
|
||||
}
|
||||
end
|
||||
render json: members
|
||||
end
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
class Image < ApplicationRecord
|
||||
UPLOAD_PATH = ENV.fetch("IMAGE_CONTAINER_PATH", "/images")
|
||||
|
||||
attr_accessor :file_upload
|
||||
|
||||
before_save :process_upload, if: -> { file_upload.present? }
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[content_type created_at filename id original_filename updated_at]
|
||||
end
|
||||
|
||||
def file_path
|
||||
File.join(UPLOAD_PATH, filename.to_s)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_upload
|
||||
FileUtils.mkdir_p(UPLOAD_PATH)
|
||||
self.original_filename = file_upload.original_filename
|
||||
self.content_type = file_upload.content_type.presence || "application/octet-stream"
|
||||
ext = File.extname(file_upload.original_filename)
|
||||
self.filename = "#{SecureRandom.uuid}#{ext}"
|
||||
IO.copy_stream(file_upload.to_io, file_path)
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,11 @@
|
||||
class Member < ApplicationRecord
|
||||
belongs_to :image, optional: true
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[avatar_filename created_at id motto nick real_nick updated_at]
|
||||
%w[avatar_filename created_at id image_id motto nick real_nick updated_at]
|
||||
end
|
||||
|
||||
def self.ransackable_associations(auth_object = nil)
|
||||
%w[image]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
class Software < ApplicationRecord
|
||||
self.table_name = "softwares"
|
||||
|
||||
belongs_to :image, optional: true
|
||||
|
||||
has_many :releases, foreign_key: :software_id
|
||||
has_many :external_links, foreign_key: :software_id
|
||||
|
||||
@@ -9,10 +11,10 @@ class Software < ApplicationRecord
|
||||
default_scope { where(deleted_at: nil) }
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[author created_at desc highlighted id license name platform site status story title updated_at]
|
||||
%w[author created_at desc highlighted id image_id license name platform site status story title updated_at]
|
||||
end
|
||||
|
||||
def self.ransackable_associations(auth_object = nil)
|
||||
%w[releases external_links]
|
||||
%w[releases external_links image]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
class SoftwareHighlightedService
|
||||
def call
|
||||
software = Software.includes(:external_links)
|
||||
software = Software.includes(:external_links, :image)
|
||||
.where(highlighted: true)
|
||||
.order(id: :desc)
|
||||
.first
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
class SoftwareListService
|
||||
def call
|
||||
softwares = Software.includes(:releases, :external_links).all
|
||||
softwares = Software.includes(:releases, :external_links, :image).all
|
||||
items = softwares.map do |sw|
|
||||
SoftwareSerializer.show_data(sw, sw.releases.to_a)
|
||||
end
|
||||
|
||||
@@ -32,7 +32,8 @@ class SoftwareSerializer
|
||||
"platform" => sw.platform,
|
||||
"status" => sw.status,
|
||||
"highlighted" => sw.highlighted ? true : false,
|
||||
"externalLinks" => sw.external_links.map { |el| serialize_external_link(el) }
|
||||
"externalLinks" => sw.external_links.map { |el| serialize_external_link(el) },
|
||||
"imageUrl" => sw.image_id ? "/api/image/#{sw.image_id}" : nil
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ Rails.application.routes.draw do
|
||||
get "software/highlighted", to: "software_highlighted#index"
|
||||
get "events", to: "events#index"
|
||||
get "members", to: "members#index"
|
||||
get "image/:id", to: "images#show"
|
||||
end
|
||||
|
||||
get "update", to: "update#update"
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
class CreateImages < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :images do |t|
|
||||
t.string :filename, null: false
|
||||
t.string :original_filename, null: false
|
||||
t.string :content_type, null: false, default: "application/octet-stream"
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddImageIdToSoftwares < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_reference :softwares, :image, null: true, foreign_key: true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddImageIdToMembers < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_reference :members, :image, null: true, foreign_key: true
|
||||
end
|
||||
end
|
||||
@@ -3,4 +3,5 @@ export interface Member {
|
||||
real_nick: string
|
||||
motto: string
|
||||
avatar_filename: string
|
||||
image_url?: string | null
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface Software {
|
||||
license?: string
|
||||
story?: string
|
||||
externalLinks?: { label: string; url: string }[]
|
||||
imageUrl?: string | null
|
||||
}
|
||||
|
||||
export interface SoftwareEntry {
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
<div v-show="activeFilter === 'all' || software.status === activeFilter" class="software-card group">
|
||||
<div class="software-card-content">
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div v-if="software.imageUrl" class="flex-shrink-0">
|
||||
<img :src="software.imageUrl" :alt="software.title" class="software-thumb" />
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<RouterLink :to="`/catalog/${software.name}`" class="hover:text-purple-600 transition-colors">
|
||||
@@ -116,4 +119,7 @@ onMounted(async () => {
|
||||
}
|
||||
.btn-play-sm { @apply btn-base-sm bg-purple-600 hover:bg-purple-700 text-white shadow-lg shadow-purple-600/20; }
|
||||
.btn-info-sm { @apply btn-base-sm bg-gray-100 hover:bg-gray-200 text-gray-700; }
|
||||
.software-thumb {
|
||||
@apply w-24 h-24 object-cover rounded-xl border border-gray-100 shadow-sm;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -27,6 +27,9 @@
|
||||
<!-- Sidebar Info -->
|
||||
<div class="lg:col-span-1 space-y-6">
|
||||
<div class="bg-white rounded-3xl shadow-xl p-8 border border-gray-100">
|
||||
<div v-if="software.imageUrl" class="mb-6">
|
||||
<img :src="software.imageUrl" :alt="software.title" class="w-full rounded-2xl object-cover shadow-sm" />
|
||||
</div>
|
||||
<h2 class="text-xl font-bold text-gray-900 mb-6 flex items-center gap-2">
|
||||
<span class="text-purple-600">ℹ</span> Project Info
|
||||
</h2>
|
||||
|
||||
@@ -41,6 +41,9 @@
|
||||
<!-- Featured Software Section -->
|
||||
<section v-if="highlightedSoftware" class="mb-16">
|
||||
<div class="featured-card group">
|
||||
<div v-if="highlightedSoftware.software.imageUrl" class="featured-image-panel">
|
||||
<img :src="highlightedSoftware.software.imageUrl" :alt="highlightedSoftware.software.title" class="featured-image" />
|
||||
</div>
|
||||
<div class="featured-content">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="featured-badge">Featured Game</span>
|
||||
@@ -319,6 +322,12 @@ onUnmounted(() => {
|
||||
.featured-btn-secondary {
|
||||
@apply px-8 py-4 bg-indigo-500/10 text-white border border-indigo-400/30 font-bold rounded-xl transition-all hover:bg-indigo-500/20;
|
||||
}
|
||||
.featured-image-panel {
|
||||
@apply flex-shrink-0 w-full md:w-64 lg:w-80 overflow-hidden;
|
||||
}
|
||||
.featured-image {
|
||||
@apply w-full h-full object-cover opacity-80 group-hover:opacity-100 transition-opacity;
|
||||
}
|
||||
.featured-version-badge {
|
||||
@apply px-2 py-0.5 rounded text-[10px] font-bold bg-white/10 text-white/70 border border-white/20;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<div class="team-grid">
|
||||
<div v-for="member in members" :key="member.nick" class="team-card">
|
||||
<div class="team-card-image-container">
|
||||
<img :src="avatarUrl(member.avatar_filename)" :alt="member.nick" class="team-card-image" />
|
||||
<img :src="resolvedAvatarUrl(member)" :alt="member.nick" class="team-card-image" />
|
||||
</div>
|
||||
<div class="team-card-content">
|
||||
<h2 class="team-card-title">{{ member.nick }}: {{ member.real_nick }}</h2>
|
||||
@@ -30,8 +30,9 @@ import type { Member } from '../../lib/interfaces/member.interface'
|
||||
|
||||
const members = ref<Member[]>([])
|
||||
|
||||
function avatarUrl(filename: string): string {
|
||||
return new URL(`../../assets/team/${filename}`, import.meta.url).href
|
||||
function resolvedAvatarUrl(member: Member): string {
|
||||
if (member.image_url) return member.image_url
|
||||
return new URL(`../../assets/team/${member.avatar_filename}`, import.meta.url).href
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
@@ -157,6 +157,7 @@ services:
|
||||
- DB_NAME=softwares
|
||||
- UPDATE_SECRET=${UPDATE_SECRET}
|
||||
- FILE_CONTAINER_PATH=/softwares
|
||||
- IMAGE_CONTAINER_PATH=/images
|
||||
- SECRET_KEY_BASE=${SECRET_KEY_BASE:-changeme_in_production_use_long_random_string}
|
||||
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@teletype.hu}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-password123}
|
||||
@@ -165,6 +166,7 @@ services:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./data/softwares:/softwares
|
||||
- ./data/images:/images
|
||||
- ./apps/api:/app
|
||||
- api_bundle:/usr/local/bundle
|
||||
labels:
|
||||
|
||||
Reference in New Issue
Block a user