# frozen_string_literal: true # name: community-landing # about: Branded public landing page for unauthenticated visitors # version: 1.0.0 # authors: Community # url: https://github.com/community/community-landing enabled_site_setting :community_landing_enabled after_initialize do module ::CommunityLanding PLUGIN_NAME = "community-landing" PLUGIN_DIR = File.expand_path("..", __FILE__) end class ::CommunityLanding::LandingController < ::ApplicationController requires_plugin CommunityLanding::PLUGIN_NAME skip_before_action :check_xhr skip_before_action :redirect_to_login_if_required skip_before_action :preload_json, raise: false content_security_policy false def index fetch_community_data css = load_file("assets", "stylesheets", "community_landing", "landing.css") js = load_file("assets", "javascripts", "community_landing", "landing.js") base_url = Discourse.base_url csp = "default-src 'self' #{base_url}; " \ "script-src 'self' 'unsafe-inline'; " \ "style-src 'self' 'unsafe-inline'; " \ "img-src 'self' #{base_url} data: https:; " \ "font-src 'self' #{base_url}; " \ "frame-ancestors 'self'" response.headers["Content-Security-Policy"] = csp render html: build_html(css, js).html_safe, layout: false, content_type: "text/html" end private def load_file(*path_parts) File.read(File.join(CommunityLanding::PLUGIN_DIR, *path_parts)) rescue StandardError => e "/* Error loading #{path_parts.last}: #{e.message} */" end def fetch_community_data s = SiteSetting if s.community_landing_contributors_enabled @top_contributors = User .joins(:posts) .where(posts: { created_at: s.community_landing_contributors_days.days.ago.. }) .where.not(username: %w[system discobot]) .where(active: true, staged: false) .group("users.id") .order("COUNT(posts.id) DESC") .limit(s.community_landing_contributors_count) .select("users.*, COUNT(posts.id) AS post_count") end if s.community_landing_groups_enabled @groups = Group .where(visibility_level: Group.visibility_levels[:public]) .where(automatic: false) .limit(s.community_landing_groups_count) end if s.community_landing_topics_enabled @hot_topics = Topic .listable_topics .where(visible: true) .where("topics.created_at > ?", 30.days.ago) .order(posts_count: :desc) .limit(s.community_landing_topics_count) .includes(:category, :user) end chat_count = 0 begin chat_count = Chat::Message.count if defined?(Chat::Message) rescue chat_count = 0 end @stats = { members: User.real.count, topics: Topic.listable_topics.count, posts: Post.where(user_deleted: false).count, likes: Post.sum(:like_count), chats: chat_count, } end def e(text) ERB::Util.html_escape(text.to_s) end SUN_SVG = '' MOON_SVG = '' QUOTE_SVG = '' STAT_MEMBERS_SVG = '' STAT_TOPICS_SVG = '' STAT_POSTS_SVG = '' STAT_LIKES_SVG = '' STAT_CHATS_SVG = '' def hex_to_rgb(hex) hex = hex.to_s.gsub("#", "") return "0, 0, 0" unless hex.match?(/\A[0-9a-fA-F]{6}\z/) "#{hex[0..1].to_i(16)}, #{hex[2..3].to_i(16)}, #{hex[4..5].to_i(16)}" end def build_color_overrides(s) accent = s.community_landing_accent_color.presence || "#7c6aff" accent_hover = s.community_landing_accent_hover_color.presence || "#9485ff" dark_bg = s.community_landing_dark_bg_color.presence || "#06060f" light_bg = s.community_landing_light_bg_color.presence || "#f8f9fc" accent_rgb = hex_to_rgb(accent) "\n" end # ── Logo helpers ── def logo_img(url, alt, css_class, height) "\"#{e(alt)}\"" end def render_logo(dark_url, light_url, site_name, base_class, height) if dark_url && light_url logo_img(dark_url, site_name, "#{base_class} cl-logo--dark", height) + logo_img(light_url, site_name, "#{base_class} cl-logo--light", height) else logo_img(dark_url || light_url, site_name, base_class, height) end end # ── App badge helper ── def render_app_badge(store_url, custom_icon_url, default_svg, badge_h, badge_style) style_class = case badge_style when "pill" then "cl-app-badge--pill" when "square" then "cl-app-badge--square" else "cl-app-badge--rounded" end html = "" if custom_icon_url html << "\"\"" else html << default_svg end html << "\n" html end def build_html(css, js) s = SiteSetting site_name = s.title login_url = "/login" # Logo URLs logo_dark_url = s.community_landing_logo_dark_url.presence logo_light_url = s.community_landing_logo_light_url.presence # Fallback: if only light is set, treat it as the universal logo if logo_dark_url.nil? && logo_light_url.nil? fallback = s.respond_to?(:logo_url) ? s.logo_url.presence : nil logo_dark_url = fallback end has_logo = logo_dark_url.present? || logo_light_url.present? logo_h = s.community_landing_logo_height rescue 30 og_logo = logo_dark_url || logo_light_url # Footer logo footer_logo_url = s.community_landing_footer_logo_url.presence html = +"" html << "\n\n\n" html << "\n" html << "\n" html << "\n" html << "#{e(s.community_landing_hero_title)} | #{e(site_name)}\n" html << "\n" html << "\n" html << "\n" html << "\n" html << "\n" if og_logo html << "\n" html << "\n" html << "\n" html << build_color_overrides(s) html << "\n\n" # Navbar labels signin_label = s.community_landing_navbar_signin_label.presence || "Sign In" join_label = s.community_landing_navbar_join_label.presence || "Join Free" # ── NAVBAR ── html << "\n" # ── HERO — text left, image right ── hero_style = "" if s.community_landing_hero_background_image_url.present? hero_style = " style=\"background-image: linear-gradient(rgba(6,6,15,0.8), rgba(6,6,15,0.8)), url('#{s.community_landing_hero_background_image_url}');\"" end html << "
\n" html << "
\n" html << "
\n" html << "
\n" title_words = s.community_landing_hero_title.to_s.split(" ") if title_words.length > 1 html << "

