mirror of
https://github.com/dpnmw/community-landing.git
synced 2026-03-18 09:27:16 +00:00
Plugin Rewrite v2.3.0
This update constitutes a plugin rewrite to manage all options needed in the plugin into separate tabs and split files for maintenance.
This commit is contained in:
60
lib/community_landing/data_fetcher.rb
Normal file
60
lib/community_landing/data_fetcher.rb
Normal file
@@ -0,0 +1,60 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module CommunityLanding
|
||||
class DataFetcher
|
||||
def self.fetch
|
||||
s = SiteSetting
|
||||
data = {}
|
||||
|
||||
# Top contributors
|
||||
data[:contributors] = if s.contributors_enabled
|
||||
User
|
||||
.joins(:posts)
|
||||
.where(posts: { created_at: s.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.contributors_count)
|
||||
.select("users.*, COUNT(posts.id) AS post_count")
|
||||
end
|
||||
|
||||
# Public groups
|
||||
data[:groups] = if s.groups_enabled
|
||||
Group
|
||||
.where(visibility_level: Group.visibility_levels[:public])
|
||||
.where(automatic: false)
|
||||
.limit(s.groups_count)
|
||||
end
|
||||
|
||||
# Trending topics
|
||||
data[:topics] = if s.topics_enabled
|
||||
Topic
|
||||
.listable_topics
|
||||
.where(visible: true)
|
||||
.where("topics.created_at > ?", 30.days.ago)
|
||||
.order(posts_count: :desc)
|
||||
.limit(s.topics_count)
|
||||
.includes(:category, :user)
|
||||
end
|
||||
|
||||
# Aggregate stats
|
||||
chat_count = 0
|
||||
begin
|
||||
chat_count = Chat::Message.count if defined?(Chat::Message)
|
||||
rescue
|
||||
chat_count = 0
|
||||
end
|
||||
|
||||
data[: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,
|
||||
}
|
||||
|
||||
data
|
||||
end
|
||||
end
|
||||
end
|
||||
43
lib/community_landing/helpers.rb
Normal file
43
lib/community_landing/helpers.rb
Normal file
@@ -0,0 +1,43 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module CommunityLanding
|
||||
module Helpers
|
||||
def e(text)
|
||||
ERB::Util.html_escape(text.to_s)
|
||||
end
|
||||
|
||||
# Normalize color values — Discourse color picker stores without #
|
||||
def hex(val)
|
||||
return nil if val.blank?
|
||||
v = val.to_s.delete("#")
|
||||
v.present? ? "##{v}" : nil
|
||||
end
|
||||
|
||||
def hex_to_rgb(hex_val)
|
||||
hex_val = hex_val.to_s.gsub("#", "")
|
||||
return "0, 0, 0" unless hex_val.match?(/\A[0-9a-fA-F]{6}\z/)
|
||||
"#{hex_val[0..1].to_i(16)}, #{hex_val[2..3].to_i(16)}, #{hex_val[4..5].to_i(16)}"
|
||||
end
|
||||
|
||||
# Inline style for section border + min-height (no background — handled by CSS)
|
||||
def section_style(border_style, min_height = 0)
|
||||
parts = []
|
||||
parts << "border-bottom: 1px #{border_style} var(--cl-border);" if border_style.present? && border_style != "none"
|
||||
parts << "min-height: #{min_height}px;" if min_height.to_i > 0
|
||||
parts.any? ? " style=\"#{parts.join(' ')}\"" : ""
|
||||
end
|
||||
|
||||
def logo_img(url, alt, css_class, height)
|
||||
"<img src=\"#{url}\" alt=\"#{e(alt)}\" class=\"#{css_class}\" style=\"height: #{height}px;\">"
|
||||
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
|
||||
end
|
||||
end
|
||||
21
lib/community_landing/icons.rb
Normal file
21
lib/community_landing/icons.rb
Normal file
@@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module CommunityLanding
|
||||
module Icons
|
||||
SUN_SVG = '<svg class="cl-icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>'
|
||||
MOON_SVG = '<svg class="cl-icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>'
|
||||
QUOTE_SVG = '<svg class="cl-about__quote-mark" viewBox="0 0 24 24" fill="currentColor" width="32" height="32"><path d="M6 7h3l2 4v6H5v-6h3zm8 0h3l2 4v6h-6v-6h3z"/></svg>'
|
||||
|
||||
STAT_MEMBERS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>'
|
||||
STAT_TOPICS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>'
|
||||
STAT_POSTS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>'
|
||||
STAT_LIKES_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>'
|
||||
STAT_CHATS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>'
|
||||
|
||||
COMMENT_SVG = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>'
|
||||
HEART_SVG = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>'
|
||||
|
||||
IOS_BADGE_SVG = '<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/></svg>'
|
||||
ANDROID_BADGE_SVG = '<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M17.6 11.48l1.56-2.7a.43.43 0 00-.16-.59.43.43 0 00-.59.16l-1.58 2.73A9.9 9.9 0 0012 10.07a9.9 9.9 0 00-4.83 1.01L5.59 8.35a.43.43 0 00-.59-.16.43.43 0 00-.16.59l1.56 2.7A10.16 10.16 0 002 18h20a10.16 10.16 0 00-4.4-6.52zM7 15.5a1 1 0 110-2 1 1 0 010 2zm10 0a1 1 0 110-2 1 1 0 010 2z"/></svg>'
|
||||
end
|
||||
end
|
||||
464
lib/community_landing/page_builder.rb
Normal file
464
lib/community_landing/page_builder.rb
Normal file
@@ -0,0 +1,464 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module CommunityLanding
|
||||
class PageBuilder
|
||||
include Helpers
|
||||
|
||||
def initialize(data:, css:, js:)
|
||||
@data = data
|
||||
@css = css
|
||||
@js = js
|
||||
@s = SiteSetting
|
||||
@styles = StyleBuilder.new(@s)
|
||||
end
|
||||
|
||||
def build
|
||||
html = +""
|
||||
html << render_head
|
||||
html << "<body class=\"cl-body\">\n"
|
||||
html << render_navbar
|
||||
html << render_hero
|
||||
html << render_stats
|
||||
html << render_about
|
||||
html << render_topics
|
||||
html << render_contributors
|
||||
html << render_groups
|
||||
html << render_app_cta
|
||||
html << render_footer_desc
|
||||
html << render_footer
|
||||
html << "<script>\n#{@js}\n</script>\n"
|
||||
html << "</body>\n</html>"
|
||||
html
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# ── <head> ──
|
||||
|
||||
def render_head
|
||||
site_name = @s.title
|
||||
anim_class = @s.scroll_animation rescue "fade_up"
|
||||
anim_class = "none" if anim_class.blank?
|
||||
og_logo = logo_dark_url || logo_light_url
|
||||
|
||||
html = +""
|
||||
html << "<!DOCTYPE html>\n<html lang=\"en\" data-scroll-anim=\"#{e(anim_class)}\">\n<head>\n"
|
||||
html << "<meta charset=\"UTF-8\">\n"
|
||||
html << "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, viewport-fit=cover\">\n"
|
||||
html << "<meta name=\"color-scheme\" content=\"dark light\">\n"
|
||||
html << "<title>#{e(@s.hero_title)} | #{e(site_name)}</title>\n"
|
||||
html << "<meta name=\"description\" content=\"#{e(@s.hero_subtitle)}\">\n"
|
||||
html << "<meta property=\"og:type\" content=\"website\">\n"
|
||||
html << "<meta property=\"og:title\" content=\"#{e(@s.hero_title)}\">\n"
|
||||
html << "<meta property=\"og:description\" content=\"#{e(@s.hero_subtitle)}\">\n"
|
||||
html << "<meta property=\"og:image\" content=\"#{og_logo}\">\n" if og_logo
|
||||
html << "<meta name=\"twitter:card\" content=\"summary_large_image\">\n"
|
||||
html << "<link rel=\"canonical\" href=\"#{Discourse.base_url}\">\n"
|
||||
html << "<style>\n#{@css}\n</style>\n"
|
||||
html << @styles.color_overrides
|
||||
html << @styles.section_backgrounds
|
||||
|
||||
custom_css = @s.custom_css.presence rescue nil
|
||||
html << "<style>\n/* Custom CSS */\n#{custom_css}\n</style>\n" if custom_css
|
||||
|
||||
html << "</head>\n"
|
||||
html
|
||||
end
|
||||
|
||||
# ── 1. NAVBAR ──
|
||||
|
||||
def render_navbar
|
||||
site_name = @s.title
|
||||
signin_label = @s.navbar_signin_label.presence || "Sign In"
|
||||
join_label = @s.navbar_join_label.presence || "Get Started"
|
||||
navbar_bg = hex(@s.navbar_bg_color) rescue nil
|
||||
navbar_border = @s.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 = +""
|
||||
html << "<nav class=\"cl-navbar\" id=\"cl-navbar\"#{navbar_data}><div class=\"cl-navbar__inner\">\n"
|
||||
html << "<div class=\"cl-navbar__left\">"
|
||||
html << "<a href=\"/\" class=\"cl-navbar__brand\">"
|
||||
if has_logo?
|
||||
html << render_logo(logo_dark_url, logo_light_url, site_name, "cl-navbar__logo", logo_height)
|
||||
else
|
||||
html << "<span class=\"cl-navbar__site-name\">#{e(site_name)}</span>"
|
||||
end
|
||||
html << "</a>\n</div>"
|
||||
|
||||
html << "<div class=\"cl-navbar__right\">"
|
||||
html << theme_toggle
|
||||
html << "<a href=\"#{login_url}\" class=\"cl-navbar__link cl-btn--ghost\">#{e(signin_label)}</a>\n"
|
||||
html << "<a href=\"#{login_url}\" class=\"cl-navbar__link cl-btn--primary\">#{e(join_label)}</a>\n"
|
||||
html << "</div>"
|
||||
|
||||
html << "<button class=\"cl-navbar__hamburger\" id=\"cl-hamburger\" aria-label=\"Toggle menu\"><span></span><span></span><span></span></button>\n"
|
||||
html << "<div class=\"cl-navbar__mobile-menu\" id=\"cl-nav-links\">\n"
|
||||
html << theme_toggle
|
||||
html << "<a href=\"#{login_url}\" class=\"cl-navbar__link cl-btn--ghost\">#{e(signin_label)}</a>\n"
|
||||
html << "<a href=\"#{login_url}\" class=\"cl-navbar__link cl-btn--primary\">#{e(join_label)}</a>\n"
|
||||
html << "</div>"
|
||||
html << "</div></nav>\n"
|
||||
html
|
||||
end
|
||||
|
||||
# ── 2. HERO ──
|
||||
|
||||
def render_hero
|
||||
hero_card = @s.hero_card_enabled rescue true
|
||||
hero_bg_img = @s.hero_background_image_url.presence
|
||||
hero_border = @s.hero_border_style rescue "none"
|
||||
hero_min_h = @s.hero_min_height rescue 0
|
||||
site_name = @s.title
|
||||
|
||||
html = +""
|
||||
html << "<section class=\"cl-hero#{hero_card ? ' cl-hero--card' : ''}\" id=\"cl-hero\"#{section_style(hero_border, hero_min_h)}>\n"
|
||||
|
||||
if hero_bg_img && !hero_card
|
||||
html << "<div class=\"cl-hero__bg\" style=\"background-image: url('#{hero_bg_img}');\"></div>\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 << "<div class=\"cl-hero__inner\"#{inner_style}>\n<div class=\"cl-hero__content\">\n"
|
||||
|
||||
title_words = @s.hero_title.to_s.split(" ")
|
||||
if title_words.length > 1
|
||||
html << "<h1 class=\"cl-hero__title\">#{e(title_words[0..-2].join(' '))} <span class=\"cl-hero__title-accent\">#{e(title_words.last)}</span></h1>\n"
|
||||
else
|
||||
html << "<h1 class=\"cl-hero__title\"><span class=\"cl-hero__title-accent\">#{e(@s.hero_title)}</span></h1>\n"
|
||||
end
|
||||
|
||||
html << "<p class=\"cl-hero__subtitle\">#{e(@s.hero_subtitle)}</p>\n"
|
||||
|
||||
primary_label = @s.hero_primary_button_label.presence || "View Latest Topics"
|
||||
primary_url = @s.hero_primary_button_url.presence || "/latest"
|
||||
secondary_label = @s.hero_secondary_button_label.presence || "Explore Our Spaces"
|
||||
secondary_url = @s.hero_secondary_button_url.presence || login_url
|
||||
|
||||
html << "<div class=\"cl-hero__actions\">\n"
|
||||
html << "<a href=\"#{primary_url}\" class=\"cl-btn cl-btn--primary cl-btn--lg\">#{e(primary_label)}</a>\n"
|
||||
html << "<a href=\"#{secondary_url}\" class=\"cl-btn cl-btn--ghost cl-btn--lg\">#{e(secondary_label)}</a>\n"
|
||||
html << "</div>\n</div>\n"
|
||||
|
||||
hero_image_urls_raw = @s.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.hero_image_max_height rescue 500
|
||||
html << "<div class=\"cl-hero__image\" data-hero-images=\"#{e(urls.to_json)}\">\n"
|
||||
html << "<img src=\"#{urls.first}\" alt=\"#{e(site_name)}\" class=\"cl-hero__image-img\" style=\"max-height: #{img_max_h}px;\">\n"
|
||||
html << "</div>\n"
|
||||
end
|
||||
end
|
||||
|
||||
html << "</div></section>\n"
|
||||
html
|
||||
end
|
||||
|
||||
# ── 3. STATS ──
|
||||
|
||||
def render_stats
|
||||
stats = @data[:stats]
|
||||
stats_title = @s.stats_title.presence || "Premium Stats"
|
||||
border = @s.stats_border_style rescue "none"
|
||||
min_h = @s.stats_min_height rescue 0
|
||||
|
||||
html = +""
|
||||
html << "<section class=\"cl-stats cl-anim\" id=\"cl-stats-row\"#{section_style(border, min_h)}><div class=\"cl-container\">\n"
|
||||
html << "<h2 class=\"cl-section-title\">#{e(stats_title)}</h2>\n"
|
||||
html << "<div class=\"cl-stats__grid\">\n"
|
||||
html << stat_card(Icons::STAT_MEMBERS_SVG, stats[:members], @s.stat_members_label)
|
||||
html << stat_card(Icons::STAT_TOPICS_SVG, stats[:topics], @s.stat_topics_label)
|
||||
html << stat_card(Icons::STAT_POSTS_SVG, stats[:posts], @s.stat_posts_label)
|
||||
html << stat_card(Icons::STAT_LIKES_SVG, stats[:likes], @s.stat_likes_label)
|
||||
html << stat_card(Icons::STAT_CHATS_SVG, stats[:chats], @s.stat_chats_label)
|
||||
html << "</div>\n</div></section>\n"
|
||||
html
|
||||
end
|
||||
|
||||
# ── 4. ABOUT ──
|
||||
|
||||
def render_about
|
||||
return "" unless @s.about_enabled
|
||||
|
||||
about_body = @s.about_body.presence || ""
|
||||
about_image = @s.about_image_url.presence
|
||||
about_role = @s.about_role.presence || @s.title
|
||||
about_heading_on = @s.about_heading_enabled rescue true
|
||||
about_heading = @s.about_heading.presence || "About Community"
|
||||
border = @s.about_border_style rescue "none"
|
||||
min_h = @s.about_min_height rescue 0
|
||||
|
||||
html = +""
|
||||
html << "<section class=\"cl-about cl-anim\" id=\"cl-about\"#{section_style(border, min_h)}><div class=\"cl-container\">\n"
|
||||
html << "<div class=\"cl-about__card\">\n"
|
||||
html << "<h2 class=\"cl-about__heading\">#{e(about_heading)}</h2>\n" if about_heading_on
|
||||
html << Icons::QUOTE_SVG
|
||||
html << "<div class=\"cl-about__body\">#{about_body}</div>\n" if about_body.present?
|
||||
html << "<div class=\"cl-about__meta\">\n"
|
||||
html << "<img src=\"#{about_image}\" alt=\"\" class=\"cl-about__avatar\">\n" if about_image
|
||||
html << "<div class=\"cl-about__meta-text\">\n"
|
||||
html << "<span class=\"cl-about__author\">#{e(@s.about_title)}</span>\n"
|
||||
html << "<span class=\"cl-about__role\">#{e(about_role)}</span>\n"
|
||||
html << "</div></div>\n</div>\n</div></section>\n"
|
||||
html
|
||||
end
|
||||
|
||||
# ── 5. TRENDING DISCUSSIONS ──
|
||||
|
||||
def render_topics
|
||||
topics = @data[:topics]
|
||||
return "" unless @s.topics_enabled && topics&.any?
|
||||
|
||||
border = @s.topics_border_style rescue "none"
|
||||
min_h = @s.topics_min_height rescue 0
|
||||
|
||||
html = +""
|
||||
html << "<section class=\"cl-topics cl-anim\" id=\"cl-topics\"#{section_style(border, min_h)}><div class=\"cl-container\">\n"
|
||||
html << "<h2 class=\"cl-section-title\">#{e(@s.topics_title)}</h2>\n"
|
||||
html << "<div class=\"cl-topics__scroll\">\n"
|
||||
|
||||
topics.each do |topic|
|
||||
topic_likes = topic.like_count rescue 0
|
||||
topic_replies = topic.posts_count.to_i
|
||||
|
||||
html << "<a href=\"#{login_url}\" class=\"cl-topic-card\">\n"
|
||||
if topic.category
|
||||
html << "<span class=\"cl-topic-card__cat\" style=\"--cat-color: ##{topic.category.color}\">#{e(topic.category.name)}</span>\n"
|
||||
end
|
||||
html << "<span class=\"cl-topic-card__title\">#{e(topic.title)}</span>\n"
|
||||
html << "<div class=\"cl-topic-card__meta\">"
|
||||
html << "<span class=\"cl-topic-card__stat\">#{Icons::COMMENT_SVG} #{topic_replies}</span>"
|
||||
html << "<span class=\"cl-topic-card__stat\">#{Icons::HEART_SVG} #{topic_likes}</span>"
|
||||
html << "</div></a>\n"
|
||||
end
|
||||
|
||||
html << "</div>\n</div></section>\n"
|
||||
html
|
||||
end
|
||||
|
||||
# ── 6. TOP CREATORS ──
|
||||
|
||||
def render_contributors
|
||||
contributors = @data[:contributors]
|
||||
return "" unless @s.contributors_enabled && contributors&.any?
|
||||
|
||||
border = @s.contributors_border_style rescue "none"
|
||||
min_h = @s.contributors_min_height rescue 0
|
||||
|
||||
html = +""
|
||||
html << "<section class=\"cl-creators cl-anim\" id=\"cl-contributors\"#{section_style(border, min_h)}><div class=\"cl-container\">\n"
|
||||
html << "<h2 class=\"cl-section-title\">#{e(@s.contributors_title)}</h2>\n"
|
||||
html << "<div class=\"cl-creators__list\">\n"
|
||||
|
||||
contributors.each do |user|
|
||||
avatar_url = user.avatar_template.gsub("{size}", "120")
|
||||
activity_count = user.attributes["post_count"].to_i rescue 0
|
||||
|
||||
html << "<a href=\"#{login_url}\" class=\"cl-creator-pill\">\n"
|
||||
html << "<img src=\"#{avatar_url}\" alt=\"#{e(user.username)}\" class=\"cl-creator-pill__avatar\" loading=\"lazy\">\n"
|
||||
html << "<span class=\"cl-creator-pill__name\">@#{e(user.username)}</span>\n"
|
||||
html << "<span class=\"cl-creator-pill__count\">#{activity_count}</span>\n"
|
||||
html << "</a>\n"
|
||||
end
|
||||
|
||||
html << "</div>\n</div></section>\n"
|
||||
html
|
||||
end
|
||||
|
||||
# ── 7. COMMUNITY SPACES ──
|
||||
|
||||
def render_groups
|
||||
groups = @data[:groups]
|
||||
return "" unless @s.groups_enabled && groups&.any?
|
||||
|
||||
border = @s.groups_border_style rescue "none"
|
||||
min_h = @s.groups_min_height rescue 0
|
||||
|
||||
html = +""
|
||||
html << "<section class=\"cl-spaces cl-anim\" id=\"cl-groups\"#{section_style(border, min_h)}><div class=\"cl-container\">\n"
|
||||
html << "<h2 class=\"cl-section-title\">#{e(@s.groups_title)}</h2>\n"
|
||||
html << "<div class=\"cl-spaces__grid\">\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 << "<a href=\"#{login_url}\" class=\"cl-space-card\">\n"
|
||||
html << "<div class=\"cl-space-card__icon\" style=\"background: hsl(#{hue}, #{sat}%, #{light}%)\">"
|
||||
if group.flair_url.present?
|
||||
html << "<img src=\"#{group.flair_url}\" alt=\"\">"
|
||||
else
|
||||
html << "<span class=\"cl-space-card__letter\">#{group.name[0].upcase}</span>"
|
||||
end
|
||||
html << "</div>\n"
|
||||
html << "<span class=\"cl-space-card__name\">#{e(display_name)}</span>\n"
|
||||
html << "<span class=\"cl-space-card__sub\">#{group.user_count} members</span>\n"
|
||||
html << "</a>\n"
|
||||
end
|
||||
|
||||
html << "</div>\n</div></section>\n"
|
||||
html
|
||||
end
|
||||
|
||||
# ── 8. APP CTA ──
|
||||
|
||||
def render_app_cta
|
||||
return "" unless @s.show_app_ctas && (@s.ios_app_url.present? || @s.android_app_url.present?)
|
||||
|
||||
badge_h = @s.app_badge_height rescue 45
|
||||
badge_style = @s.app_badge_style rescue "rounded"
|
||||
app_image = @s.app_cta_image_url.presence
|
||||
ios_custom = @s.ios_app_badge_image_url.presence rescue nil
|
||||
android_custom = @s.android_app_badge_image_url.presence rescue nil
|
||||
border = @s.app_cta_border_style rescue "none"
|
||||
min_h = @s.app_cta_min_height rescue 0
|
||||
|
||||
html = +""
|
||||
html << "<section class=\"cl-app-cta cl-anim\" id=\"cl-app-cta\"#{section_style(border, min_h)}><div class=\"cl-container\">\n"
|
||||
html << "<div class=\"cl-app-cta__inner\">\n<div class=\"cl-app-cta__content\">\n"
|
||||
html << "<h2 class=\"cl-app-cta__headline\">#{e(@s.app_cta_headline)}</h2>\n"
|
||||
html << "<p class=\"cl-app-cta__subtext\">#{e(@s.app_cta_subtext)}</p>\n" if @s.app_cta_subtext.present?
|
||||
html << "<div class=\"cl-app-cta__badges\">\n"
|
||||
|
||||
html << app_badge(:ios, @s.ios_app_url, ios_custom, badge_h, badge_style) if @s.ios_app_url.present?
|
||||
html << app_badge(:android, @s.android_app_url, android_custom, badge_h, badge_style) if @s.android_app_url.present?
|
||||
|
||||
html << "</div>\n</div>\n"
|
||||
if app_image
|
||||
html << "<div class=\"cl-app-cta__image\">\n<img src=\"#{app_image}\" alt=\"App preview\" class=\"cl-app-cta__img\">\n</div>\n"
|
||||
end
|
||||
html << "</div>\n</div></section>\n"
|
||||
html
|
||||
end
|
||||
|
||||
# ── 9. FOOTER DESCRIPTION ──
|
||||
|
||||
def render_footer_desc
|
||||
return "" unless @s.footer_description.present?
|
||||
|
||||
html = +""
|
||||
html << "<div class=\"cl-footer-desc\"><div class=\"cl-container\">\n"
|
||||
html << "<p class=\"cl-footer-desc__text\">#{@s.footer_description}</p>\n"
|
||||
html << "</div></div>\n"
|
||||
html
|
||||
end
|
||||
|
||||
# ── 10. FOOTER ──
|
||||
|
||||
def render_footer
|
||||
site_name = @s.title
|
||||
footer_border = @s.footer_border_style rescue "solid"
|
||||
|
||||
style_parts = []
|
||||
style_parts << "border-top: 1px #{footer_border} var(--cl-border);" if footer_border && footer_border != "none"
|
||||
style_attr = style_parts.any? ? " style=\"#{style_parts.join(' ')}\"" : ""
|
||||
|
||||
html = +""
|
||||
html << "<footer class=\"cl-footer\" id=\"cl-footer\"#{style_attr}>\n<div class=\"cl-container\">\n"
|
||||
html << "<div class=\"cl-footer__row\">\n<div class=\"cl-footer__left\">\n"
|
||||
html << "<div class=\"cl-footer__brand\">"
|
||||
|
||||
flogo = @s.footer_logo_url.presence
|
||||
if flogo
|
||||
html << "<img src=\"#{flogo}\" alt=\"#{e(site_name)}\" class=\"cl-footer__logo\" style=\"height: #{logo_height}px;\">"
|
||||
elsif has_logo?
|
||||
html << render_logo(logo_dark_url, logo_light_url, site_name, "cl-footer__logo", logo_height)
|
||||
else
|
||||
html << "<span class=\"cl-footer__site-name\">#{e(site_name)}</span>"
|
||||
end
|
||||
|
||||
html << "</div>\n<div class=\"cl-footer__links\">\n"
|
||||
begin
|
||||
links = JSON.parse(@s.footer_links)
|
||||
links.each { |link| html << "<a href=\"#{link['url']}\" class=\"cl-footer__link\">#{e(link['label'])}</a>\n" }
|
||||
rescue JSON::ParserError
|
||||
end
|
||||
html << "</div>\n</div>\n"
|
||||
|
||||
html << "<div class=\"cl-footer__right\">\n"
|
||||
html << "<span class=\"cl-footer__copy\">© #{Time.now.year} #{e(site_name)}</span>\n"
|
||||
html << "</div>\n</div>\n"
|
||||
|
||||
html << "<div class=\"cl-footer__text\">#{@s.footer_text}</div>\n" if @s.footer_text.present?
|
||||
|
||||
html << "</div></footer>\n"
|
||||
html
|
||||
end
|
||||
|
||||
# ── Shared helpers ──
|
||||
|
||||
def stat_card(icon_svg, count, label)
|
||||
"<div class=\"cl-stat-card\">\n" \
|
||||
"<div class=\"cl-stat-card__top\">\n" \
|
||||
"<span class=\"cl-stat-card__icon\">#{icon_svg}</span>\n" \
|
||||
"<span class=\"cl-stat-card__label\">#{e(label)}</span>\n" \
|
||||
"</div>\n" \
|
||||
"<span class=\"cl-stat-card__value\" data-count=\"#{count}\">0</span>\n" \
|
||||
"</div>\n"
|
||||
end
|
||||
|
||||
def app_badge(platform, url, custom_img, badge_h, badge_style)
|
||||
label = platform == :ios ? "App Store" : "Google Play"
|
||||
icon = platform == :ios ? Icons::IOS_BADGE_SVG : Icons::ANDROID_BADGE_SVG
|
||||
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
|
||||
|
||||
if custom_img
|
||||
"<a href=\"#{url}\" class=\"cl-app-badge-img #{style_class}\" target=\"_blank\" rel=\"noopener noreferrer\">" \
|
||||
"<img src=\"#{custom_img}\" alt=\"#{label}\" style=\"height: #{badge_h}px; width: auto;\">" \
|
||||
"</a>\n"
|
||||
else
|
||||
"<a href=\"#{url}\" class=\"cl-app-badge #{style_class}\" target=\"_blank\" rel=\"noopener noreferrer\">" \
|
||||
"<span class=\"cl-app-badge__icon\">#{icon}</span>" \
|
||||
"<span class=\"cl-app-badge__label\">#{label}</span>" \
|
||||
"</a>\n"
|
||||
end
|
||||
end
|
||||
|
||||
def theme_toggle
|
||||
"<button class=\"cl-theme-toggle\" aria-label=\"Toggle theme\">#{Icons::SUN_SVG}#{Icons::MOON_SVG}</button>\n"
|
||||
end
|
||||
|
||||
def login_url
|
||||
"/login"
|
||||
end
|
||||
|
||||
# ── Logo memoization ──
|
||||
|
||||
def logo_dark_url
|
||||
return @logo_dark_url if defined?(@logo_dark_url)
|
||||
dark = @s.logo_dark_url.presence
|
||||
light = @s.logo_light_url.presence
|
||||
if dark.nil? && light.nil?
|
||||
dark = @s.respond_to?(:logo_url) ? @s.logo_url.presence : nil
|
||||
end
|
||||
@logo_dark_url = dark
|
||||
end
|
||||
|
||||
def logo_light_url
|
||||
return @logo_light_url if defined?(@logo_light_url)
|
||||
@logo_light_url = @s.logo_light_url.presence
|
||||
end
|
||||
|
||||
def has_logo?
|
||||
logo_dark_url.present? || logo_light_url.present?
|
||||
end
|
||||
|
||||
def logo_height
|
||||
@logo_height ||= (@s.logo_height rescue 30)
|
||||
end
|
||||
end
|
||||
end
|
||||
114
lib/community_landing/style_builder.rb
Normal file
114
lib/community_landing/style_builder.rb
Normal file
@@ -0,0 +1,114 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module CommunityLanding
|
||||
class StyleBuilder
|
||||
include Helpers
|
||||
|
||||
def initialize(settings = SiteSetting)
|
||||
@s = settings
|
||||
end
|
||||
|
||||
# CSS custom properties for accent colors, gradients, backgrounds
|
||||
def color_overrides
|
||||
accent = hex(@s.accent_color) || "#d4a24e"
|
||||
accent_hover = hex(@s.accent_hover_color) || "#c4922e"
|
||||
dark_bg = hex(@s.dark_bg_color) || "#06060f"
|
||||
light_bg = hex(@s.light_bg_color) || "#faf6f0"
|
||||
stat_icon = hex(@s.stat_icon_color) || accent
|
||||
about_g1 = hex(@s.about_gradient_start) || "#fdf6ec"
|
||||
about_g2 = hex(@s.about_gradient_mid) || "#fef9f0"
|
||||
about_g3 = hex(@s.about_gradient_end) || "#fdf6ec"
|
||||
about_bg_img = @s.about_background_image_url.presence
|
||||
app_g1 = hex(@s.app_cta_gradient_start) || accent
|
||||
app_g2 = hex(@s.app_cta_gradient_mid) || accent_hover
|
||||
app_g3 = hex(@s.app_cta_gradient_end) || accent_hover
|
||||
accent_rgb = hex_to_rgb(accent)
|
||||
|
||||
about_bg_extra = about_bg_img ? ", url('#{about_bg_img}') center/cover no-repeat" : ""
|
||||
|
||||
"<style>
|
||||
:root, [data-theme=\"dark\"] {
|
||||
--cl-accent: #{accent};
|
||||
--cl-accent-hover: #{accent_hover};
|
||||
--cl-accent-glow: rgba(#{accent_rgb}, 0.35);
|
||||
--cl-accent-subtle: rgba(#{accent_rgb}, 0.08);
|
||||
--cl-bg: #{dark_bg};
|
||||
--cl-hero-bg: #{dark_bg};
|
||||
--cl-gradient-text: linear-gradient(135deg, #{accent_hover}, #{accent}, #{accent_hover});
|
||||
--cl-border-hover: rgba(#{accent_rgb}, 0.25);
|
||||
--cl-orb-1: rgba(#{accent_rgb}, 0.12);
|
||||
--cl-stat-icon-color: #{stat_icon};
|
||||
--cl-about-gradient: linear-gradient(135deg, #{about_g1}, #{about_g2}, #{about_g3})#{about_bg_extra};
|
||||
--cl-app-gradient: linear-gradient(135deg, #{app_g1}, #{app_g2}, #{app_g3});
|
||||
}
|
||||
[data-theme=\"light\"] {
|
||||
--cl-accent: #{accent};
|
||||
--cl-accent-hover: #{accent_hover};
|
||||
--cl-accent-glow: rgba(#{accent_rgb}, 0.2);
|
||||
--cl-accent-subtle: rgba(#{accent_rgb}, 0.06);
|
||||
--cl-bg: #{light_bg};
|
||||
--cl-hero-bg: #{light_bg};
|
||||
--cl-gradient-text: linear-gradient(135deg, #{accent}, #{accent_hover}, #{accent});
|
||||
--cl-border-hover: rgba(#{accent_rgb}, 0.3);
|
||||
--cl-orb-1: rgba(#{accent_rgb}, 0.08);
|
||||
--cl-stat-icon-color: #{stat_icon};
|
||||
--cl-about-gradient: linear-gradient(135deg, #{about_g1}, #{about_g2}, #{about_g3})#{about_bg_extra};
|
||||
--cl-app-gradient: linear-gradient(135deg, #{app_g1}, #{app_g2}, #{app_g3});
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root:not([data-theme=\"dark\"]) {
|
||||
--cl-accent: #{accent};
|
||||
--cl-accent-hover: #{accent_hover};
|
||||
--cl-accent-glow: rgba(#{accent_rgb}, 0.2);
|
||||
--cl-accent-subtle: rgba(#{accent_rgb}, 0.06);
|
||||
--cl-bg: #{light_bg};
|
||||
--cl-hero-bg: #{light_bg};
|
||||
--cl-gradient-text: linear-gradient(135deg, #{accent}, #{accent_hover}, #{accent});
|
||||
--cl-border-hover: rgba(#{accent_rgb}, 0.3);
|
||||
--cl-orb-1: rgba(#{accent_rgb}, 0.08);
|
||||
--cl-stat-icon-color: #{stat_icon};
|
||||
--cl-about-gradient: linear-gradient(135deg, #{about_g1}, #{about_g2}, #{about_g3})#{about_bg_extra};
|
||||
--cl-app-gradient: linear-gradient(135deg, #{app_g1}, #{app_g2}, #{app_g3});
|
||||
}
|
||||
}
|
||||
</style>\n"
|
||||
end
|
||||
|
||||
# Per-section dark/light background overrides
|
||||
def section_backgrounds
|
||||
css = +""
|
||||
sections = [
|
||||
["#cl-hero", safe_hex(:hero_bg_dark), safe_hex(:hero_bg_light)],
|
||||
["#cl-stats-row", safe_hex(:stats_bg_dark), safe_hex(:stats_bg_light)],
|
||||
["#cl-about", safe_hex(:about_bg_dark), safe_hex(:about_bg_light)],
|
||||
["#cl-topics", safe_hex(:topics_bg_dark), safe_hex(:topics_bg_light)],
|
||||
["#cl-contributors", safe_hex(:contributors_bg_dark), safe_hex(:contributors_bg_light)],
|
||||
["#cl-groups", safe_hex(:groups_bg_dark), safe_hex(:groups_bg_light)],
|
||||
["#cl-app-cta", safe_hex(:app_cta_bg_dark), safe_hex(:app_cta_bg_light)],
|
||||
["#cl-footer", safe_hex(:footer_bg_dark), safe_hex(:footer_bg_light)],
|
||||
]
|
||||
|
||||
sections.each do |sel, dark_bg, light_bg|
|
||||
next unless dark_bg || light_bg
|
||||
if dark_bg
|
||||
css << ":root #{sel}, [data-theme=\"dark\"] #{sel} { background: #{dark_bg}; }\n"
|
||||
end
|
||||
if light_bg
|
||||
css << "[data-theme=\"light\"] #{sel} { background: #{light_bg}; }\n"
|
||||
css << "@media (prefers-color-scheme: light) { :root:not([data-theme=\"dark\"]) #{sel} { background: #{light_bg}; } }\n"
|
||||
end
|
||||
end
|
||||
|
||||
css.present? ? "<style>\n#{css}</style>\n" : ""
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Safe accessor — returns nil if the setting doesn't exist
|
||||
def safe_hex(setting_name)
|
||||
hex(@s.public_send(setting_name))
|
||||
rescue
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user