diff --git a/apps/api/app/admin/softwares.rb b/apps/api/app/admin/softwares.rb index 606946c..5bc6b2f 100644 --- a/apps/api/app/admin/softwares.rb +++ b/apps/api/app/admin/softwares.rb @@ -1,6 +1,7 @@ ActiveAdmin.register Software do permit_params :name, :title, :author, :desc, :story, - :license, :platform, :status, :highlighted, :image_id, + :license, :platform, :status, :highlighted, + software_images_attributes: [ :id, :image_id, :is_default, :position, :_destroy ], external_links_attributes: [ :id, :label, :url, :_destroy ], releases_attributes: [ :id, :version, :html_folder_path, :cartridge_path, :source_path, :docs_folder_path, :web_playable, :_destroy ] @@ -19,8 +20,9 @@ ActiveAdmin.register Software do 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;" + 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 @@ -44,13 +46,19 @@ ActiveAdmin.register Software do f.input :status, as: :select, collection: %w[development demo released archived] f.input :highlighted f.input :license - f.input :image_id, as: :select, label: "Image", - 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 + f.inputs "Images" do + f.has_many :software_images, allow_destroy: true, new_record: true do |si| + si.input :image_id, as: :select, label: "Image", + collection: Image.order(:original_filename).map { |img| [img.original_filename, img.id] } + 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 @@ -73,6 +81,12 @@ ActiveAdmin.register Software do end end + controller do + def scoped_collection + super.includes(:software_images) + end + end + form do |f| f.inputs "Details" do f.input :name @@ -82,13 +96,19 @@ ActiveAdmin.register Software do f.input :status, as: :select, collection: %w[development demo released archived] f.input :highlighted f.input :license - f.input :image_id, as: :select, label: "Image", - 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 + f.inputs "Images" do + f.has_many :software_images, allow_destroy: true, new_record: true do |si| + si.input :image_id, as: :select, label: "Image", + collection: Image.order(:original_filename).map { |img| [img.original_filename, img.id] } + 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 diff --git a/apps/api/app/models/image.rb b/apps/api/app/models/image.rb index 587e1ad..1bdaa46 100644 --- a/apps/api/app/models/image.rb +++ b/apps/api/app/models/image.rb @@ -1,6 +1,8 @@ class Image < ApplicationRecord UPLOAD_PATH = ENV.fetch("IMAGE_CONTAINER_PATH", "/images") + has_many :software_images, dependent: :restrict_with_error + attr_accessor :file_upload before_save :process_upload, if: -> { file_upload.present? } diff --git a/apps/api/app/models/software.rb b/apps/api/app/models/software.rb index def66d7..c5727e5 100644 --- a/apps/api/app/models/software.rb +++ b/apps/api/app/models/software.rb @@ -1,21 +1,22 @@ class Software < ApplicationRecord self.table_name = "softwares" - belongs_to :image, optional: true - + has_many :software_images, foreign_key: :software_id, dependent: :destroy + has_many :images, through: :software_images has_many :releases, foreign_key: :software_id has_many :external_links, foreign_key: :software_id + accepts_nested_attributes_for :software_images, allow_destroy: true accepts_nested_attributes_for :external_links, allow_destroy: true accepts_nested_attributes_for :releases, allow_destroy: true default_scope { where(deleted_at: nil) } def self.ransackable_attributes(auth_object = nil) - %w[author created_at desc highlighted id image_id license name platform site status story title updated_at] + %w[author created_at desc highlighted id license name platform site status story title updated_at] end def self.ransackable_associations(auth_object = nil) - %w[releases external_links image] + %w[releases external_links software_images images] end end diff --git a/apps/api/app/models/software_image.rb b/apps/api/app/models/software_image.rb new file mode 100644 index 0000000..d17a0de --- /dev/null +++ b/apps/api/app/models/software_image.rb @@ -0,0 +1,27 @@ +class SoftwareImage < ApplicationRecord + belongs_to :software + belongs_to :image + + validates :image_id, uniqueness: { scope: :software_id } + validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + + before_save :unset_other_defaults, if: -> { is_default? && is_default_changed? } + + default_scope { order(:position) } + + def self.ransackable_attributes(auth_object = nil) + %w[created_at id image_id is_default position software_id updated_at] + end + + def self.ransackable_associations(auth_object = nil) + %w[image software] + end + + private + + def unset_other_defaults + SoftwareImage.where(software_id: software_id, is_default: true) + .where.not(id: id) + .update_all(is_default: false) + end +end diff --git a/apps/api/app/serializers/software_serializer.rb b/apps/api/app/serializers/software_serializer.rb index 2af8fb6..53aa301 100644 --- a/apps/api/app/serializers/software_serializer.rb +++ b/apps/api/app/serializers/software_serializer.rb @@ -16,5 +16,13 @@ class SoftwareSerializer < Blueprinter::Base field :status field(:highlighted) { |sw| sw.highlighted ? true : false } field(:externalLinks) { |sw| ExternalLinkSerializer.render_as_hash(sw.external_links) } - field(:imageUrl) { |sw| sw.image_id ? "/api/image/#{sw.image_id}" : nil } + field(:imageUrl) { |sw| + si = sw.software_images.detect(&:is_default?) || sw.software_images.first + si ? "/api/image/#{si.image_id}" : nil + } + field(:images) { |sw| + sw.software_images.map { |si| + { url: "/api/image/#{si.image_id}", isDefault: si.is_default?, position: si.position } + } + } end diff --git a/apps/api/app/services/software_highlighted_service.rb b/apps/api/app/services/software_highlighted_service.rb index cc3b104..622d287 100644 --- a/apps/api/app/services/software_highlighted_service.rb +++ b/apps/api/app/services/software_highlighted_service.rb @@ -2,7 +2,7 @@ class SoftwareHighlightedService include SoftwareResponseBuilder def index - software = Software.includes(:external_links, :image) + software = Software.includes(:external_links, :software_images) .where(highlighted: true) .order(id: :desc) .first diff --git a/apps/api/app/services/software_service.rb b/apps/api/app/services/software_service.rb index 06400aa..48633e9 100644 --- a/apps/api/app/services/software_service.rb +++ b/apps/api/app/services/software_service.rb @@ -2,7 +2,7 @@ class SoftwareService include SoftwareResponseBuilder def index - softwares = Software.includes(:releases, :external_links, :image).all + softwares = Software.includes(:releases, :external_links, :software_images).all { softwares: softwares.map { |sw| build_response(sw, sw.releases.to_a) } } end end diff --git a/apps/api/db/migrate/20260505000010_create_software_images.rb b/apps/api/db/migrate/20260505000010_create_software_images.rb new file mode 100644 index 0000000..1634d3c --- /dev/null +++ b/apps/api/db/migrate/20260505000010_create_software_images.rb @@ -0,0 +1,18 @@ +class CreateSoftwareImages < ActiveRecord::Migration[8.0] + def change + create_table :software_images do |t| + t.bigint :software_id, null: false, unsigned: true + t.bigint :image_id, null: false + t.boolean :is_default, null: false, default: false + t.integer :position, null: false, default: 0 + + t.timestamps + end + + add_index :software_images, [:software_id, :image_id], unique: true + add_index :software_images, [:software_id, :position] + + add_foreign_key :software_images, :softwares + add_foreign_key :software_images, :images + end +end diff --git a/apps/api/db/migrate/20260505000011_migrate_and_remove_software_image_id.rb b/apps/api/db/migrate/20260505000011_migrate_and_remove_software_image_id.rb new file mode 100644 index 0000000..a60d5df --- /dev/null +++ b/apps/api/db/migrate/20260505000011_migrate_and_remove_software_image_id.rb @@ -0,0 +1,24 @@ +class MigrateAndRemoveSoftwareImageId < ActiveRecord::Migration[8.0] + def up + Software.unscoped.where.not(image_id: nil).find_each do |sw| + SoftwareImage.create!( + software_id: sw.id, + image_id: sw.image_id, + is_default: true, + position: 0 + ) + end + + remove_foreign_key :softwares, :images + remove_column :softwares, :image_id + end + + def down + add_column :softwares, :image_id, :bigint + add_foreign_key :softwares, :images + + SoftwareImage.where(is_default: true).find_each do |si| + Software.unscoped.where(id: si.software_id).update_all(image_id: si.image_id) + end + end +end diff --git a/apps/frontend/src/lib/interfaces/software.interface.ts b/apps/frontend/src/lib/interfaces/software.interface.ts index 9f36015..e594d15 100644 --- a/apps/frontend/src/lib/interfaces/software.interface.ts +++ b/apps/frontend/src/lib/interfaces/software.interface.ts @@ -7,6 +7,12 @@ export interface Release { UpdatedAt: string } +export interface SoftwareImage { + url: string + isDefault: boolean + position: number +} + export interface Software { name: string title: string @@ -18,6 +24,7 @@ export interface Software { story?: string externalLinks?: { label: string; url: string }[] imageUrl?: string | null + images?: SoftwareImage[] } export interface SoftwareEntry { diff --git a/apps/frontend/src/page/catalog/CatalogIndexPage.vue b/apps/frontend/src/page/catalog/CatalogIndexPage.vue index fed5e80..181cc65 100644 --- a/apps/frontend/src/page/catalog/CatalogIndexPage.vue +++ b/apps/frontend/src/page/catalog/CatalogIndexPage.vue @@ -23,8 +23,8 @@
-
- +
+
@@ -66,6 +66,7 @@ import { storeToRefs } from 'pinia' import { RouterLink } from 'vue-router' import { useI18n } from 'vue-i18n' import { useSoftwareStore } from '../../stores/software.store' +import type { Software } from '../../lib/interfaces/software.interface' const { t } = useI18n() @@ -76,6 +77,14 @@ const store = useSoftwareStore() const { items: softwares } = storeToRefs(store) const { getLatestStable } = store +function getDefaultImageUrl(sw: Software): string | null { + if (sw.images?.length) { + const def = sw.images.find(i => i.isDefault) + return def?.url ?? sw.images[0]?.url ?? null + } + return sw.imageUrl ?? null +} + onMounted(() => store.fetchAll()) diff --git a/apps/frontend/src/page/catalog/CatalogShowPage.vue b/apps/frontend/src/page/catalog/CatalogShowPage.vue index 313a916..9f32eba 100644 --- a/apps/frontend/src/page/catalog/CatalogShowPage.vue +++ b/apps/frontend/src/page/catalog/CatalogShowPage.vue @@ -27,8 +27,26 @@
-
- +
+
+ +
+
+ +

