diff --git a/apps/api/app/admin/platform_links.rb b/apps/api/app/admin/platform_links.rb new file mode 100644 index 0000000..b10d521 --- /dev/null +++ b/apps/api/app/admin/platform_links.rb @@ -0,0 +1,52 @@ +ActiveAdmin.register PlatformLink 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 diff --git a/apps/api/app/models/platform_link.rb b/apps/api/app/models/platform_link.rb new file mode 100644 index 0000000..df2c2ff --- /dev/null +++ b/apps/api/app/models/platform_link.rb @@ -0,0 +1,23 @@ +class PlatformLink < ApplicationRecord + self.table_name = "platform_links" + + SUPPORTED_PLATFORMS = %w[tic80 ebitengine love c64 godot bevy phaser].freeze + + validates :name, presence: true + validates :url, presence: true + validates :platform, presence: true, inclusion: { in: SUPPORTED_PLATFORMS } + + default_scope { where(deleted_at: nil).order(:position) } + + def self.ransackable_attributes(auth_object = nil) + %w[created_at deleted_at id name platform position updated_at url] + end + + def self.ransackable_associations(auth_object = nil) + [] + end + + def self.for_platform(platform) + where(platform: platform).to_a + end +end diff --git a/apps/api/app/serializers/platform_link_serializer.rb b/apps/api/app/serializers/platform_link_serializer.rb new file mode 100644 index 0000000..7ba962c --- /dev/null +++ b/apps/api/app/serializers/platform_link_serializer.rb @@ -0,0 +1,4 @@ +class PlatformLinkSerializer < Blueprinter::Base + field :name + field :url +end diff --git a/apps/api/app/serializers/software_serializer.rb b/apps/api/app/serializers/software_serializer.rb index 53aa301..7719d4a 100644 --- a/apps/api/app/serializers/software_serializer.rb +++ b/apps/api/app/serializers/software_serializer.rb @@ -16,6 +16,7 @@ class SoftwareSerializer < Blueprinter::Base field :status field(:highlighted) { |sw| sw.highlighted ? true : false } field(:externalLinks) { |sw| ExternalLinkSerializer.render_as_hash(sw.external_links) } + field(:platformLinks) { |sw| PlatformLinkSerializer.render_as_hash(PlatformLink.for_platform(sw.platform)) } field(:imageUrl) { |sw| si = sw.software_images.detect(&:is_default?) || sw.software_images.first si ? "/api/image/#{si.image_id}" : nil diff --git a/apps/api/db/migrate/20260804000001_create_platform_links.rb b/apps/api/db/migrate/20260804000001_create_platform_links.rb new file mode 100644 index 0000000..a3ec741 --- /dev/null +++ b/apps/api/db/migrate/20260804000001_create_platform_links.rb @@ -0,0 +1,18 @@ +class CreatePlatformLinks < ActiveRecord::Migration[8.1] + def change + create_table :platform_links, id: { type: :bigint, unsigned: true }, + charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", + if_not_exists: true do |t| + t.datetime :created_at, precision: 3 + t.datetime :deleted_at, precision: 3 + t.string :name, limit: 128 + t.string :platform, limit: 128 + t.integer :position, default: 0, null: false + t.datetime :updated_at, precision: 3 + t.string :url + + t.index :deleted_at, name: "idx_platform_links_deleted_at" + t.index :platform, name: "idx_platform_links_platform" + end + end +end diff --git a/apps/api/spec/factories/platform_links.rb b/apps/api/spec/factories/platform_links.rb new file mode 100644 index 0000000..4847fd2 --- /dev/null +++ b/apps/api/spec/factories/platform_links.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + factory :platform_link do + sequence(:name) { |n| "Link #{n}" } + platform { "tic80" } + url { "https://example.com/link" } + position { 0 } + end +end diff --git a/apps/api/spec/models/platform_link_spec.rb b/apps/api/spec/models/platform_link_spec.rb new file mode 100644 index 0000000..fe7212c --- /dev/null +++ b/apps/api/spec/models/platform_link_spec.rb @@ -0,0 +1,52 @@ +require "rails_helper" + +RSpec.describe PlatformLink, type: :model do + describe "validations" do + it { should validate_presence_of(:name) } + it { should validate_presence_of(:url) } + it { should validate_presence_of(:platform) } + + it "rejects invalid platform" do + link = build(:platform_link, platform: "invalid") + expect(link).not_to be_valid + expect(link.errors[:platform]).to be_present + end + + it "accepts valid platforms" do + PlatformLink::SUPPORTED_PLATFORMS.each do |p| + link = build(:platform_link, platform: p) + expect(link).to be_valid + end + end + end + + describe "default scope" do + it "excludes soft-deleted records" do + active = create(:platform_link) + create(:platform_link, deleted_at: Time.current) + + expect(PlatformLink.all).to eq([active]) + end + + it "orders by position" do + second = create(:platform_link, position: 2) + first = create(:platform_link, position: 1) + + expect(PlatformLink.all).to eq([first, second]) + end + end + + describe ".for_platform" do + it "returns links for given platform only" do + tic80_link = create(:platform_link, platform: "tic80") + create(:platform_link, platform: "love") + + result = PlatformLink.for_platform("tic80") + expect(result).to eq([tic80_link]) + end + + it "returns empty array for platform without links" do + expect(PlatformLink.for_platform("godot")).to eq([]) + end + end +end diff --git a/apps/frontend/src/i18n/locales/en.ts b/apps/frontend/src/i18n/locales/en.ts index 10f982d..5df7d03 100644 --- a/apps/frontend/src/i18n/locales/en.ts +++ b/apps/frontend/src/i18n/locales/en.ts @@ -146,6 +146,7 @@ export default { play: 'Play', downloads: 'downloads', links: 'Links', + platformLinks: 'Platform Links', assetsTitle: 'Downloads', assetDownload: 'Download', assetOpen: 'Open', diff --git a/apps/frontend/src/i18n/locales/hu.ts b/apps/frontend/src/i18n/locales/hu.ts index 1eebc51..ef610ab 100644 --- a/apps/frontend/src/i18n/locales/hu.ts +++ b/apps/frontend/src/i18n/locales/hu.ts @@ -146,6 +146,7 @@ export default { play: '▶ Játék', downloads: 'letöltés', links: 'Linkek', + platformLinks: 'Platform linkek', assetsTitle: 'Letöltések', assetDownload: 'Letöltés', assetOpen: 'Megnyitás', diff --git a/apps/frontend/src/lib/interfaces/software.interface.ts b/apps/frontend/src/lib/interfaces/software.interface.ts index 0a2e4c5..5c337f3 100644 --- a/apps/frontend/src/lib/interfaces/software.interface.ts +++ b/apps/frontend/src/lib/interfaces/software.interface.ts @@ -20,6 +20,11 @@ export interface SoftwareImage { position: number } +export interface PlatformLink { + name: string + url: string +} + export interface Software { name: string title: string @@ -30,6 +35,7 @@ export interface Software { license?: string story?: string externalLinks?: { label: string; url: string }[] + platformLinks?: PlatformLink[] imageUrl?: string | null images?: SoftwareImage[] } diff --git a/apps/frontend/src/page/catalog/CatalogShowPage.vue b/apps/frontend/src/page/catalog/CatalogShowPage.vue index 9452bfc..04bd7f7 100644 --- a/apps/frontend/src/page/catalog/CatalogShowPage.vue +++ b/apps/frontend/src/page/catalog/CatalogShowPage.vue @@ -70,6 +70,14 @@ +