diff --git a/assets/javascripts/community_landing/landing.js b/assets/javascripts/community_landing/landing.js index f08431b..b309679 100644 --- a/assets/javascripts/community_landing/landing.js +++ b/assets/javascripts/community_landing/landing.js @@ -118,11 +118,19 @@ // ═══════════════════════════════════════════════════════════════════ // 6. STAT COUNTER // ═══════════════════════════════════════════════════════════════════ + function formatRounded(n) { + if (n < 1000) return n.toLocaleString(); + if (n < 10000) return (n / 1000).toFixed(1).replace(/\.0$/, "") + "K"; + if (n < 1000000) return Math.round(n / 1000) + "K"; + return (n / 1000000).toFixed(1).replace(/\.0$/, "") + "M"; + } + function animateCount(el) { if (el.classList.contains("counted")) return; el.classList.add("counted"); var target = parseInt(el.getAttribute("data-count"), 10); if (isNaN(target) || target === 0) return; + var round = el.getAttribute("data-round") === "true"; var duration = 2000; var start = null; @@ -131,9 +139,10 @@ function step(ts) { if (!start) start = ts; var p = Math.min((ts - start) / duration, 1); - el.textContent = Math.floor(target * ease(p)).toLocaleString(); + var current = Math.floor(target * ease(p)); + el.textContent = round ? formatRounded(current) : current.toLocaleString(); if (p < 1) requestAnimationFrame(step); - else el.textContent = target.toLocaleString(); + else el.textContent = round ? formatRounded(target) : target.toLocaleString(); } requestAnimationFrame(step); } diff --git a/assets/javascripts/discourse/initializers/community-landing-admin-tabs.js b/assets/javascripts/discourse/initializers/community-landing-admin-tabs.js index c122b3b..7ab4a80 100644 --- a/assets/javascripts/discourse/initializers/community-landing-admin-tabs.js +++ b/assets/javascripts/discourse/initializers/community-landing-admin-tabs.js @@ -27,7 +27,7 @@ const TABS = [ "hero_image_urls", "hero_image_max_height", "hero_primary_button_label", "hero_primary_button_url", "hero_secondary_button_label", "hero_secondary_button_url", - "hero_video_url", + "hero_video_url", "hero_video_button_color", "hero_video_blur_on_hover", "hero_bg_dark", "hero_bg_light", "hero_min_height", "hero_border_style" ]) }, @@ -37,7 +37,7 @@ const TABS = [ settings: new Set([ "stats_title", "stat_icon_color", "stat_icon_bg_color", "stat_icon_shape", "stat_counter_color", "stat_members_label", "stat_topics_label", "stat_posts_label", - "stat_likes_label", "stat_chats_label", + "stat_likes_label", "stat_chats_label", "stat_round_numbers", "stats_bg_dark", "stats_bg_light", "stats_min_height", "stats_border_style" ]) }, @@ -184,7 +184,8 @@ function buildTabsUI() { if (!container) return false; // Already injected — just re-apply filter - if (container.querySelector(".cl-admin-tab")) { + // Search broadly: native tabs may be a sibling of container, not a child + if (document.querySelector(".cl-admin-tab")) { applyTabFilter(); return true; } @@ -199,7 +200,10 @@ function buildTabsUI() { if (!hasOurs) return false; // ── Strategy 1: Inject into native Discourse tab bar ── - const nativeTabsEl = container.querySelector(".admin-plugin-config-area__tabs"); + // Native tabs may be a sibling of our container, so search at page level + const page = container.closest(".admin-plugin-config-page") || container.parentElement; + const nativeTabsEl = (page && page.querySelector(".admin-plugin-config-area__tabs")) || + document.querySelector(".admin-plugin-config-area__tabs"); if (nativeTabsEl) { // Find the native "Settings" link and hook into it const nativeLink = nativeTabsEl.querySelector("a"); @@ -326,7 +330,7 @@ export default { return; } const c = getContainer(); - if (c && !c.querySelector(".cl-admin-tab")) { + if (c && !document.querySelector(".cl-admin-tab")) { buildTabsUI(); } }, 500); diff --git a/assets/stylesheets/community_landing/landing.css b/assets/stylesheets/community_landing/landing.css index b9ea8ff..19ffbd7 100644 --- a/assets/stylesheets/community_landing/landing.css +++ b/assets/stylesheets/community_landing/landing.css @@ -739,7 +739,7 @@ height: 80px; border-radius: 50%; border: none; - background: var(--cl-accent); + background: var(--cl-video-btn-bg, var(--cl-accent)); color: #fff; cursor: pointer; display: flex; @@ -752,7 +752,7 @@ .cl-hero-play:hover { transform: translate(-50%, -50%) scale(1.1); - background: var(--cl-accent-hover); + background: var(--cl-video-btn-bg, var(--cl-accent-hover)); box-shadow: 0 12px 40px var(--cl-accent-glow); } @@ -882,26 +882,44 @@ } } +/* ── Blur hero image on play button hover ── */ +.cl-hero__image:has(.cl-hero-play[data-blur-hover="true"]:hover) .cl-hero__image-img { + filter: blur(4px); + transition: filter 0.4s ease; +} + +.cl-hero__image .cl-hero__image-img { + transition: filter 0.4s ease; +} + +/* ── Hero Creators (top 3 pills in hero) ── */ +.cl-hero__creators { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 2rem; +} + /* ═══════════════════════════════════════════════════════════════════ - 3. PREMIUM STATS — icon with bg, label, animated counter + 3. COMPACT STATS BAR — icon + count + label inline ═══════════════════════════════════════════════════════════════════ */ .cl-stats { - padding: 2.5rem 0 2rem; + padding: 2rem 0 1.5rem; } .cl-stats__grid { display: grid; grid-template-columns: repeat(2, 1fr); - gap: 0.75rem; + gap: 0.6rem; } -@media (min-width: 640px) { +@media (min-width: 480px) { .cl-stats__grid { grid-template-columns: repeat(3, 1fr); } } -@media (min-width: 1024px) { +@media (min-width: 768px) { .cl-stats__grid { grid-template-columns: repeat(5, 1fr); } @@ -909,14 +927,13 @@ .cl-stat-card { display: flex; - flex-direction: column; + flex-direction: row; align-items: center; - gap: 1rem; - padding: 2.5rem 1.5rem; + gap: 0.75rem; + padding: 1rem 1.25rem; background: var(--cl-card); border: 1px solid var(--cl-border); - border-radius: var(--cl-radius); - text-align: center; + border-radius: var(--cl-radius-sm); backdrop-filter: var(--cl-blur); -webkit-backdrop-filter: var(--cl-blur); transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1); @@ -924,18 +941,18 @@ .cl-stat-card:hover { border-color: var(--cl-accent); - transform: translateY(-10px); - box-shadow: 0 20px 40px var(--cl-shadow); + transform: translateY(-4px); + box-shadow: 0 12px 28px var(--cl-shadow); background: var(--cl-glass); } -/* Icon with background */ .cl-stat-card__icon-wrap { display: flex; align-items: center; justify-content: center; - width: 56px; - height: 56px; + width: 36px; + height: 36px; + flex-shrink: 0; background: var(--cl-stat-icon-bg); color: var(--cl-stat-icon-color); transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); @@ -948,8 +965,8 @@ } .cl-stat-card__icon-wrap svg { - width: 28px; - height: 28px; + width: 18px; + height: 18px; } .cl-stat-icon--circle { @@ -957,24 +974,32 @@ } .cl-stat-icon--rounded { - border-radius: 16px; + border-radius: 10px; +} + +.cl-stat-card__text { + display: flex; + flex-direction: column; + min-width: 0; +} + +.cl-stat-card__value { + font-size: 1.3rem; + font-weight: 900; + color: var(--cl-stat-counter-color, var(--cl-text-strong)); + letter-spacing: -0.02em; + line-height: 1.1; } .cl-stat-card__label { - font-size: 0.85rem; + font-size: 0.72rem; color: var(--cl-muted); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; -} - -/* Counter */ -.cl-stat-card__value { - font-size: 2rem; - font-weight: 900; - color: var(--cl-stat-counter-color, var(--cl-text-strong)); - letter-spacing: -0.02em; - line-height: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } /* ═══════════════════════════════════════════════════════════════════ @@ -1266,7 +1291,7 @@ } /* ═══════════════════════════════════════════════════════════════════ - 7. COMMUNITY SPACES — colored header cards + 7. COMMUNITY SPACES — compact horizontal pills with accent stripe ═══════════════════════════════════════════════════════════════════ */ .cl-spaces { padding: 1.5rem 0 2rem; @@ -1274,85 +1299,61 @@ .cl-spaces__grid { display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 0.85rem; + grid-template-columns: 1fr; + gap: 0.6rem; } @media (min-width: 480px) { .cl-spaces__grid { - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(2, 1fr); } } @media (min-width: 768px) { .cl-spaces__grid { - grid-template-columns: repeat(4, 1fr); - } -} - -@media (min-width: 1024px) { - .cl-spaces__grid { - grid-template-columns: repeat(5, 1fr); + grid-template-columns: repeat(3, 1fr); } } .cl-space-card { display: flex; - flex-direction: column; + flex-direction: row; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 1rem 0.6rem 0.6rem; background: var(--cl-space-card-bg, var(--cl-card)); border: 1px solid var(--cl-border); - border-radius: var(--cl-radius); + border-left: 3px solid var(--space-color); + border-radius: var(--cl-radius-sm); text-decoration: none; - overflow: hidden; - transition: all 0.5s cubic-bezier(0.16, 1, 0.3, 1); + transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1); backdrop-filter: var(--cl-blur); -webkit-backdrop-filter: var(--cl-blur); } .cl-space-card:hover { border-color: var(--cl-accent); - transform: translateY(-10px); - box-shadow: 0 25px 50px var(--cl-shadow); -} - -/* Colored header band */ -.cl-space-card__header { - background: var(--space-color); - padding: 2.5rem 1rem; - display: flex; - align-items: center; - justify-content: center; - position: relative; - overflow: hidden; -} - -.cl-space-card__header::after { - content: ""; - position: absolute; - inset: 0; - background: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(0, 0, 0, 0.1)); - pointer-events: none; + border-left-color: var(--space-color); + transform: translateY(-4px); + box-shadow: 0 12px 28px var(--cl-shadow); + background: var(--cl-glass); } .cl-space-card__icon { - width: 64px; - height: 64px; - border-radius: 18px; + width: 32px; + height: 32px; + border-radius: 50%; display: flex; align-items: center; justify-content: center; overflow: hidden; - background: rgba(255, 255, 255, 0.25); - backdrop-filter: blur(10px); - -webkit-backdrop-filter: blur(10px); - position: relative; - z-index: 1; - border: 1px solid rgba(255, 255, 255, 0.2); - transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1); + flex-shrink: 0; + background: var(--space-color); + transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1); } .cl-space-card:hover .cl-space-card__icon { - transform: scale(1.15) rotate(3deg); + transform: scale(1.1) rotate(5deg); } .cl-space-card__icon img { @@ -1362,23 +1363,21 @@ } .cl-space-card__letter { - font-size: 1.8rem; + font-size: 0.9rem; font-weight: 900; color: #fff; line-height: 1; - text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); } /* Card body */ .cl-space-card__body { - padding: 1.25rem 1.25rem; display: flex; flex-direction: column; - gap: 0.25rem; + min-width: 0; } .cl-space-card__name { - font-size: 0.95rem; + font-size: 0.88rem; font-weight: 800; color: var(--cl-text-strong); white-space: nowrap; @@ -1387,7 +1386,7 @@ } .cl-space-card__sub { - font-size: 0.78rem; + font-size: 0.72rem; color: var(--cl-muted); font-weight: 600; text-transform: uppercase; diff --git a/config/locales/en.yml b/config/locales/en.yml index 0f5fea2..e732a8d 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -36,6 +36,8 @@ en: hero_secondary_button_label: "Text on the secondary (outlined) CTA button." hero_secondary_button_url: "URL the secondary button links to." hero_video_url: "URL for a hero video. Supports MP4 and YouTube links. A play button appears in the hero area; clicking opens a lightbox modal with the video." + hero_video_button_color: "Custom background color for the hero video play button. Leave blank to use the accent color." + hero_video_blur_on_hover: "Apply a blur effect to the hero image when hovering the play button." hero_bg_dark: "Background color for the hero section in dark mode. Hex value. Leave blank for default." hero_bg_light: "Background color for the hero section in light mode. Hex value. Leave blank for default." hero_min_height: "Minimum height for the hero section in pixels. Set to 0 for auto height." @@ -52,6 +54,7 @@ en: stat_posts_label: "Custom label for the Posts stat card." stat_likes_label: "Custom label for the Likes stat card." stat_chats_label: "Custom label for the Chats stat card. Shows total chat messages if the Chat plugin is active." + stat_round_numbers: "Round large numbers for a cleaner display: 1000 becomes 1K, 12345 becomes 12.3K, 1234567 becomes 1.2M." stats_bg_dark: "Background color for the stats section in dark mode. Leave blank for default." stats_bg_light: "Background color for the stats section in light mode. Leave blank for default." stats_min_height: "Minimum height for the stats section in pixels. Set to 0 for auto height." diff --git a/config/settings.yml b/config/settings.yml index a9a4fa3..f28dba2 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -130,6 +130,12 @@ plugins: hero_video_url: default: "" type: string + hero_video_button_color: + default: "" + type: color + hero_video_blur_on_hover: + default: true + type: bool hero_bg_dark: default: "" type: color @@ -186,6 +192,9 @@ plugins: stat_chats_label: default: "Chats" type: string + stat_round_numbers: + default: false + type: bool stats_bg_dark: default: "" type: color diff --git a/lib/community_landing/page_builder.rb b/lib/community_landing/page_builder.rb index 24d63a9..11d0e74 100644 --- a/lib/community_landing/page_builder.rb +++ b/lib/community_landing/page_builder.rb @@ -157,10 +157,30 @@ module CommunityLanding html << "
\n" html << "#{e(primary_label)}\n" html << "#{e(secondary_label)}\n" - html << "
\n\n" + html << "\n" + + # Hero creators (top 3) + contributors = @data[:contributors] + if (@s.contributors_enabled rescue false) && contributors&.any? + top3 = contributors.first(3) + html << "
\n" + top3.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}\n" + html << "\n" + end + html << "
\n" + end + + html << "\n" hero_image_urls_raw = @s.hero_image_urls.presence hero_video = @s.hero_video_url.presence rescue nil + blur_attr = (@s.hero_video_blur_on_hover rescue true) ? " data-blur-hover=\"true\"" : "" has_images = false if hero_image_urls_raw @@ -171,7 +191,7 @@ module CommunityLanding html << "
\n" html << "\"#{e(site_name)}\"\n" if hero_video - html << "\n" end @@ -181,7 +201,7 @@ module CommunityLanding if hero_video && !has_images html << "
\n" - html << "\n" html << "
\n" @@ -199,16 +219,17 @@ module CommunityLanding border = @s.stats_border_style rescue "none" min_h = @s.stats_min_height rescue 0 icon_shape = @s.stat_icon_shape rescue "circle" + round_nums = @s.stat_round_numbers rescue false html = +"" html << "
\n" html << "

