diff --git a/apps/api/app/admin/images.rb b/apps/api/app/admin/images.rb new file mode 100644 index 0000000..39742a3 --- /dev/null +++ b/apps/api/app/admin/images.rb @@ -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 diff --git a/apps/api/app/admin/members.rb b/apps/api/app/admin/members.rb index 41e3b48..d25a7f6 100644 --- a/apps/api/app/admin/members.rb +++ b/apps/api/app/admin/members.rb @@ -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 diff --git a/apps/api/app/admin/softwares.rb b/apps/api/app/admin/softwares.rb index 032dacf..39ad895 100644 --- a/apps/api/app/admin/softwares.rb +++ b/apps/api/app/admin/softwares.rb @@ -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 diff --git a/apps/api/app/controllers/api/images_controller.rb b/apps/api/app/controllers/api/images_controller.rb new file mode 100644 index 0000000..353cc18 --- /dev/null +++ b/apps/api/app/controllers/api/images_controller.rb @@ -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 diff --git a/apps/api/app/controllers/api/members_controller.rb b/apps/api/app/controllers/api/members_controller.rb index 297eca5..ba9720b 100644 --- a/apps/api/app/controllers/api/members_controller.rb +++ b/apps/api/app/controllers/api/members_controller.rb @@ -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 diff --git a/apps/api/app/models/image.rb b/apps/api/app/models/image.rb new file mode 100644 index 0000000..587e1ad --- /dev/null +++ b/apps/api/app/models/image.rb @@ -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 diff --git a/apps/api/app/models/member.rb b/apps/api/app/models/member.rb index c8b4770..5deef91 100644 --- a/apps/api/app/models/member.rb +++ b/apps/api/app/models/member.rb @@ -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 diff --git a/apps/api/app/models/software.rb b/apps/api/app/models/software.rb index bfe9a82..4b609ed 100644 --- a/apps/api/app/models/software.rb +++ b/apps/api/app/models/software.rb @@ -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 diff --git a/apps/api/app/services/software_highlighted_service.rb b/apps/api/app/services/software_highlighted_service.rb index 2165aac..a87a184 100644 --- a/apps/api/app/services/software_highlighted_service.rb +++ b/apps/api/app/services/software_highlighted_service.rb @@ -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 diff --git a/apps/api/app/services/software_list_service.rb b/apps/api/app/services/software_list_service.rb index 6eb499d..34cd875 100644 --- a/apps/api/app/services/software_list_service.rb +++ b/apps/api/app/services/software_list_service.rb @@ -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 diff --git a/apps/api/app/services/software_serializer.rb b/apps/api/app/services/software_serializer.rb index 8121df3..32bda62 100644 --- a/apps/api/app/services/software_serializer.rb +++ b/apps/api/app/services/software_serializer.rb @@ -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 diff --git a/apps/api/config/routes.rb b/apps/api/config/routes.rb index 9b2ab2d..d3fab11 100644 --- a/apps/api/config/routes.rb +++ b/apps/api/config/routes.rb @@ -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" diff --git a/apps/api/db/migrate/20260505000007_create_images.rb b/apps/api/db/migrate/20260505000007_create_images.rb new file mode 100644 index 0000000..5632ec0 --- /dev/null +++ b/apps/api/db/migrate/20260505000007_create_images.rb @@ -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 diff --git a/apps/api/db/migrate/20260505000008_add_image_id_to_softwares.rb b/apps/api/db/migrate/20260505000008_add_image_id_to_softwares.rb new file mode 100644 index 0000000..9fcae70 --- /dev/null +++ b/apps/api/db/migrate/20260505000008_add_image_id_to_softwares.rb @@ -0,0 +1,5 @@ +class AddImageIdToSoftwares < ActiveRecord::Migration[7.1] + def change + add_reference :softwares, :image, null: true, foreign_key: true + end +end diff --git a/apps/api/db/migrate/20260505000009_add_image_id_to_members.rb b/apps/api/db/migrate/20260505000009_add_image_id_to_members.rb new file mode 100644 index 0000000..3c4df0b --- /dev/null +++ b/apps/api/db/migrate/20260505000009_add_image_id_to_members.rb @@ -0,0 +1,5 @@ +class AddImageIdToMembers < ActiveRecord::Migration[7.1] + def change + add_reference :members, :image, null: true, foreign_key: true + end +end diff --git a/apps/frontend/src/lib/interfaces/member.interface.ts b/apps/frontend/src/lib/interfaces/member.interface.ts index e47454c..1f6bc79 100644 --- a/apps/frontend/src/lib/interfaces/member.interface.ts +++ b/apps/frontend/src/lib/interfaces/member.interface.ts @@ -3,4 +3,5 @@ export interface Member { real_nick: string motto: string avatar_filename: string + image_url?: string | null } diff --git a/apps/frontend/src/lib/interfaces/software.interface.ts b/apps/frontend/src/lib/interfaces/software.interface.ts index 43b56b3..9f36015 100644 --- a/apps/frontend/src/lib/interfaces/software.interface.ts +++ b/apps/frontend/src/lib/interfaces/software.interface.ts @@ -17,6 +17,7 @@ export interface Software { license?: string story?: string externalLinks?: { label: string; url: string }[] + imageUrl?: string | null } export interface SoftwareEntry { diff --git a/apps/frontend/src/page/catalog/CatalogIndexPage.vue b/apps/frontend/src/page/catalog/CatalogIndexPage.vue index 0746ef4..6968a2d 100644 --- a/apps/frontend/src/page/catalog/CatalogIndexPage.vue +++ b/apps/frontend/src/page/catalog/CatalogIndexPage.vue @@ -23,6 +23,9 @@
+
+ +
@@ -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; +} diff --git a/apps/frontend/src/page/catalog/CatalogShowPage.vue b/apps/frontend/src/page/catalog/CatalogShowPage.vue index 4870359..656d349 100644 --- a/apps/frontend/src/page/catalog/CatalogShowPage.vue +++ b/apps/frontend/src/page/catalog/CatalogShowPage.vue @@ -27,6 +27,9 @@
+
+ +

Project Info

diff --git a/apps/frontend/src/page/home/HomePage.vue b/apps/frontend/src/page/home/HomePage.vue index ccadaa1..5a27616 100644 --- a/apps/frontend/src/page/home/HomePage.vue +++ b/apps/frontend/src/page/home/HomePage.vue @@ -41,6 +41,9 @@