diff --git a/app/assets/images/icons/fontawesome/bluesky-brands-solid.svg b/app/assets/images/icons/fontawesome/bsky-brands-solid.svg similarity index 100% rename from app/assets/images/icons/fontawesome/bluesky-brands-solid.svg rename to app/assets/images/icons/fontawesome/bsky-brands-solid.svg diff --git a/app/assets/images/icons/fontawesome/speaker-deck-brands-solid.svg b/app/assets/images/icons/fontawesome/speakerdeck-brands-solid.svg similarity index 100% rename from app/assets/images/icons/fontawesome/speaker-deck-brands-solid.svg rename to app/assets/images/icons/fontawesome/speakerdeck-brands-solid.svg diff --git a/app/assets/images/icons/fontawesome/website-brands-solid.svg b/app/assets/images/icons/fontawesome/website-brands-solid.svg new file mode 100644 index 000000000..fd2fb694c --- /dev/null +++ b/app/assets/images/icons/fontawesome/website-brands-solid.svg @@ -0,0 +1 @@ + diff --git a/app/avo/resources/event.rb b/app/avo/resources/event.rb index 2a757159e..97a1ad8db 100644 --- a/app/avo/resources/event.rb +++ b/app/avo/resources/event.rb @@ -7,7 +7,7 @@ class Avo::Resources::Event < Avo::BaseResource if id.is_a?(Array) query.where(slug: id) else - query.find_by(slug: id) + query.find_by(slug: id) || query.find_by(id:) end } self.external_link = -> { @@ -27,6 +27,7 @@ def fields field :talks, as: :has_many field :speakers, as: :has_many, through: :talks field :topics, as: :has_many + field :social_profiles, as: :has_many, use_resource: "Avo::Resources::SocialProfile" end def actions diff --git a/app/avo/resources/social_profile.rb b/app/avo/resources/social_profile.rb new file mode 100644 index 000000000..0c8afa5fa --- /dev/null +++ b/app/avo/resources/social_profile.rb @@ -0,0 +1,8 @@ +class Avo::Resources::SocialProfile < Avo::BaseResource + def fields + field :id, as: :id + field :provider, enum: ::SocialProfile.providers, as: :select, required: true + field :value, as: :text + field :sociable, as: :belongs_to, polymorphic_as: :sociable, types: [::Speaker, ::Event], foreign_key: :slug + end +end diff --git a/app/avo/resources/speaker.rb b/app/avo/resources/speaker.rb index 475992682..29372e22e 100644 --- a/app/avo/resources/speaker.rb +++ b/app/avo/resources/speaker.rb @@ -4,7 +4,7 @@ class Avo::Resources::Speaker < Avo::BaseResource if id.is_a?(Array) query.where(slug: id) else - query.find_by(slug: id) + query.find_by(slug: id) || query.find_by(id:) end } self.search = { @@ -17,19 +17,15 @@ class Avo::Resources::Speaker < Avo::BaseResource def fields field :id, as: :id, link_to_record: true field :name, as: :text, link_to_record: true, sortable: true - field :twitter, as: :text field :github, as: :text - field :speakerdeck, as: :text - field :mastodon, as: :text, hide_on: :index - field :linkedin, as: :text, hide_on: :index - field :bsky, as: :text, hide_on: :index field :bio, as: :textarea, hide_on: :index - field :website, as: :text, hide_on: :index field :slug, as: :text, hide_on: :index field :talks_count, as: :number, sortable: true field :canonical, as: :belongs_to, hide_on: :index # field :suggestions, as: :has_many field :speaker_talks, as: :has_many, resource: Avo::Resources::SpeakerTalk, attach_scope: -> { query.order(title: :asc) } + # field :speaker_talks, as: :has_many + field :social_profiles, as: :has_many, use_resource: "Avo::Resources::SocialProfile" field :talks, as: :has_many, use_resource: "Avo::Resources::Talk", attach_scope: -> { query.order(title: :asc) }, searchable: true end diff --git a/app/controllers/avo/social_profiles_controller.rb b/app/controllers/avo/social_profiles_controller.rb new file mode 100644 index 000000000..dccf10aa3 --- /dev/null +++ b/app/controllers/avo/social_profiles_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/3.0/controllers.html +class Avo::SocialProfilesController < Avo::ResourcesController +end diff --git a/app/controllers/social_profiles_controller.rb b/app/controllers/social_profiles_controller.rb new file mode 100644 index 000000000..f4fd1e35d --- /dev/null +++ b/app/controllers/social_profiles_controller.rb @@ -0,0 +1,74 @@ +class SocialProfilesController < ApplicationController + skip_before_action :authenticate_user!, only: [:edit, :update] + before_action :check_ownership, only: [:new, :create] + before_action :check_provider, only: [:new] + before_action :set_social_profile, only: [:edit, :update] + + def new + @social_profile = @sociable.social_profiles.new(provider: params[:provider]) + end + + def create + @social_profile = @sociable.social_profiles.new(social_profile_params) + + begin + ActiveRecord::Base.transaction do + @social_profile.save! + @suggestion = @social_profile.create_suggestion_from( + params: @social_profile.attributes.slice("provider", "value"), + new_record: true + ) + end + rescue ActiveRecord::RecordInvalid + end + + respond_to do |format| + if @suggestion&.persisted? + flash[:notice] = "Saved" + format.turbo_stream + else + format.turbo_stream { render :create, status: :unprocessable_entity } + end + end + end + + def edit + end + + def update + begin + @suggestion = @social_profile.create_suggestion_from(params: social_profile_params, user: Current.user) + rescue ActiveRecord::RecordInvalid + end + + respond_to do |format| + if @suggestion&.persisted? + flash[:notice] = "Saved" + format.turbo_stream + else + format.turbo_stream { render :update, status: :unprocessable_entity } + end + end + end + + private + + def social_profile_params + params.require(:social_profile).permit( + :provider, + :value + ) + end + + def set_social_profile + @social_profile ||= SocialProfile.find(params[:id]) + end + + def check_provider + raise StandardError, "Invalid Social Provider" unless SocialProfile::PROVIDERS.include?(params[:provider]) + end + + def check_ownership + head :forbidden unless @sociable.managed_by?(Current.user) + end +end diff --git a/app/controllers/speakers/social_profiles_controller.rb b/app/controllers/speakers/social_profiles_controller.rb new file mode 100644 index 000000000..ffebb19f8 --- /dev/null +++ b/app/controllers/speakers/social_profiles_controller.rb @@ -0,0 +1,9 @@ +class Speakers::SocialProfilesController < ::SocialProfilesController + prepend_before_action :set_sociable + + private + + def set_sociable + @sociable ||= Speaker.find_by!(slug: params[:speaker_slug]) + end +end diff --git a/app/controllers/speakers_controller.rb b/app/controllers/speakers_controller.rb index f6a39db74..f24061d0b 100644 --- a/app/controllers/speakers_controller.rb +++ b/app/controllers/speakers_controller.rb @@ -69,13 +69,7 @@ def speaker_params params.require(:speaker).permit( :name, :github, - :twitter, - :bsky, - :linkedin, - :mastodon, :bio, - :website, - :speakerdeck, :pronouns_type, :pronouns, :slug diff --git a/app/models/concerns/sociable.rb b/app/models/concerns/sociable.rb new file mode 100644 index 000000000..db0256697 --- /dev/null +++ b/app/models/concerns/sociable.rb @@ -0,0 +1,21 @@ +module Sociable + extend ActiveSupport::Concern + + included do + has_many :social_profiles, -> { order(:created_at) }, as: :sociable, dependent: :destroy + end + + SocialProfile::PROVIDERS.each do |method| + define_method(method) do + social_profiles.send(method).first&.value + end + + define_method(:"build_#{method}") do |value| + social_profiles.build(provider: method, value:) + end + + define_method(:"create_#{method}") do |value| + social_profiles.create(provider: method, value:) + end + end +end diff --git a/app/models/concerns/suggestable.rb b/app/models/concerns/suggestable.rb index 9e934a698..5e05fea7a 100644 --- a/app/models/concerns/suggestable.rb +++ b/app/models/concerns/suggestable.rb @@ -5,8 +5,11 @@ module Suggestable has_many :suggestions, as: :suggestable, dependent: :destroy end - def create_suggestion_from(params:, user: Current.user) - suggestions.create(content: select_differences_for(params), suggested_by_id: user&.id).tap do |suggestion| + # NOTE: validate before saving + def create_suggestion_from(params:, user: Current.user, new_record: false) + params = select_differences_for(params) unless new_record + + suggestions.create(content: params, suggested_by_id: user&.id).tap do |suggestion| suggestion.approved!(approver: user) if managed_by?(user) end end diff --git a/app/models/event.rb b/app/models/event.rb index d1a2bb34c..421afce42 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -32,6 +32,7 @@ class Event < ApplicationRecord include Suggestable include Sluggable + include Sociable slug_from :name # associations @@ -289,6 +290,6 @@ def year end def website - self[:website].presence || organisation.website + social_profiles.detect { |sp| sp.website? }&.value || organisation.website end end diff --git a/app/models/organisation.rb b/app/models/organisation.rb index e3333983b..d2647b485 100644 --- a/app/models/organisation.rb +++ b/app/models/organisation.rb @@ -28,6 +28,7 @@ class Organisation < ApplicationRecord include Sluggable include Suggestable + include Sociable include ActionView::Helpers::TextHelper diff --git a/app/models/social_profile.rb b/app/models/social_profile.rb new file mode 100644 index 000000000..fdab01f6b --- /dev/null +++ b/app/models/social_profile.rb @@ -0,0 +1,66 @@ +# == Schema Information +# +# Table name: social_profiles +# +# id :integer not null, primary key +# provider :string not null +# sociable_type :string not null, indexed => [sociable_id] +# value :string not null +# created_at :datetime not null +# updated_at :datetime not null +# sociable_id :integer not null, indexed => [sociable_type] +# +# Indexes +# +# index_social_profiles_on_sociable (sociable_type,sociable_id) +# +class SocialProfile < ApplicationRecord + include Suggestable + PROVIDERS = %w[twitter linkedin bsky mastodon speakerdeck website] + + belongs_to :sociable, polymorphic: true + + enum :provider, PROVIDERS.index_by(&:itself), validate: {presence: true} + + after_initialize do + self.value = self.class.normalize_value_for(provider.to_sym, value) if provider.present? + end + + validates :provider, presence: true + validates :value, presence: true, uniqueness: {scope: :provider} + + scope :excluding_provider, ->(provider) { where.not(provider:) } + + # normalizes + normalizes :twitter, with: ->(value) { value.gsub(%r{https?://(?:www\.)?(?:x\.com|twitter\.com)/}, "").gsub(/@/, "") } + normalizes :linkedin, with: ->(value) { value.gsub(%r{https?://(?:www\.)?(?:linkedin\.com/in)/}, "") } + normalizes :bsky, with: ->(value) { value.gsub(%r{https?://(?:www\.)?(?:x\.com|bsky\.app/profile)/}, "").gsub(/@/, "") } + normalizes :speakerdeck, with: ->(value) { value.gsub(/^(?:https?:\/\/)?(?:www\.)?speakerdeck\.com\//, "").gsub(/^@/, "") } + normalizes :mastodon, with: ->(value) { + return value if value&.match?(URI::DEFAULT_PARSER.make_regexp) + return "" unless value.count("@") == 2 + + _, handle, instance = value.split("@") + + "https://#{instance}/@#{handle}" + } + + def url + case provider.to_sym + when :twitter, :speakerdeck + "https://#{provider}.com/#{value}" + when :linkedin + "https://linkedin.com/in/#{value}" + when :bsky + "https://bsky.app/profile/#{value}" + else + value + end + end + + private + + def managed_by?(visiting_user) + sociable.managed_by?(visiting_user) + end +end diff --git a/app/models/speaker.rb b/app/models/speaker.rb index d6e39d891..a2fcb3e76 100644 --- a/app/models/speaker.rb +++ b/app/models/speaker.rb @@ -38,6 +38,7 @@ class Speaker < ApplicationRecord include ActionView::RecordIdentifier include Sluggable include Suggestable + include Sociable include Speaker::Searchable slug_from :name @@ -77,21 +78,6 @@ class Speaker < ApplicationRecord # normalizes normalizes :github, with: ->(value) { value.gsub(/^(?:https?:\/\/)?(?:www\.)?github\.com\//, "").gsub(/^@/, "") } - normalizes :twitter, with: ->(value) { value.gsub(%r{https?://(?:www\.)?(?:x\.com|twitter\.com)/}, "").gsub(/@/, "") } - normalizes :bsky, with: ->(value) { - value.gsub(%r{https?://(?:www\.)?(?:x\.com|bsky\.app/profile)/}, "").gsub(/@/, "") - } - normalizes :linkedin, with: ->(value) { value.gsub(%r{https?://(?:www\.)?(?:linkedin\.com/in)/}, "") } - normalizes :bsky, with: ->(value) { value.gsub(%r{https?://(?:www\.)?(?:[^\/]+\.com)/}, "").gsub(/@/, "") } - - normalizes :mastodon, with: ->(value) { - return value if value&.match?(URI::DEFAULT_PARSER.make_regexp) - return "" unless value.count("@") == 2 - - _, handle, instance = value.split("@") - - "https://#{instance}/@#{handle}" - } def self.reset_talks_counts find_each do |speaker| diff --git a/app/models/speaker/profiles.rb b/app/models/speaker/profiles.rb index d6ace8daf..d260de7b5 100644 --- a/app/models/speaker/profiles.rb +++ b/app/models/speaker/profiles.rb @@ -15,13 +15,14 @@ def enhance_all_later socials = github_client.social_accounts(speaker.github) links = socials.pluck(:provider, :url).to_h + speaker.build_twitter(links["twitter"]) if links["twitter"].present? + speaker.build_mastodon(links["mastodon"]) if links["mastodon"].present? + speaker.build_bsky(links["bluesky"]) if links["bluesky"].present? + speaker.build_linkedin(links["linkedin"]) if links["linkedin"].present? + speaker.build_website(profile.blog) if profile.blog.present? + speaker.update!( - twitter: speaker.twitter.presence || links["twitter"] || "", - mastodon: speaker.mastodon.presence || links["mastodon"] || "", - bsky: speaker.bsky.presence || links["bluesky"] || "", - linkedin: speaker.linkedin.presence || links["linkedin"] || "", bio: speaker.bio.presence || profile.bio || "", - website: speaker.website.presence || profile.blog || "", github_metadata: { profile: JSON.parse(profile.body), socials: JSON.parse(socials.body) diff --git a/app/views/events/_header.html.erb b/app/views/events/_header.html.erb index 4665ab816..0a574dc32 100644 --- a/app/views/events/_header.html.erb +++ b/app/views/events/_header.html.erb @@ -14,18 +14,20 @@
<%= speaker.bio %>
- <% if speaker.website.present? %> - <%= external_link_to speaker.website.gsub(%r{^https?://}, ""), speaker.website, class: "text-center" %> + <% speaker.social_profiles.website.each do |profile| %> + <%= external_link_to profile.value.gsub(%r{^https?://}, ""), profile.value, class: "text-center" %> <% end %> -