advanced image management

This commit is contained in:
2026-07-27 08:19:48 +02:00
parent f776919769
commit b193ac6caf
14 changed files with 268 additions and 24 deletions
+29 -9
View File
@@ -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
+2
View File
@@ -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? }
+5 -4
View File
@@ -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
+27
View File
@@ -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
@@ -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
@@ -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
+1 -1
View File
@@ -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
@@ -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
@@ -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
@@ -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 {
@@ -23,8 +23,8 @@
<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 v-if="getDefaultImageUrl(software)" class="flex-shrink-0">
<img :src="getDefaultImageUrl(software)!" :alt="software.title" class="software-thumb" />
</div>
<div class="flex-grow">
<div class="flex items-center gap-3 mb-2">
@@ -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())
</script>
@@ -27,8 +27,26 @@
<!-- 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 flex justify-center">
<img :src="software.imageUrl" :alt="software.title" class="max-h-48 w-auto object-contain border-4 border-gray-100 shadow-sm" />
<div v-if="defaultImageUrl" class="mb-6">
<div class="flex justify-center">
<img
:src="defaultImageUrl"
:alt="software.title"
:class="['max-h-48 w-auto object-contain border-4 border-gray-100 shadow-sm', softwareImages.length > 0 ? 'cursor-pointer hover:opacity-80 transition-opacity' : '']"
@click="openLightbox(defaultImageIndex)"
/>
</div>
<div v-if="softwareImages.length > 1" class="flex gap-2 mt-3 justify-center">
<img
v-for="(img, idx) in softwareImages"
:key="idx"
:src="img.url"
:alt="`${software.title} ${idx + 1}`"
:class="['w-12 h-12 object-cover rounded border-2 cursor-pointer transition-all',
img.isDefault ? 'border-purple-400' : 'border-gray-200 hover:border-purple-300']"
@click="openLightbox(idx)"
/>
</div>
</div>
<h2 class="text-xl font-bold text-gray-900 mb-6 flex items-center gap-2">
<span class="text-purple-600"></span> {{ t('catalogShow.projectInfo') }}
@@ -158,6 +176,13 @@
</div>
</main>
<ImageLightbox
:images="softwareImages"
:is-open="lightboxOpen"
:start-index="lightboxStartIndex"
@close="lightboxOpen = false"
/>
<main v-else class="main-container -mt-10">
<div class="bg-white rounded-3xl shadow-xl p-12 text-center text-gray-400">
{{ 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<Software | null>(null)
const releases = ref<Release[]>([])
const error = ref<string | null>(null)
const lightboxOpen = ref(false)
const lightboxStartIndex = ref(0)
const softwareImages = computed<SoftwareImage[]>(() => {
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
@@ -0,0 +1,65 @@
<template>
<Teleport to="body">
<div
v-if="isOpen"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
@click.self="close"
@keydown.escape="close"
@keydown.left="prev"
@keydown.right="next"
tabindex="0"
ref="overlay"
>
<button @click="close" class="absolute top-4 right-4 text-white/70 hover:text-white text-3xl font-bold z-10">&times;</button>
<button v-if="images.length > 1" @click="prev"
class="absolute left-4 text-white/70 hover:text-white text-5xl font-bold z-10 select-none">&lsaquo;</button>
<img
:src="images[currentIndex].url"
:alt="`Image ${currentIndex + 1}`"
class="max-h-[85vh] max-w-[90vw] object-contain rounded-lg shadow-2xl"
/>
<button v-if="images.length > 1" @click="next"
class="absolute right-4 text-white/70 hover:text-white text-5xl font-bold z-10 select-none">&rsaquo;</button>
<div v-if="images.length > 1" class="absolute bottom-6 text-white/60 text-sm font-mono">
{{ currentIndex + 1 }} / {{ images.length }}
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import type { SoftwareImage } from '../../lib/interfaces/software.interface'
const props = defineProps<{
images: SoftwareImage[]
isOpen: boolean
startIndex?: number
}>()
const emit = defineEmits<{
close: []
}>()
const currentIndex = ref(0)
const overlay = ref<HTMLElement | null>(null)
watch(() => props.isOpen, async (open) => {
if (open) {
currentIndex.value = props.startIndex ?? 0
document.body.style.overflow = 'hidden'
await nextTick()
overlay.value?.focus()
} else {
document.body.style.overflow = ''
}
})
function close() { emit('close') }
function next() { currentIndex.value = (currentIndex.value + 1) % props.images.length }
function prev() { currentIndex.value = (currentIndex.value - 1 + props.images.length) % props.images.length }
</script>
+13 -3
View File
@@ -39,8 +39,8 @@
<!-- 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 v-if="highlightedImageUrl" class="featured-image-panel">
<img :src="highlightedImageUrl" :alt="highlightedSoftware.software.title" class="featured-image" />
</div>
<div class="featured-content">
<div class="flex items-center gap-2 mb-4">
@@ -220,7 +220,7 @@
</template>
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { computed, onMounted, onUnmounted } from 'vue'
import { storeToRefs } from 'pinia'
import { RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
@@ -236,6 +236,16 @@ const { nextEvent, followingEvents, countdownText } = storeToRefs(eventStore)
const softwareStore = useSoftwareStore()
const { highlighted: highlightedSoftware, highlightedStableRelease } = storeToRefs(softwareStore)
const highlightedImageUrl = computed(() => {
const sw = highlightedSoftware.value?.software
if (!sw) return 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
})
const ytStore = useYoutubeStore()
const { state: ytState, videoId: ytVideoId, title: ytTitle, publishDate: ytPublishDate, viewCount: ytViewCount, error: ytError } = storeToRefs(ytStore)