# frozen_string_literal: true # name: community-landing # about: Branded public landing page for unauthenticated visitors # version: 2.1.0 # authors: Community # url: https://github.com/community/community-landing enabled_site_setting :community_landing_enabled register_asset "stylesheets/community_landing/admin.css", :admin 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 # ── SVG Icons ── SUN_SVG = '' MOON_SVG = '' QUOTE_SVG = '' STAT_MEMBERS_SVG = '' STAT_TOPICS_SVG = '' STAT_POSTS_SVG = '' STAT_LIKES_SVG = '' STAT_CHATS_SVG = '' COMMENT_SVG = '' HEART_SVG = '' IOS_BADGE_SVG = '' ANDROID_BADGE_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 || "#d4a24e" accent_hover = s.community_landing_accent_hover_color.presence || "#c4922e" dark_bg = s.community_landing_dark_bg_color.presence || "#06060f" light_bg = s.community_landing_light_bg_color.presence || "#faf6f0" stat_icon_color = s.community_landing_stat_icon_color.presence || accent about_g1 = s.community_landing_about_gradient_start.presence || "#fdf6ec" about_g2 = s.community_landing_about_gradient_mid.presence || "#fef9f0" about_g3 = s.community_landing_about_gradient_end.presence || "#fdf6ec" about_bg_img = s.community_landing_about_background_image_url.presence app_g1 = s.community_landing_app_cta_gradient_start.presence || accent app_g2 = s.community_landing_app_cta_gradient_mid.presence || accent_hover app_g3 = s.community_landing_app_cta_gradient_end.presence || accent_hover accent_rgb = hex_to_rgb(accent) anim = s.community_landing_scroll_animation rescue "fade_up" about_bg_extra = about_bg_img ? ", url('#{about_bg_img}') center/cover no-repeat" : "" "\n" end # ── Section row style helpers ── def section_style(bg_color, border_style) parts = [] parts << "background: #{bg_color};" if bg_color.present? parts << "border-bottom: 1px #{border_style} var(--cl-border);" if border_style.present? && border_style != "none" parts.any? ? " style=\"#{parts.join(" ")}\"" : "" 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 def build_html(css, js) s = SiteSetting site_name = s.title login_url = "/login" anim_class = s.community_landing_scroll_animation rescue "fade_up" anim_class = "none" if anim_class.blank? # Logo URLs logo_dark_url = s.community_landing_logo_dark_url.presence logo_light_url = s.community_landing_logo_light_url.presence 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_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) # Custom CSS injection custom_css = s.community_landing_custom_css.presence rescue nil if custom_css html << "\n" end html << "\n\n" signin_label = s.community_landing_navbar_signin_label.presence || "Sign In" join_label = s.community_landing_navbar_join_label.presence || "Get Started" # ── 1. NAVBAR ── navbar_bg = s.community_landing_navbar_bg_color.presence rescue nil navbar_border = s.community_landing_navbar_border_style rescue "none" navbar_data = "" navbar_data << " data-nav-bg=\"#{e(navbar_bg)}\"" if navbar_bg navbar_data << " data-nav-border=\"#{e(navbar_border)}\"" if navbar_border && navbar_border != "none" html << "\n" # ── 2. HERO ── hero_card = s.community_landing_hero_card_enabled rescue true hero_bg_img = s.community_landing_hero_background_image_url.presence hero_bg_color = s.community_landing_hero_bg_color.presence hero_border = s.community_landing_hero_border_style rescue "none" hero_section_style = section_style(hero_bg_color, hero_border) html << "
\n" if hero_bg_img && !hero_card html << "
\n" end inner_style = "" if hero_card && hero_bg_img inner_style = " style=\"background-image: linear-gradient(rgba(0,0,0,0.4), rgba(0,0,0,0.4)), url('#{hero_bg_img}'); background-size: cover; background-position: center;\"" end 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 || "View Latest Topics" primary_url = s.community_landing_hero_primary_button_url.presence || "/latest" secondary_label = s.community_landing_hero_secondary_button_label.presence || "Explore Our Spaces" 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" 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" # ── 3. PREMIUM STATS ── stats_title = s.community_landing_stats_title.presence || "Premium Stats" stats_bg = s.community_landing_stats_bg_color.presence stats_border = s.community_landing_stats_border_style rescue "none" html << "
\n" html << "