{{ t('catalogShow.projectInfo') }} @@ -158,6 +176,13 @@

+ +
{{ error || t('catalogShow.loading') }} @@ -172,7 +197,8 @@ import { RouterLink, useRoute } from 'vue-router' import { useI18n } from 'vue-i18n' import { formatDateTime } from '../../lib/dateFormat' import { useSoftwareStore } from '../../stores/software.store' -import type { Release, Software } from '../../lib/interfaces/software.interface' +import type { Release, Software, SoftwareImage } from '../../lib/interfaces/software.interface' +import ImageLightbox from './ImageLightbox.vue' const { t } = useI18n() const route = useRoute() @@ -181,6 +207,33 @@ const store = useSoftwareStore() const software = ref(null) const releases = ref([]) const error = ref(null) +const lightboxOpen = ref(false) +const lightboxStartIndex = ref(0) + +const softwareImages = computed(() => { + if (software.value?.images?.length) { + return [...software.value.images].sort((a, b) => a.position - b.position) + } + if (software.value?.imageUrl) { + return [{ url: software.value.imageUrl, isDefault: true, position: 0 }] + } + return [] +}) + +const defaultImageIndex = computed(() => { + const idx = softwareImages.value.findIndex(i => i.isDefault) + return idx >= 0 ? idx : 0 +}) + +const defaultImageUrl = computed(() => + softwareImages.value[defaultImageIndex.value]?.url ?? null +) + +function openLightbox(index: number) { + if (softwareImages.value.length === 0) return + lightboxStartIndex.value = index + lightboxOpen.value = true +} const stableReleases = computed(() => releases.value diff --git a/apps/frontend/src/page/catalog/ImageLightbox.vue b/apps/frontend/src/page/catalog/ImageLightbox.vue new file mode 100644 index 0000000..28f7f65 --- /dev/null +++ b/apps/frontend/src/page/catalog/ImageLightbox.vue @@ -0,0 +1,65 @@ + + + diff --git a/apps/frontend/src/page/home/HomePage.vue b/apps/frontend/src/page/home/HomePage.vue index 7edc992..686a025 100644 --- a/apps/frontend/src/page/home/HomePage.vue +++ b/apps/frontend/src/page/home/HomePage.vue @@ -39,8 +39,8 @@