#{e(stats_title)}

\n" html << "
\n" - html << stat_card(Icons::STAT_MEMBERS_SVG, stats[:members], @s.stat_members_label, icon_shape) - html << stat_card(Icons::STAT_TOPICS_SVG, stats[:topics], @s.stat_topics_label, icon_shape) - html << stat_card(Icons::STAT_POSTS_SVG, stats[:posts], @s.stat_posts_label, icon_shape) - html << stat_card(Icons::STAT_LIKES_SVG, stats[:likes], @s.stat_likes_label, icon_shape) - html << stat_card(Icons::STAT_CHATS_SVG, stats[:chats], @s.stat_chats_label, icon_shape) + html << stat_card(Icons::STAT_MEMBERS_SVG, stats[:members], @s.stat_members_label, icon_shape, round_nums) + html << stat_card(Icons::STAT_TOPICS_SVG, stats[:topics], @s.stat_topics_label, icon_shape, round_nums) + html << stat_card(Icons::STAT_POSTS_SVG, stats[:posts], @s.stat_posts_label, icon_shape, round_nums) + html << stat_card(Icons::STAT_LIKES_SVG, stats[:likes], @s.stat_likes_label, icon_shape, round_nums) + html << stat_card(Icons::STAT_CHATS_SVG, stats[:chats], @s.stat_chats_label, icon_shape, round_nums) html << "
\n
\n" html end @@ -339,8 +360,7 @@ module CommunityLanding light = 45 + (group.name.bytes.last.to_i % 12) icon_color = "hsl(#{hue}, #{sat}%, #{light}%)" - html << "\n" - html << "
\n" + html << "\n" html << "
" if group.flair_url.present? html << "\"\"" @@ -348,7 +368,6 @@ module CommunityLanding html << "#{group.name[0].upcase}" end html << "
\n" - html << "
\n" html << "
\n" html << "#{e(display_name)}\n" html << "#{group.user_count} members\n" @@ -447,12 +466,15 @@ module CommunityLanding # ── Shared helpers ── - def stat_card(icon_svg, count, label, icon_shape = "circle") + def stat_card(icon_svg, count, label, icon_shape = "circle", round_numbers = false) shape_class = icon_shape == "rounded" ? "cl-stat-icon--rounded" : "cl-stat-icon--circle" + round_attr = round_numbers ? ' data-round="true"' : '' "
\n" \ "
#{icon_svg}
\n" \ + "
\n" \ + "0\n" \ "#{e(label)}\n" \ - "0\n" \ + "
\n" \ "
\n" end diff --git a/lib/community_landing/style_builder.rb b/lib/community_landing/style_builder.rb index db7189e..2bca9bf 100644 --- a/lib/community_landing/style_builder.rb +++ b/lib/community_landing/style_builder.rb @@ -26,6 +26,7 @@ module CommunityLanding stat_counter = hex(@s.stat_counter_color.presence) rescue nil space_card_bg = hex(@s.groups_card_bg_color.presence) rescue nil topic_card_bg = hex(@s.topics_card_bg_color.presence) rescue nil + video_btn_bg = hex(@s.hero_video_button_color.presence) rescue nil accent_rgb = hex_to_rgb(accent) stat_icon_rgb = hex_to_rgb(stat_icon) @@ -35,6 +36,7 @@ module CommunityLanding topic_card_bg_val = topic_card_bg || "var(--cl-card)" about_bg_extra = about_bg_img ? ", url('#{about_bg_img}') center/cover no-repeat" : "" + video_btn_line = video_btn_bg ? "\n --cl-video-btn-bg: #{video_btn_bg};" : "" "