#{e(stats_title)}

\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" # ── 4. ABOUT COMMUNITY ── 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 about_heading_on = s.community_landing_about_heading_enabled rescue true about_heading = s.community_landing_about_heading.presence || "About Community" about_bg = s.community_landing_about_bg_color.presence about_border = s.community_landing_about_border_style rescue "none" html << "
\n" html << "
\n" html << "

#{e(about_heading)}

\n" if about_heading_on 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" html << "
\n" end # ── 5. TRENDING DISCUSSIONS ── if s.community_landing_topics_enabled && @hot_topics&.any? topics_bg = s.community_landing_topics_bg_color.presence topics_border = s.community_landing_topics_border_style rescue "none" html << "
\n" html << "

#{e(s.community_landing_topics_title)}

\n" html << "
\n" @hot_topics.each do |topic| topic_likes = topic.like_count rescue 0 topic_replies = topic.posts_count.to_i html << "\n" if topic.category html << "#{e(topic.category.name)}\n" end html << "#{e(topic.title)}\n" html << "
" html << "#{COMMENT_SVG} #{topic_replies}" html << "#{HEART_SVG} #{topic_likes}" html << "
" html << "
\n" end html << "
\n
\n" end # ── 6. TOP CREATORS ── if s.community_landing_contributors_enabled && @top_contributors&.any? contrib_bg = s.community_landing_contributors_bg_color.presence contrib_border = s.community_landing_contributors_border_style rescue "none" 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") activity_count = user.attributes["post_count"].to_i rescue 0 html << "\n" html << "\"#{e(user.username)}\"\n" html << "@#{e(user.username)}\n" html << "#{activity_count} Activity\n" html << "\n" end html << "
\n
\n" end # ── 7. COMMUNITY SPACES ── if s.community_landing_groups_enabled && @groups&.any? groups_bg = s.community_landing_groups_bg_color.presence groups_border = s.community_landing_groups_border_style rescue "none" 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 sat = 50 + (group.name.bytes.first.to_i % 20) light = 40 + (group.name.bytes.last.to_i % 15) 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" end # ── 8. APP CTA ── 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" app_image = s.community_landing_app_cta_image_url.presence ios_custom = s.community_landing_ios_app_badge_image_url.presence rescue nil android_custom = s.community_landing_android_app_badge_image_url.presence rescue nil app_bg = s.community_landing_app_cta_bg_color.presence app_border = s.community_landing_app_cta_border_style rescue "none" html << "
\n" html << "
\n" html << "
\n" html << "

#{e(s.community_landing_app_cta_headline)}

\n" html << "

#{e(s.community_landing_app_cta_subtext)}

\n" if s.community_landing_app_cta_subtext.present? html << "
\n" if s.community_landing_ios_app_url.present? if ios_custom 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 << "" html << "\"App" html << "\n" else html << "" html << "#{IOS_BADGE_SVG}" html << "App Store" html << "\n" end end if s.community_landing_android_app_url.present? if android_custom 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 << "" html << "\"Google" html << "\n" else html << "" html << "#{ANDROID_BADGE_SVG}" html << "Google Play" html << "\n" end end html << "
\n" html << "
\n" if app_image html << "
\n" html << "\"App\n" html << "
\n" end html << "
\n" html << "
\n" end # ── 9. FOOTER DESCRIPTION ── if s.community_landing_footer_description.present? html << "\n" end # ── 10. FOOTER ── footer_bg = s.community_landing_footer_bg_color.presence rescue nil footer_border = s.community_landing_footer_border_style rescue "solid" footer_style_parts = [] footer_style_parts << "background: #{footer_bg};" if footer_bg footer_style_parts << "border-top: 1px #{footer_border} var(--cl-border);" if footer_border && footer_border != "none" footer_style_attr = footer_style_parts.any? ? " style=\"#{footer_style_parts.join(" ")}\"" : "" html << "\n" html << "\n" html << "\n" html end def stats_counter_card(icon_svg, count, label) "
\n" \ "
\n" \ "#{icon_svg}\n" \ "#{e(label)}\n" \ "
\n" \ "0\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