#{e(title_words[0..-2].join(" "))} #{e(title_words.last)}

\n" else html << "

#{e(s.community_landing_hero_title)}

\n" end html << "

#{e(s.community_landing_hero_subtitle)}

\n" primary_label = s.community_landing_hero_primary_button_label.presence || "Browse the Forum" primary_url = s.community_landing_hero_primary_button_url.presence || "/latest" secondary_label = s.community_landing_hero_secondary_button_label.presence || "Join the Community" secondary_url = s.community_landing_hero_secondary_button_url.presence || login_url html << "
\n" html << "#{e(primary_label)}\n" html << "#{e(secondary_label)}\n" html << "
\n" html << "
\n" # end cl-hero__content hero_image_urls_raw = s.community_landing_hero_image_urls.presence if hero_image_urls_raw urls = hero_image_urls_raw.split("|").map(&:strip).reject(&:empty?).first(5) if urls.any? img_max_h = s.community_landing_hero_image_max_height rescue 500 html << "
\n" html << "\"#{e(site_name)}\"\n" html << "
\n" end end html << "
\n" # end hero # ── STATS ROW — full-width counter cards ── html << "
\n" html << "
\n" html << stats_counter_card(STAT_MEMBERS_SVG, @stats[:members], s.community_landing_stat_members_label) html << stats_counter_card(STAT_TOPICS_SVG, @stats[:topics], s.community_landing_stat_topics_label) html << stats_counter_card(STAT_POSTS_SVG, @stats[:posts], s.community_landing_stat_posts_label) html << stats_counter_card(STAT_LIKES_SVG, @stats[:likes], s.community_landing_stat_likes_label) html << stats_counter_card(STAT_CHATS_SVG, @stats[:chats], s.community_landing_stat_chats_label) html << "
\n
\n" # ── TWO-COLUMN CONTENT AREA ── html << "
\n" html << "
\n" # ── LEFT COLUMN — About + Contributors ── html << "
\n" # About — quote card if s.community_landing_about_enabled about_body = s.community_landing_about_body.presence || "" about_image = s.community_landing_about_image_url.presence about_role = s.community_landing_about_role.presence || site_name html << "
\n" html << "
\n" html << QUOTE_SVG if about_body.present? html << "
#{about_body}
\n" end html << "
\n" if about_image html << "\"\"\n" end html << "
\n" html << "#{e(s.community_landing_about_title)}\n" html << "#{e(about_role)}\n" html << "
\n" html << "
\n" end # Top Contributors if s.community_landing_contributors_enabled && @top_contributors&.any? html << "
\n" html << "

#{e(s.community_landing_contributors_title)}

\n" html << "
\n" @top_contributors.each do |user| avatar_url = user.avatar_template.gsub("{size}", "120") html << "\n" html << "\"#{e(user.username)}\"\n" html << "#{e(user.username)}\n" html << "\n" end html << "
\n
\n" end html << "
\n" # end left # ── RIGHT COLUMN — Trending Discussions ── html << "
\n" # Trending Discussions if s.community_landing_topics_enabled && @hot_topics&.any? html << "
\n" html << "

#{e(s.community_landing_topics_title)}

\n" html << "\n
\n" end html << "
\n" # end right # ── BOTTOM ROW — Groups (full-width) ── if s.community_landing_groups_enabled && @groups&.any? html << "
\n" html << "
\n" html << "

#{e(s.community_landing_groups_title)}

\n" html << "
\n" @groups.each do |group| display_name = group.name.tr("_-", " ").gsub(/\b\w/, &:upcase) hue = group.name.bytes.sum % 360 html << "\n" html << "
" if group.flair_url.present? html << "\"\"" else html << "#{group.name[0].upcase}" end html << "
\n" html << "#{e(display_name)}\n" html << "#{group.user_count} members\n" html << "
\n" end html << "
\n
\n" html << "
\n" end html << "
\n" # end content grid # ── APP CTA (above footer) ── if s.community_landing_show_app_ctas && (s.community_landing_ios_app_url.present? || s.community_landing_android_app_url.present?) badge_h = s.community_landing_app_badge_height rescue 45 badge_style = s.community_landing_app_badge_style rescue "rounded" ios_icon = s.community_landing_ios_app_icon_url.presence android_icon = s.community_landing_android_app_icon_url.presence ios_w = (badge_h * 3.0).to_i android_w = (badge_h * 3.375).to_i ios_default_svg = "Download on theApp Store" android_default_svg = "GET IT ONGoogle Play" html << "
\n" html << "

#{e(s.community_landing_app_cta_headline)}

\n" html << "

#{e(s.community_landing_app_cta_subtext)}

\n" html << "
\n" if s.community_landing_ios_app_url.present? html << render_app_badge(s.community_landing_ios_app_url, ios_icon, ios_default_svg, badge_h, badge_style) end if s.community_landing_android_app_url.present? html << render_app_badge(s.community_landing_android_app_url, android_icon, android_default_svg, badge_h, badge_style) end html << "
\n" end # ── FOOTER ── html << "\n" html << "\n" html << "\n" html end def stats_counter_card(icon_svg, count, label) "
\n" \ "
#{icon_svg}
\n" \ "0\n" \ "#{e(label)}\n" \ "
\n" end end Discourse::Application.routes.prepend do root to: "community_landing/landing#index", constraints: ->(req) { req.cookies["_t"].blank? && SiteSetting.community_landing_enabled } end end