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:
2026-03-06 17:45:04 -04:00
parent 1c66805242
commit af75ea54a6
11 changed files with 1300 additions and 805 deletions

View File

@@ -0,0 +1,189 @@
import { withPluginApi } from "discourse/lib/plugin-api";
const TABS = [
{
id: "general",
label: "General",
settings: new Set([
"community_landing_enabled",
"logo_dark_url", "logo_light_url", "logo_height", "footer_logo_url",
"accent_color", "accent_hover_color", "dark_bg_color", "light_bg_color",
"scroll_animation"
])
},
{
id: "navbar",
label: "Navbar",
settings: new Set([
"navbar_signin_label", "navbar_join_label", "navbar_bg_color", "navbar_border_style"
])
},
{
id: "hero",
label: "Hero",
settings: new Set([
"hero_title", "hero_subtitle", "hero_card_enabled", "hero_background_image_url",
"hero_image_urls", "hero_image_max_height",
"hero_primary_button_label", "hero_primary_button_url",
"hero_secondary_button_label", "hero_secondary_button_url",
"hero_bg_dark", "hero_bg_light", "hero_min_height", "hero_border_style"
])
},
{
id: "stats",
label: "Stats",
settings: new Set([
"stats_title", "stat_icon_color",
"stat_members_label", "stat_topics_label", "stat_posts_label",
"stat_likes_label", "stat_chats_label",
"stats_bg_dark", "stats_bg_light", "stats_min_height", "stats_border_style"
])
},
{
id: "about",
label: "About",
settings: new Set([
"about_enabled", "about_heading_enabled", "about_heading",
"about_title", "about_role", "about_body", "about_image_url",
"about_gradient_start", "about_gradient_mid", "about_gradient_end",
"about_background_image_url",
"about_bg_dark", "about_bg_light", "about_min_height", "about_border_style"
])
},
{
id: "topics",
label: "Trending",
settings: new Set([
"topics_enabled", "topics_title", "topics_count",
"topics_bg_dark", "topics_bg_light", "topics_min_height", "topics_border_style"
])
},
{
id: "contributors",
label: "Creators",
settings: new Set([
"contributors_enabled", "contributors_title", "contributors_days", "contributors_count",
"contributors_bg_dark", "contributors_bg_light", "contributors_min_height", "contributors_border_style"
])
},
{
id: "groups",
label: "Spaces",
settings: new Set([
"groups_enabled", "groups_title", "groups_count",
"groups_bg_dark", "groups_bg_light", "groups_min_height", "groups_border_style"
])
},
{
id: "appcta",
label: "App CTA",
settings: new Set([
"show_app_ctas", "ios_app_url", "android_app_url",
"ios_app_badge_image_url", "android_app_badge_image_url",
"app_badge_height", "app_badge_style",
"app_cta_headline", "app_cta_subtext",
"app_cta_gradient_start", "app_cta_gradient_mid", "app_cta_gradient_end",
"app_cta_image_url",
"app_cta_bg_dark", "app_cta_bg_light", "app_cta_min_height", "app_cta_border_style"
])
},
{
id: "footer",
label: "Footer",
settings: new Set([
"footer_description", "footer_text", "footer_links",
"footer_bg_dark", "footer_bg_light", "footer_border_style"
])
},
{
id: "css",
label: "Custom CSS",
settings: new Set(["custom_css"])
}
];
let currentTab = "general";
function applyTabFilter(container) {
const tab = TABS.find((t) => t.id === currentTab);
if (!tab) return;
container.querySelectorAll(".row.setting[data-setting]").forEach((row) => {
const name = row.getAttribute("data-setting");
row.style.display = tab.settings.has(name) ? "" : "none";
});
}
function buildTabsUI() {
const container =
document.querySelector(".admin-plugin-config-area") ||
document.querySelector(".admin-detail");
if (!container) return false;
// Already injected?
if (container.querySelector(".cl-admin-tabs")) return true;
const allRows = container.querySelectorAll(".row.setting[data-setting]");
if (allRows.length < 5) return false;
// Verify our settings are present
const firstTab = TABS[0];
const hasOurs = Array.from(allRows).some((row) =>
firstTab.settings.has(row.getAttribute("data-setting"))
);
if (!hasOurs) return false;
// Create tab bar
const tabBar = document.createElement("div");
tabBar.className = "cl-admin-tabs";
TABS.forEach((tab) => {
const btn = document.createElement("button");
btn.className = "cl-admin-tab" + (tab.id === currentTab ? " active" : "");
btn.textContent = tab.label;
btn.setAttribute("data-tab", tab.id);
btn.addEventListener("click", () => {
currentTab = tab.id;
tabBar
.querySelectorAll(".cl-admin-tab")
.forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
applyTabFilter(container);
});
tabBar.appendChild(btn);
});
// Insert tab bar before the first setting row
const settingsParent = allRows[0].parentNode;
settingsParent.insertBefore(tabBar, allRows[0]);
// Add class to disable separator borders
container.classList.add("cl-tabs-active");
// Apply initial filter
applyTabFilter(container);
return true;
}
export default {
name: "community-landing-admin-tabs",
initialize() {
withPluginApi("1.0", (api) => {
api.onPageChange((url) => {
if (
url.includes("community-landing") ||
url.includes("community_landing")
) {
let attempts = 0;
const tryInject = () => {
if (buildTabsUI() || attempts > 15) return;
attempts++;
setTimeout(tryInject, 200);
};
tryInject();
}
});
});
},
};

View File

@@ -1,74 +1,123 @@
/* ═══════════════════════════════════════════════════════════════════ /* ═══════════════════════════════════════════════════════════════════
Community Landing — Admin Settings Panel Styles Community Landing — Admin Settings Panel Styles
Adds visual separators and spacing between setting groups Tab navigation + fallback separators
═══════════════════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════════════════ */
/* All community_landing settings get extra bottom spacing */ /* ── Tab Navigation ── */
.admin-detail .row.setting[data-setting^="community_landing_"] {
.cl-admin-tabs {
display: flex;
flex-wrap: wrap;
gap: 0;
margin: 0 0 24px 0;
padding: 0;
border-bottom: 1px solid var(--primary-low, rgba(0, 0, 0, 0.1));
}
.cl-admin-tab {
padding: 10px 14px;
border: none;
background: none;
color: var(--primary-medium, #888);
font-size: 13px;
font-weight: 600;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color 0.15s ease, border-color 0.15s ease;
}
.cl-admin-tab:hover {
color: var(--primary, #333);
}
.cl-admin-tab.active {
color: var(--tertiary, #0088cc);
border-bottom-color: var(--tertiary, #0088cc);
}
/* Dark mode tabs */
html.dark-scheme .cl-admin-tabs {
border-bottom-color: rgba(255, 255, 255, 0.1);
}
html.dark-scheme .cl-admin-tab:hover {
color: var(--primary, #ddd);
}
/* ── When tabs are active, remove separator borders ── */
.cl-tabs-active .row.setting[data-setting] {
border-top: none !important;
margin-top: 0 !important;
padding-top: 0 !important;
}
/* ── Fallback: Separator borders when tabs are NOT active ──
(e.g. if JS fails to load or on older Discourse) */
/* All plugin settings spacing */
.admin-detail:not(.cl-tabs-active) .row.setting[data-setting="community_landing_enabled"],
.admin-detail:not(.cl-tabs-active) .row.setting[data-setting^="logo_"],
.admin-detail:not(.cl-tabs-active) .row.setting[data-setting^="accent_"],
.admin-detail:not(.cl-tabs-active) .row.setting[data-setting="dark_bg_color"],
.admin-detail:not(.cl-tabs-active) .row.setting[data-setting="light_bg_color"],
.admin-detail:not(.cl-tabs-active) .row.setting[data-setting^="scroll_"],
.admin-detail:not(.cl-tabs-active) .row.setting[data-setting^="navbar_"],
.admin-detail:not(.cl-tabs-active) .row.setting[data-setting^="hero_"],
.admin-detail:not(.cl-tabs-active) .row.setting[data-setting^="stats_"],
.admin-detail:not(.cl-tabs-active) .row.setting[data-setting^="stat_"],
.admin-detail:not(.cl-tabs-active) .row.setting[data-setting^="about_"],
.admin-detail:not(.cl-tabs-active) .row.setting[data-setting^="topics_"],
.admin-detail:not(.cl-tabs-active) .row.setting[data-setting^="contributors_"],
.admin-detail:not(.cl-tabs-active) .row.setting[data-setting^="groups_"],
.admin-detail:not(.cl-tabs-active) .row.setting[data-setting="show_app_ctas"],
.admin-detail:not(.cl-tabs-active) .row.setting[data-setting^="ios_"],
.admin-detail:not(.cl-tabs-active) .row.setting[data-setting^="android_"],
.admin-detail:not(.cl-tabs-active) .row.setting[data-setting^="app_"],
.admin-detail:not(.cl-tabs-active) .row.setting[data-setting^="footer_"],
.admin-detail:not(.cl-tabs-active) .row.setting[data-setting="custom_css"] {
margin-bottom: 20px; margin-bottom: 20px;
} }
/* ── Section separator borders ── /* Section separator borders (fallback only) */
These selectors target the FIRST setting of each group to add .admin-detail:not(.cl-tabs-active) .row.setting[data-setting="logo_dark_url"],
a visible border-top separator with extra padding above it. */ .admin-detail:not(.cl-tabs-active) .row.setting[data-setting="accent_color"],
.admin-detail:not(.cl-tabs-active) .row.setting[data-setting="scroll_animation"],
/* Branding section */ .admin-detail:not(.cl-tabs-active) .row.setting[data-setting="navbar_signin_label"],
.admin-detail .row.setting[data-setting="community_landing_logo_dark_url"], .admin-detail:not(.cl-tabs-active) .row.setting[data-setting="hero_title"],
/* Global Colors section */ .admin-detail:not(.cl-tabs-active) .row.setting[data-setting="stats_title"],
.admin-detail .row.setting[data-setting="community_landing_accent_color"], .admin-detail:not(.cl-tabs-active) .row.setting[data-setting="about_enabled"],
/* Custom CSS section */ .admin-detail:not(.cl-tabs-active) .row.setting[data-setting="topics_enabled"],
.admin-detail .row.setting[data-setting="community_landing_custom_css"], .admin-detail:not(.cl-tabs-active) .row.setting[data-setting="contributors_enabled"],
/* Scroll Animations section */ .admin-detail:not(.cl-tabs-active) .row.setting[data-setting="groups_enabled"],
.admin-detail .row.setting[data-setting="community_landing_scroll_animation"], .admin-detail:not(.cl-tabs-active) .row.setting[data-setting="show_app_ctas"],
/* Row 1: Navbar */ .admin-detail:not(.cl-tabs-active) .row.setting[data-setting="footer_description"],
.admin-detail .row.setting[data-setting="community_landing_navbar_signin_label"], .admin-detail:not(.cl-tabs-active) .row.setting[data-setting="custom_css"] {
/* Row 2: Hero Section */
.admin-detail .row.setting[data-setting="community_landing_hero_title"],
/* Row 3: Premium Stats */
.admin-detail .row.setting[data-setting="community_landing_stats_title"],
/* Row 4: About Community */
.admin-detail .row.setting[data-setting="community_landing_about_enabled"],
/* Row 5: Trending Discussions */
.admin-detail .row.setting[data-setting="community_landing_topics_enabled"],
/* Row 6: Top Creators */
.admin-detail .row.setting[data-setting="community_landing_contributors_enabled"],
/* Row 7: Community Spaces */
.admin-detail .row.setting[data-setting="community_landing_groups_enabled"],
/* Row 8: App Download CTA */
.admin-detail .row.setting[data-setting="community_landing_show_app_ctas"],
/* Row 9: Footer */
.admin-detail .row.setting[data-setting="community_landing_footer_description"] {
border-top: 2px solid rgba(0, 0, 0, 0.12); border-top: 2px solid rgba(0, 0, 0, 0.12);
margin-top: 28px; margin-top: 28px;
padding-top: 24px; padding-top: 24px;
} }
/* Dark mode admin panel support */ /* Dark mode separators (fallback only) */
.admin-detail.dark .row.setting[data-setting="community_landing_logo_dark_url"], html.dark-scheme .admin-detail:not(.cl-tabs-active) .row.setting[data-setting="logo_dark_url"],
.admin-detail.dark .row.setting[data-setting="community_landing_accent_color"], html.dark-scheme .admin-detail:not(.cl-tabs-active) .row.setting[data-setting="accent_color"],
.admin-detail.dark .row.setting[data-setting="community_landing_custom_css"], html.dark-scheme .admin-detail:not(.cl-tabs-active) .row.setting[data-setting="scroll_animation"],
.admin-detail.dark .row.setting[data-setting="community_landing_scroll_animation"], html.dark-scheme .admin-detail:not(.cl-tabs-active) .row.setting[data-setting="navbar_signin_label"],
.admin-detail.dark .row.setting[data-setting="community_landing_navbar_signin_label"], html.dark-scheme .admin-detail:not(.cl-tabs-active) .row.setting[data-setting="hero_title"],
.admin-detail.dark .row.setting[data-setting="community_landing_hero_title"], html.dark-scheme .admin-detail:not(.cl-tabs-active) .row.setting[data-setting="stats_title"],
.admin-detail.dark .row.setting[data-setting="community_landing_stats_title"], html.dark-scheme .admin-detail:not(.cl-tabs-active) .row.setting[data-setting="about_enabled"],
.admin-detail.dark .row.setting[data-setting="community_landing_about_enabled"], html.dark-scheme .admin-detail:not(.cl-tabs-active) .row.setting[data-setting="topics_enabled"],
.admin-detail.dark .row.setting[data-setting="community_landing_topics_enabled"], html.dark-scheme .admin-detail:not(.cl-tabs-active) .row.setting[data-setting="contributors_enabled"],
.admin-detail.dark .row.setting[data-setting="community_landing_contributors_enabled"], html.dark-scheme .admin-detail:not(.cl-tabs-active) .row.setting[data-setting="groups_enabled"],
.admin-detail.dark .row.setting[data-setting="community_landing_groups_enabled"], html.dark-scheme .admin-detail:not(.cl-tabs-active) .row.setting[data-setting="show_app_ctas"],
.admin-detail.dark .row.setting[data-setting="community_landing_show_app_ctas"], html.dark-scheme .admin-detail:not(.cl-tabs-active) .row.setting[data-setting="footer_description"],
.admin-detail.dark .row.setting[data-setting="community_landing_footer_description"], html.dark-scheme .admin-detail:not(.cl-tabs-active) .row.setting[data-setting="custom_css"] {
html.dark-scheme .admin-detail .row.setting[data-setting="community_landing_logo_dark_url"],
html.dark-scheme .admin-detail .row.setting[data-setting="community_landing_accent_color"],
html.dark-scheme .admin-detail .row.setting[data-setting="community_landing_custom_css"],
html.dark-scheme .admin-detail .row.setting[data-setting="community_landing_scroll_animation"],
html.dark-scheme .admin-detail .row.setting[data-setting="community_landing_navbar_signin_label"],
html.dark-scheme .admin-detail .row.setting[data-setting="community_landing_hero_title"],
html.dark-scheme .admin-detail .row.setting[data-setting="community_landing_stats_title"],
html.dark-scheme .admin-detail .row.setting[data-setting="community_landing_about_enabled"],
html.dark-scheme .admin-detail .row.setting[data-setting="community_landing_topics_enabled"],
html.dark-scheme .admin-detail .row.setting[data-setting="community_landing_contributors_enabled"],
html.dark-scheme .admin-detail .row.setting[data-setting="community_landing_groups_enabled"],
html.dark-scheme .admin-detail .row.setting[data-setting="community_landing_show_app_ctas"],
html.dark-scheme .admin-detail .row.setting[data-setting="community_landing_footer_description"] {
border-top-color: rgba(255, 255, 255, 0.12); border-top-color: rgba(255, 255, 255, 0.12);
} }
/* ── Tab content spacing ── */
.cl-tabs-active .row.setting[data-setting] {
margin-bottom: 16px;
}

View File

@@ -493,9 +493,10 @@
font-size: 0.82rem; font-weight: 600; color: var(--cl-text-strong); font-size: 0.82rem; font-weight: 600; color: var(--cl-text-strong);
white-space: nowrap; white-space: nowrap;
} }
.cl-creator-pill__activity { .cl-creator-pill__count {
font-size: 0.72rem; color: var(--cl-muted); white-space: nowrap; font-size: 0.72rem; color: var(--cl-accent); white-space: nowrap;
font-weight: 500; font-weight: 700; margin-left: auto; padding-left: 0.4rem;
min-width: 1.2em; text-align: center;
} }
/* ═══════════════════════════════════════════════════════════════════ /* ═══════════════════════════════════════════════════════════════════

View File

@@ -1,114 +1,129 @@
en: en:
site_settings: site_settings:
# ── Master Switch ── # ── Master Switch ──
community_landing_enabled: "━━ LANDING PAGE ━━ — Enable the branded community landing page. When turned on, logged-out visitors see a custom welcome page instead of the default Discourse homepage. All sections below are configurable." community_landing_enabled: "Enable the branded community landing page. When on, logged-out visitors see a custom welcome page instead of the default Discourse homepage. All sections below are configurable."
# ── Branding: Logo ── # ── Branding: Logo ──
community_landing_logo_dark_url: "━━ BRANDING ━━ — Logo image URL for dark mode. Displayed in the navbar header and footer. Leave blank to show the site name as text instead." logo_dark_url: "━━ BRANDING ━━ — Logo image URL for dark mode. Displayed in the navbar and footer. Leave blank to show the site name as text."
community_landing_logo_light_url: "Logo image URL for light mode. If not set, the dark logo is used for both themes." logo_light_url: "Logo image URL for light mode. If not set, the dark logo is used for both themes."
community_landing_logo_height: "Logo height in pixels (1680). Applies to both the navbar and footer logos." logo_height: "Logo height in pixels (1680). Applies to both the navbar and footer logos."
community_landing_footer_logo_url: "Override logo specifically for the footer. If not set, the navbar logo is reused." footer_logo_url: "Override logo specifically for the footer. If not set, the navbar logo is reused."
# ── Appearance: Color Scheme ── # ── Appearance: Color Scheme ──
community_landing_accent_color: "━━ GLOBAL COLORS ━━ — Primary accent color used across the entire landing page: buttons, links, highlights, gradients, and stat icons. Enter a hex value (e.g. #d4a24e)." accent_color: "━━ GLOBAL COLORS ━━ — Primary accent color used across the page: buttons, links, highlights, gradients, and stat icons. Hex value (e.g. #d4a24e)."
community_landing_accent_hover_color: "Accent color on hover states. Should be slightly lighter or darker than the primary accent. Hex value." accent_hover_color: "Accent color on hover states. Should be slightly lighter or darker than the primary accent. Hex value."
community_landing_dark_bg_color: "Background color for dark mode. Sets the overall page background when in dark theme. Hex value." dark_bg_color: "Overall page background color for dark mode. Hex value."
community_landing_light_bg_color: "Background color for light mode. Sets the overall page background when in light theme. Hex value." light_bg_color: "Overall page background color for light mode. Hex value."
# ── Custom CSS ──
community_landing_custom_css: "━━ CUSTOM CSS ━━ — Inject your own CSS directly into the landing page. Use this for fine-tuning styles, overriding any default, or adding completely custom design touches. Paste raw CSS rules here (no &lt;style&gt; tags needed). This CSS loads after all other styles, giving it the highest priority."
# ── Scroll Animations ── # ── Scroll Animations ──
community_landing_scroll_animation: "━━ SCROLL ANIMATIONS ━━ — Choose how sections animate into view as the visitor scrolls down the page. Options: fade_up (slide up from below), fade_in (appear in place), slide_left / slide_right (horizontal entrance), zoom_in (scale up from smaller), flip_up (3D rotation), or none to disable all scroll animations." scroll_animation: "━━ SCROLL ANIMATIONS ━━ — How sections animate into view on scroll. Options: fade_up, fade_in, slide_left, slide_right, zoom_in, flip_up, or none."
# ── Navbar ── # ── 1. Navbar ──
community_landing_navbar_signin_label: "━━ ROW 1: NAVBAR ━━ — The fixed navigation bar at the top of the page with your logo, theme toggle, sign-in link, and join button. This setting controls the text for the sign-in link." navbar_signin_label: "━━ ROW 1: NAVBAR ━━ — Fixed navigation bar at the top with logo, theme toggle, sign-in link, and join button. This setting controls the sign-in link text."
community_landing_navbar_join_label: "Text for the join/register call-to-action button in the navbar." navbar_join_label: "Text for the join/register CTA button in the navbar."
community_landing_navbar_bg_color: "Custom background color for the navbar when scrolled. Leave blank for the default frosted glass effect." navbar_bg_color: "Custom background color for the navbar when scrolled. Leave blank for the default frosted glass effect."
community_landing_navbar_border_style: "Border line style at the bottom of the navbar when scrolled. Choose none, solid, dashed, or dotted." navbar_border_style: "Border style at the bottom of the navbar when scrolled."
# ── Hero Section ── # ── 2. Hero Section ──
community_landing_hero_title: "━━ ROW 2: HERO SECTION ━━ — The large welcome area at the top of the page with headline, subtitle, CTA buttons, and optional imagery. This setting is the main headline text. The last word is automatically highlighted with your accent color gradient." hero_title: "━━ ROW 2: HERO ━━ — Large welcome area at the top with headline, subtitle, CTA buttons, and optional imagery. This is the main headline text. The last word is highlighted with your accent color."
community_landing_hero_subtitle: "Supporting text shown below the hero headline. Describe your community's purpose or value proposition." hero_subtitle: "Supporting text below the hero headline. Describe your community's purpose or value proposition."
community_landing_hero_card_enabled: "Wrap the hero content inside a rounded card container with a subtle border and shadow. Turn off for a flat, full-width hero layout." hero_card_enabled: "Display the hero content inside a rounded card container with border and shadow. When off, the hero uses a flat full-width layout."
community_landing_hero_background_image_url: "Full-bleed background image behind the hero section. In card mode, the image fills the card with a dark overlay. In flat mode, it covers the entire section." hero_background_image_url: "Full-bleed background image behind the hero section. In card mode, fills the card with a dark overlay. In flat mode, covers the entire section."
community_landing_hero_image_urls: "Images displayed on the right side of the hero. Add up to 5 image URLs — a random one is shown on each page load. Click + to add rows." hero_image_urls: "Images displayed on the right side of the hero. Add up to 5 URLs — a random one is shown on each page load."
community_landing_hero_image_max_height: "Maximum height in pixels for the hero image (1001200). Prevents tall images from overwhelming the section." hero_image_max_height: "Maximum height in pixels for the hero image (1001200)."
community_landing_hero_primary_button_label: "Text on the primary (filled, accent-colored) call-to-action button." hero_primary_button_label: "Text on the primary (filled, accent-colored) CTA button."
community_landing_hero_primary_button_url: "URL the primary button links to. Use a relative path like /latest or an absolute URL." hero_primary_button_url: "URL the primary button links to. Use a relative path like /latest or an absolute URL."
community_landing_hero_secondary_button_label: "Text on the secondary (outlined) call-to-action button." hero_secondary_button_label: "Text on the secondary (outlined) CTA button."
community_landing_hero_secondary_button_url: "URL the secondary button links to. Defaults to the login page." hero_secondary_button_url: "URL the secondary button links to."
community_landing_hero_bg_color: "Custom background color for the hero section wrapper. Leave blank for the default page background." hero_bg_dark: "Background color for the hero section in dark mode. Hex value. Leave blank for default."
community_landing_hero_border_style: "Border line style at the bottom of the hero section. Choose none, solid, dashed, or dotted." 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."
hero_border_style: "Border style at the bottom of the hero section."
# ── Stats Section ── # ── 3. Stats Section ──
community_landing_stats_title: "━━ ROW 3: PREMIUM STATS ━━ — A full-width row displaying live community statistics (members, topics, posts, likes, chats) with animated counters and icons. Each card shows the icon and label on one line with the counter number below. This setting is the section heading text." stats_title: "━━ ROW 3: PREMIUM STATS ━━ — Full-width row of live community statistics (members, topics, posts, likes, chats) with animated counters and icons. Each card shows icon + label on one line with the counter below. This is the section heading."
community_landing_stat_icon_color: "Color for all stat counter icons. Enter a hex value (e.g. #d4a24e). Matches your accent color by default." stat_icon_color: "Color for all stat counter icons. Hex value (e.g. #d4a24e)."
community_landing_stat_members_label: "Custom label for the Members stat counter card." stat_members_label: "Custom label for the Members stat card."
community_landing_stat_topics_label: "Custom label for the Topics stat counter card." stat_topics_label: "Custom label for the Topics stat card."
community_landing_stat_posts_label: "Custom label for the Posts stat counter card." stat_posts_label: "Custom label for the Posts stat card."
community_landing_stat_likes_label: "Custom label for the Likes stat counter card." stat_likes_label: "Custom label for the Likes stat card."
community_landing_stat_chats_label: "Custom label for the Chats stat counter card. Shows total chat messages if the Chat plugin is active." stat_chats_label: "Custom label for the Chats stat card. Shows total chat messages if the Chat plugin is active."
community_landing_stats_bg_color: "Custom background color for the stats section wrapper. Leave blank for default." stats_bg_dark: "Background color for the stats section in dark mode. Leave blank for default."
community_landing_stats_border_style: "Border line style at the bottom of the stats section." 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."
stats_border_style: "Border style at the bottom of the stats section."
# ── About Section ── # ── 4. About Section ──
community_landing_about_enabled: "━━ ROW 4: ABOUT COMMUNITY ━━ — A full-width gradient card with a bold heading, decorative quote icon, community description text, and author attribution (avatar, name, role). Supports a 3-color gradient background and an optional overlay image. Toggle this to show or hide the entire section." about_enabled: "━━ ROW 4: ABOUT ━━ — Show the About section: a gradient card with bold heading, decorative quote icon, community description, and author attribution (avatar, name, role). Supports a 3-color gradient background and optional overlay image."
community_landing_about_heading_enabled: "Show the bold heading text (e.g. 'About Community') at the top of the card. Turn off to start directly with the quote icon and description." about_heading_enabled: "Show the bold heading text at the top of the About card. Turn off to start with the quote icon and description."
community_landing_about_heading: "The bold heading text displayed at the top of the About card (e.g. 'About Community', 'Our Story', 'Who We Are')." about_heading: "The heading text at the top of the About card (e.g. 'About Community', 'Our Story')."
community_landing_about_title: "Author or community name shown in the card's bottom attribution area, next to the avatar." about_title: "Author or community name shown in the card's bottom attribution, next to the avatar."
community_landing_about_role: "Subtitle below the author name (e.g. 'Community Manager', 'Founded 2020'). If blank, the site name is used." about_role: "Subtitle below the author name (e.g. 'Community Manager'). If blank, the site name is used."
community_landing_about_body: "Main body text for the About card. This is the community description. Supports basic HTML: p, a, strong, em, ul, li, br." about_body: "Main body text for the About card. Supports basic HTML: p, a, strong, em, ul, li, br."
community_landing_about_image_url: "Small avatar image shown next to the author name in the card footer. Square images work best." about_image_url: "Small avatar image shown next to the author name. Square images work best."
community_landing_about_gradient_start: "First color (left) of the About card's 3-color gradient background. Hex value (e.g. #fdf6ec)." about_gradient_start: "First color (left) of the About card's 3-color gradient. Hex value."
community_landing_about_gradient_mid: "Middle color of the About card gradient. Hex value (e.g. #fef9f0)." about_gradient_mid: "Middle color of the About card gradient. Hex value."
community_landing_about_gradient_end: "Third color (right) of the About card gradient. Hex value (e.g. #fdf6ec)." about_gradient_end: "Third color (right) of the About card gradient. Hex value."
community_landing_about_background_image_url: "Background image layered on top of the gradient. Use a subtle pattern or texture. The gradient shows through any transparent areas." about_background_image_url: "Background image layered on top of the gradient. Use a subtle pattern or texture."
community_landing_about_bg_color: "Custom background color for the about section wrapper (the area around the card). Leave blank for default." about_bg_dark: "Background color for the about section wrapper in dark mode. Leave blank for default."
community_landing_about_border_style: "Border line style at the bottom of the about section." about_bg_light: "Background color for the about section wrapper in light mode. Leave blank for default."
about_min_height: "Minimum height for the about section in pixels. Set to 0 for auto height."
about_border_style: "Border style at the bottom of the about section."
# ── Trending Discussions ── # ── 5. Trending Discussions ──
community_landing_topics_enabled: "━━ ROW 5: TRENDING DISCUSSIONS ━━ — A horizontally scrollable row of topic cards showing the most active discussions. Each card displays the category badge, topic title, reply count, and like count — all pulled live from your forum. Supports drag-to-scroll on desktop and native swipe on mobile. Toggle to show or hide." topics_enabled: "━━ ROW 5: TRENDING ━━ — Show the Trending Discussions section: a horizontally scrollable row of topic cards showing the most active discussions. Each card displays category badge, title, reply count, and like count — all live data. Supports drag-to-scroll and native swipe."
community_landing_topics_title: "Heading text displayed above the scrollable topic cards (e.g. 'Trending Discussions', 'Hot Topics')." topics_title: "Heading text above the scrollable topic cards."
community_landing_topics_count: "Number of trending topic cards to display. Shows the most-replied topics from the last 30 days." topics_count: "Number of trending topic cards to display."
community_landing_topics_bg_color: "Custom background color for the trending discussions section wrapper. Leave blank for default." topics_bg_dark: "Background color for the trending section in dark mode. Leave blank for default."
community_landing_topics_border_style: "Border line style at the bottom of the trending section." topics_bg_light: "Background color for the trending section in light mode. Leave blank for default."
topics_min_height: "Minimum height for the trending section in pixels. Set to 0 for auto height."
topics_border_style: "Border style at the bottom of the trending section."
# ── Top Creators ── # ── 6. Top Creators ──
community_landing_contributors_enabled: "━━ ROW 6: TOP CREATORS ━━ — A row of pill-shaped badges showing your most active community members. Each pill displays the user's avatar, @username, and activity count (number of posts in the lookback period). Toggle to show or hide the entire section." contributors_enabled: "━━ ROW 6: CREATORS ━━ — Show the Top Creators section: pill-shaped badges showing your most active community members. Each pill displays avatar, @username, and post count from the lookback period."
community_landing_contributors_title: "Heading text displayed above the creator pills (e.g. 'Top Creators', 'Most Active Members')." contributors_title: "Heading text above the creator pills."
community_landing_contributors_days: "Lookback period in days for calculating top contributors. A larger number captures a wider time range of activity." contributors_days: "Lookback period in days for calculating top contributors."
community_landing_contributors_count: "Number of top contributor pills to display. Recommended: 612 for a balanced layout." contributors_count: "Number of top contributor pills to display. Recommended: 612."
community_landing_contributors_bg_color: "Custom background color for the creators section wrapper. Leave blank for default." contributors_bg_dark: "Background color for the creators section in dark mode. Leave blank for default."
community_landing_contributors_border_style: "Border line style at the bottom of the creators section." contributors_bg_light: "Background color for the creators section in light mode. Leave blank for default."
contributors_min_height: "Minimum height for the creators section in pixels. Set to 0 for auto height."
contributors_border_style: "Border style at the bottom of the creators section."
# ── Community Spaces ── # ── 7. Community Spaces ──
community_landing_groups_enabled: "━━ ROW 7: COMMUNITY SPACES ━━ — A grid of colorful cards representing your public community groups. Each card shows a large colored icon (with the group's first letter if no flair is set), the group name, and member count. Only publicly visible, non-automatic groups are displayed. Toggle to show or hide." groups_enabled: "━━ ROW 7: SPACES ━━ — Show the Community Spaces section: a grid of colorful cards representing your public groups. Each card shows a colored icon (with group's first letter or flair), group name, and member count. Only public, non-automatic groups are shown."
community_landing_groups_title: "Heading text displayed above the group cards (e.g. 'Community Spaces', 'Our Groups')." groups_title: "Heading text above the group cards."
community_landing_groups_count: "Number of group cards to display. Only public, non-automatic groups are shown." groups_count: "Number of group cards to display."
community_landing_groups_bg_color: "Custom background color for the community spaces section wrapper. Leave blank for default." groups_bg_dark: "Background color for the spaces section in dark mode. Leave blank for default."
community_landing_groups_border_style: "Border line style at the bottom of the spaces section." groups_bg_light: "Background color for the spaces section in light mode. Leave blank for default."
groups_min_height: "Minimum height for the spaces section in pixels. Set to 0 for auto height."
groups_border_style: "Border style at the bottom of the spaces section."
# ── App Download CTA ── # ── 8. App Download CTA ──
community_landing_show_app_ctas: "━━ ROW 8: APP DOWNLOAD CTA ━━ — A gradient banner promoting your mobile app with a headline, subtitle, download badges (App Store / Google Play), and an optional promotional image on the right side. The gradient, badge style, and all text are fully configurable. Requires at least one app store URL. Toggle to show or hide." show_app_ctas: "━━ ROW 8: APP CTA ━━ — Show the App Download CTA: a gradient banner promoting your mobile app with headline, subtitle, download badges (App Store / Google Play), and optional promotional image. Requires at least one app store URL."
community_landing_ios_app_url: "Apple App Store URL for your iOS app. Leave blank to hide the iOS badge entirely." ios_app_url: "Apple App Store URL for your iOS app. Leave blank to hide the iOS badge."
community_landing_android_app_url: "Google Play Store URL for your Android app. Leave blank to hide the Android badge entirely." android_app_url: "Google Play Store URL for your Android app. Leave blank to hide the Android badge."
community_landing_ios_app_badge_image_url: "Replace the entire iOS badge button with a custom image (e.g. an official App Store badge PNG/SVG). Leave blank to use the default icon+text button." ios_app_badge_image_url: "Replace the iOS badge button with a custom image (e.g. official App Store badge). Leave blank for default."
community_landing_android_app_badge_image_url: "Replace the entire Android badge button with a custom image (e.g. an official Google Play badge PNG/SVG). Leave blank to use the default icon+text button." android_app_badge_image_url: "Replace the Android badge button with a custom image (e.g. official Google Play badge). Leave blank for default."
community_landing_app_badge_height: "Height of app download badges in pixels (3080). Applies to both default and custom image badges." app_badge_height: "Height of app download badges in pixels (3080)."
community_landing_app_badge_style: "Border-radius style for default badges: rounded (soft corners), pill (fully rounded), or square (minimal rounding)." app_badge_style: "Badge border-radius: rounded (soft corners), pill (fully rounded), or square (minimal rounding)."
community_landing_app_cta_headline: "Bold headline text in the app download banner (e.g. 'Get the best experience on our app')." app_cta_headline: "Bold headline text in the app download banner."
community_landing_app_cta_subtext: "Supporting text below the headline (e.g. 'Available free on iOS and Android')." app_cta_subtext: "Supporting text below the headline."
community_landing_app_cta_gradient_start: "First color (left) of the app CTA 3-color gradient background. Hex value." app_cta_gradient_start: "First color (left) of the app CTA 3-color gradient. Hex value."
community_landing_app_cta_gradient_mid: "Middle color of the app CTA gradient. Hex value." app_cta_gradient_mid: "Middle color of the app CTA gradient. Hex value."
community_landing_app_cta_gradient_end: "Third color (right) of the app CTA gradient. Hex value." app_cta_gradient_end: "Third color (right) of the app CTA gradient. Hex value."
community_landing_app_cta_image_url: "Promotional image on the right side of the CTA (e.g. a phone mockup). Supports PNG for transparent backgrounds. The image extends above the gradient area." app_cta_image_url: "Promotional image on the right side of the CTA (e.g. phone mockup). PNG for transparent backgrounds."
community_landing_app_cta_bg_color: "Custom background color for the app CTA section wrapper (behind the gradient card). Leave blank for default." app_cta_bg_dark: "Background color for the app CTA section in dark mode. Leave blank for default."
community_landing_app_cta_border_style: "Border line style at the bottom of the app CTA section." app_cta_bg_light: "Background color for the app CTA section in light mode. Leave blank for default."
app_cta_min_height: "Minimum height for the app CTA section in pixels. Set to 0 for auto height."
app_cta_border_style: "Border style at the bottom of the app CTA section."
# ── Footer ── # ── 9. Footer ──
community_landing_footer_description: "━━ ROW 9: FOOTER ━━ — The bottom of the page with your logo, navigation links, copyright notice, and optional description text. This setting adds a description paragraph between the app CTA and the footer bar. Useful for a brief community tagline or mission statement." footer_description: "━━ ROW 9: FOOTER ━━ — Bottom of the page with logo, navigation links, copyright, and optional description. This adds a description paragraph above the footer bar."
community_landing_footer_text: "Optional HTML text displayed inside the footer bar area. Supports: p, a, strong, em, ul, li, br." footer_text: "Optional HTML text inside the footer bar. Supports: p, a, strong, em, ul, li, br."
community_landing_footer_links: 'Footer navigation links as a JSON array. Format: [{"label":"Terms","url":"/tos"},{"label":"Privacy","url":"/privacy"}]. Logo and links appear on the left, copyright on the right.' footer_links: 'Footer navigation links as a JSON array. Format: [{"label":"Terms","url":"/tos"},{"label":"Privacy","url":"/privacy"}].'
community_landing_footer_bg_color: "Custom background color for the footer bar. Leave blank for the default footer background." footer_bg_dark: "Background color for the footer bar in dark mode. Leave blank for default."
community_landing_footer_border_style: "Border line style at the top of the footer bar. Defaults to solid." footer_bg_light: "Background color for the footer bar in light mode. Leave blank for default."
footer_border_style: "Border style at the top of the footer bar."
# ── Custom CSS ──
custom_css: "━━ CUSTOM CSS ━━ — Paste your own CSS rules directly into the landing page. This CSS loads after all other styles, giving it the highest priority. No style tags needed."

View File

@@ -9,41 +9,41 @@ plugins:
# ══════════════════════════════════════════ # ══════════════════════════════════════════
# Branding: Logo # Branding: Logo
# ══════════════════════════════════════════ # ══════════════════════════════════════════
community_landing_logo_dark_url: logo_dark_url:
default: "" default: ""
type: string type: string
community_landing_logo_light_url: logo_light_url:
default: "" default: ""
type: string type: string
community_landing_logo_height: logo_height:
default: 30 default: 30
type: integer type: integer
min: 16 min: 16
max: 80 max: 80
community_landing_footer_logo_url: footer_logo_url:
default: "" default: ""
type: string type: string
# ══════════════════════════════════════════ # ══════════════════════════════════════════
# Appearance: Color Scheme # Appearance: Color Scheme
# ══════════════════════════════════════════ # ══════════════════════════════════════════
community_landing_accent_color: accent_color:
default: "#d4a24e" default: "d4a24e"
type: string type: color
community_landing_accent_hover_color: accent_hover_color:
default: "#c4922e" default: "c4922e"
type: string type: color
community_landing_dark_bg_color: dark_bg_color:
default: "#06060f" default: "06060f"
type: string type: color
community_landing_light_bg_color: light_bg_color:
default: "#faf6f0" default: "faf6f0"
type: string type: color
# ══════════════════════════════════════════ # ══════════════════════════════════════════
# Scroll Animations # Scroll Animations
# ══════════════════════════════════════════ # ══════════════════════════════════════════
community_landing_scroll_animation: scroll_animation:
default: "fade_up" default: "fade_up"
type: enum type: enum
choices: choices:
@@ -55,26 +55,19 @@ plugins:
- flip_up - flip_up
- none - none
# ══════════════════════════════════════════
# Custom CSS
# ══════════════════════════════════════════
community_landing_custom_css:
default: ""
type: string
# ══════════════════════════════════════════ # ══════════════════════════════════════════
# 1. Navbar # 1. Navbar
# ══════════════════════════════════════════ # ══════════════════════════════════════════
community_landing_navbar_signin_label: navbar_signin_label:
default: "Sign In" default: "Sign In"
type: string type: string
community_landing_navbar_join_label: navbar_join_label:
default: "Get Started" default: "Get Started"
type: string type: string
community_landing_navbar_bg_color: navbar_bg_color:
default: "" default: ""
type: string type: string
community_landing_navbar_border_style: navbar_border_style:
default: "none" default: "none"
type: enum type: enum
choices: choices:
@@ -86,42 +79,50 @@ plugins:
# ══════════════════════════════════════════ # ══════════════════════════════════════════
# 2. Hero Section # 2. Hero Section
# ══════════════════════════════════════════ # ══════════════════════════════════════════
community_landing_hero_title: hero_title:
default: "Welcome to Our Creative HeadQuarters" default: "Welcome to Our Creative HeadQuarters"
type: string type: string
community_landing_hero_subtitle: hero_subtitle:
default: "Are you ready to start your creative journey?" default: "Are you ready to start your creative journey?"
type: string type: string
community_landing_hero_card_enabled: hero_card_enabled:
default: true default: true
type: bool type: bool
community_landing_hero_background_image_url: hero_background_image_url:
default: "" default: ""
type: string type: string
community_landing_hero_image_urls: hero_image_urls:
default: "" default: ""
type: list type: list
community_landing_hero_image_max_height: hero_image_max_height:
default: 500 default: 500
type: integer type: integer
min: 100 min: 100
max: 1200 max: 1200
community_landing_hero_primary_button_label: hero_primary_button_label:
default: "View Latest Topics" default: "View Latest Topics"
type: string type: string
community_landing_hero_primary_button_url: hero_primary_button_url:
default: "/latest" default: "/latest"
type: string type: string
community_landing_hero_secondary_button_label: hero_secondary_button_label:
default: "Explore Our Spaces" default: "Explore Our Spaces"
type: string type: string
community_landing_hero_secondary_button_url: hero_secondary_button_url:
default: "/login" default: "/login"
type: string type: string
community_landing_hero_bg_color: hero_bg_dark:
default: "" default: ""
type: string type: string
community_landing_hero_border_style: hero_bg_light:
default: ""
type: string
hero_min_height:
default: 0
type: integer
min: 0
max: 2000
hero_border_style:
default: "none" default: "none"
type: enum type: enum
choices: choices:
@@ -133,31 +134,39 @@ plugins:
# ══════════════════════════════════════════ # ══════════════════════════════════════════
# 3. Premium Stats Section # 3. Premium Stats Section
# ══════════════════════════════════════════ # ══════════════════════════════════════════
community_landing_stats_title: stats_title:
default: "Premium Stats" default: "Premium Stats"
type: string type: string
community_landing_stat_icon_color: stat_icon_color:
default: "#d4a24e" default: "d4a24e"
type: string type: color
community_landing_stat_members_label: stat_members_label:
default: "Members" default: "Members"
type: string type: string
community_landing_stat_topics_label: stat_topics_label:
default: "Topics" default: "Topics"
type: string type: string
community_landing_stat_posts_label: stat_posts_label:
default: "Posts" default: "Posts"
type: string type: string
community_landing_stat_likes_label: stat_likes_label:
default: "Likes" default: "Likes"
type: string type: string
community_landing_stat_chats_label: stat_chats_label:
default: "Chats" default: "Chats"
type: string type: string
community_landing_stats_bg_color: stats_bg_dark:
default: "" default: ""
type: string type: string
community_landing_stats_border_style: stats_bg_light:
default: ""
type: string
stats_min_height:
default: 0
type: integer
min: 0
max: 2000
stats_border_style:
default: "none" default: "none"
type: enum type: enum
choices: choices:
@@ -169,43 +178,51 @@ plugins:
# ══════════════════════════════════════════ # ══════════════════════════════════════════
# 4. About Community Section # 4. About Community Section
# ══════════════════════════════════════════ # ══════════════════════════════════════════
community_landing_about_enabled: about_enabled:
default: true default: true
type: bool type: bool
community_landing_about_heading_enabled: about_heading_enabled:
default: true default: true
type: bool type: bool
community_landing_about_heading: about_heading:
default: "About Community" default: "About Community"
type: string type: string
community_landing_about_title: about_title:
default: "Community Name" default: "Community Name"
type: string type: string
community_landing_about_role: about_role:
default: "" default: ""
type: string type: string
community_landing_about_body: about_body:
default: "" default: ""
type: string type: string
community_landing_about_image_url: about_image_url:
default: "" default: ""
type: string type: string
community_landing_about_gradient_start: about_gradient_start:
default: "#fdf6ec" default: "fdf6ec"
type: string type: color
community_landing_about_gradient_mid: about_gradient_mid:
default: "#fef9f0" default: "fef9f0"
type: string type: color
community_landing_about_gradient_end: about_gradient_end:
default: "#fdf6ec" default: "fdf6ec"
type: string type: color
community_landing_about_background_image_url: about_background_image_url:
default: "" default: ""
type: string type: string
community_landing_about_bg_color: about_bg_dark:
default: "" default: ""
type: string type: string
community_landing_about_border_style: about_bg_light:
default: ""
type: string
about_min_height:
default: 0
type: integer
min: 0
max: 2000
about_border_style:
default: "none" default: "none"
type: enum type: enum
choices: choices:
@@ -217,19 +234,27 @@ plugins:
# ══════════════════════════════════════════ # ══════════════════════════════════════════
# 5. Trending Discussions Section # 5. Trending Discussions Section
# ══════════════════════════════════════════ # ══════════════════════════════════════════
community_landing_topics_enabled: topics_enabled:
default: true default: true
type: bool type: bool
community_landing_topics_title: topics_title:
default: "Trending Discussions" default: "Trending Discussions"
type: string type: string
community_landing_topics_count: topics_count:
default: 5 default: 5
type: integer type: integer
community_landing_topics_bg_color: topics_bg_dark:
default: "" default: ""
type: string type: string
community_landing_topics_border_style: topics_bg_light:
default: ""
type: string
topics_min_height:
default: 0
type: integer
min: 0
max: 2000
topics_border_style:
default: "none" default: "none"
type: enum type: enum
choices: choices:
@@ -241,22 +266,30 @@ plugins:
# ══════════════════════════════════════════ # ══════════════════════════════════════════
# 6. Top Creators Section # 6. Top Creators Section
# ══════════════════════════════════════════ # ══════════════════════════════════════════
community_landing_contributors_enabled: contributors_enabled:
default: true default: true
type: bool type: bool
community_landing_contributors_title: contributors_title:
default: "Top Creators" default: "Top Creators"
type: string type: string
community_landing_contributors_days: contributors_days:
default: 90 default: 90
type: integer type: integer
community_landing_contributors_count: contributors_count:
default: 10 default: 10
type: integer type: integer
community_landing_contributors_bg_color: contributors_bg_dark:
default: "" default: ""
type: string type: string
community_landing_contributors_border_style: contributors_bg_light:
default: ""
type: string
contributors_min_height:
default: 0
type: integer
min: 0
max: 2000
contributors_border_style:
default: "none" default: "none"
type: enum type: enum
choices: choices:
@@ -268,19 +301,27 @@ plugins:
# ══════════════════════════════════════════ # ══════════════════════════════════════════
# 7. Community Spaces Section # 7. Community Spaces Section
# ══════════════════════════════════════════ # ══════════════════════════════════════════
community_landing_groups_enabled: groups_enabled:
default: true default: true
type: bool type: bool
community_landing_groups_title: groups_title:
default: "Community Spaces" default: "Community Spaces"
type: string type: string
community_landing_groups_count: groups_count:
default: 5 default: 5
type: integer type: integer
community_landing_groups_bg_color: groups_bg_dark:
default: "" default: ""
type: string type: string
community_landing_groups_border_style: groups_bg_light:
default: ""
type: string
groups_min_height:
default: 0
type: integer
min: 0
max: 2000
groups_border_style:
default: "none" default: "none"
type: enum type: enum
choices: choices:
@@ -292,55 +333,63 @@ plugins:
# ══════════════════════════════════════════ # ══════════════════════════════════════════
# 8. App Download CTA Section # 8. App Download CTA Section
# ══════════════════════════════════════════ # ══════════════════════════════════════════
community_landing_show_app_ctas: show_app_ctas:
default: true default: true
type: bool type: bool
community_landing_ios_app_url: ios_app_url:
default: "" default: ""
type: string type: string
community_landing_android_app_url: android_app_url:
default: "" default: ""
type: string type: string
community_landing_ios_app_badge_image_url: ios_app_badge_image_url:
default: "" default: ""
type: string type: string
community_landing_android_app_badge_image_url: android_app_badge_image_url:
default: "" default: ""
type: string type: string
community_landing_app_badge_height: app_badge_height:
default: 45 default: 45
type: integer type: integer
min: 30 min: 30
max: 80 max: 80
community_landing_app_badge_style: app_badge_style:
default: "rounded" default: "rounded"
type: enum type: enum
choices: choices:
- rounded - rounded
- pill - pill
- square - square
community_landing_app_cta_headline: app_cta_headline:
default: "Get the best experience on our app" default: "Get the best experience on our app"
type: string type: string
community_landing_app_cta_subtext: app_cta_subtext:
default: "Available free on iOS and Android" default: "Available free on iOS and Android"
type: string type: string
community_landing_app_cta_gradient_start: app_cta_gradient_start:
default: "#d4a24e" default: "d4a24e"
type: string type: color
community_landing_app_cta_gradient_mid: app_cta_gradient_mid:
default: "#c4922e" default: "c4922e"
type: string type: color
community_landing_app_cta_gradient_end: app_cta_gradient_end:
default: "#b8862e" default: "b8862e"
type: string type: color
community_landing_app_cta_image_url: app_cta_image_url:
default: "" default: ""
type: string type: string
community_landing_app_cta_bg_color: app_cta_bg_dark:
default: "" default: ""
type: string type: string
community_landing_app_cta_border_style: app_cta_bg_light:
default: ""
type: string
app_cta_min_height:
default: 0
type: integer
min: 0
max: 2000
app_cta_border_style:
default: "none" default: "none"
type: enum type: enum
choices: choices:
@@ -352,19 +401,22 @@ plugins:
# ══════════════════════════════════════════ # ══════════════════════════════════════════
# 9. Footer # 9. Footer
# ══════════════════════════════════════════ # ══════════════════════════════════════════
community_landing_footer_description: footer_description:
default: "" default: ""
type: string type: string
community_landing_footer_text: footer_text:
default: "" default: ""
type: string type: string
community_landing_footer_links: footer_links:
default: '[{"label":"Brand","url":"/about"},{"label":"Links","url":"/links"},{"label":"Terms","url":"/tos"},{"label":"Privacy","url":"/privacy"}]' default: '[{"label":"Brand","url":"/about"},{"label":"Links","url":"/links"},{"label":"Terms","url":"/tos"},{"label":"Privacy","url":"/privacy"}]'
type: string type: string
community_landing_footer_bg_color: footer_bg_dark:
default: "" default: ""
type: string type: string
community_landing_footer_border_style: footer_bg_light:
default: ""
type: string
footer_border_style:
default: "solid" default: "solid"
type: enum type: enum
choices: choices:
@@ -372,3 +424,10 @@ plugins:
- solid - solid
- dashed - dashed
- dotted - dotted
# ══════════════════════════════════════════
# Custom CSS (last)
# ══════════════════════════════════════════
custom_css:
default: ""
type: text

View 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

View 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

View 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

View 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\">&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

View 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

548
plugin.rb
View File

@@ -2,9 +2,10 @@
# name: community-landing # name: community-landing
# about: Branded public landing page for unauthenticated visitors # about: Branded public landing page for unauthenticated visitors
# version: 2.1.0 # version: 2.3.0
# authors: Community # authors: DPN MEDiA WORKS
# url: https://github.com/community/community-landing # url: https://github.com/dpnmw/community-landing
# meta_url: https://dpnmediaworks.com
enabled_site_setting :community_landing_enabled enabled_site_setting :community_landing_enabled
@@ -16,6 +17,12 @@ after_initialize do
PLUGIN_DIR = File.expand_path("..", __FILE__) PLUGIN_DIR = File.expand_path("..", __FILE__)
end end
require_relative "lib/community_landing/icons"
require_relative "lib/community_landing/helpers"
require_relative "lib/community_landing/data_fetcher"
require_relative "lib/community_landing/style_builder"
require_relative "lib/community_landing/page_builder"
class ::CommunityLanding::LandingController < ::ApplicationController class ::CommunityLanding::LandingController < ::ApplicationController
requires_plugin CommunityLanding::PLUGIN_NAME requires_plugin CommunityLanding::PLUGIN_NAME
@@ -25,11 +32,13 @@ after_initialize do
content_security_policy false content_security_policy false
def index def index
fetch_community_data data = CommunityLanding::DataFetcher.fetch
css = load_file("assets", "stylesheets", "community_landing", "landing.css") css = load_file("assets", "stylesheets", "community_landing", "landing.css")
js = load_file("assets", "javascripts", "community_landing", "landing.js") js = load_file("assets", "javascripts", "community_landing", "landing.js")
html = CommunityLanding::PageBuilder.new(data: data, css: css, js: js).build
base_url = Discourse.base_url base_url = Discourse.base_url
csp = "default-src 'self' #{base_url}; " \ csp = "default-src 'self' #{base_url}; " \
"script-src 'self' 'unsafe-inline'; " \ "script-src 'self' 'unsafe-inline'; " \
@@ -39,7 +48,7 @@ after_initialize do
"frame-ancestors 'self'" "frame-ancestors 'self'"
response.headers["Content-Security-Policy"] = csp response.headers["Content-Security-Policy"] = csp
render html: build_html(css, js).html_safe, layout: false, content_type: "text/html" render html: html.html_safe, layout: false, content_type: "text/html"
end end
private private
@@ -49,535 +58,6 @@ after_initialize do
rescue StandardError => e rescue StandardError => e
"/* Error loading #{path_parts.last}: #{e.message} */" "/* Error loading #{path_parts.last}: #{e.message} */"
end 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 = '<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>'
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" : ""
"<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_color};
--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_color};
--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_color};
--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
# ── 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)
"<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
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 << "<!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.community_landing_hero_title)} | #{e(site_name)}</title>\n"
html << "<meta name=\"description\" content=\"#{e(s.community_landing_hero_subtitle)}\">\n"
html << "<meta property=\"og:type\" content=\"website\">\n"
html << "<meta property=\"og:title\" content=\"#{e(s.community_landing_hero_title)}\">\n"
html << "<meta property=\"og:description\" content=\"#{e(s.community_landing_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 << build_color_overrides(s)
# Custom CSS injection
custom_css = s.community_landing_custom_css.presence rescue nil
if custom_css
html << "<style>\n/* Custom CSS */\n#{custom_css}\n</style>\n"
end
html << "</head>\n<body class=\"cl-body\">\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 << "<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_h)
else
html << "<span class=\"cl-navbar__site-name\">#{e(site_name)}</span>"
end
html << "</a>\n"
html << "</div>"
html << "<div class=\"cl-navbar__right\">"
html << "<button class=\"cl-theme-toggle\" aria-label=\"Toggle theme\">#{SUN_SVG}#{MOON_SVG}</button>\n"
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 << "<button class=\"cl-theme-toggle\" aria-label=\"Toggle theme\">#{SUN_SVG}#{MOON_SVG}</button>\n"
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"
# ── 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 << "<section class=\"cl-hero#{hero_card ? ' cl-hero--card' : ''}\" id=\"cl-hero\"#{hero_section_style}>\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"
html << "<div class=\"cl-hero__content\">\n"
title_words = s.community_landing_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.community_landing_hero_title)}</span></h1>\n"
end
html << "<p class=\"cl-hero__subtitle\">#{e(s.community_landing_hero_subtitle)}</p>\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 << "<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"
html << "</div>\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 << "<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"
# ── 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 << "<section class=\"cl-stats cl-anim\" id=\"cl-stats-row\"#{section_style(stats_bg, stats_border)}><div class=\"cl-container\">\n"
html << "<h2 class=\"cl-section-title\">#{e(stats_title)}</h2>\n"
html << "<div class=\"cl-stats__grid\">\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 << "</div>\n</div></section>\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 << "<section class=\"cl-about cl-anim\" id=\"cl-about\"#{section_style(about_bg, about_border)}><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 << QUOTE_SVG
if about_body.present?
html << "<div class=\"cl-about__body\">#{about_body}</div>\n"
end
html << "<div class=\"cl-about__meta\">\n"
if about_image
html << "<img src=\"#{about_image}\" alt=\"\" class=\"cl-about__avatar\">\n"
end
html << "<div class=\"cl-about__meta-text\">\n"
html << "<span class=\"cl-about__author\">#{e(s.community_landing_about_title)}</span>\n"
html << "<span class=\"cl-about__role\">#{e(about_role)}</span>\n"
html << "</div></div>\n"
html << "</div>\n"
html << "</div></section>\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 << "<section class=\"cl-topics cl-anim\" id=\"cl-topics\"#{section_style(topics_bg, topics_border)}><div class=\"cl-container\">\n"
html << "<h2 class=\"cl-section-title\">#{e(s.community_landing_topics_title)}</h2>\n"
html << "<div class=\"cl-topics__scroll\">\n"
@hot_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\">#{COMMENT_SVG} #{topic_replies}</span>"
html << "<span class=\"cl-topic-card__stat\">#{HEART_SVG} #{topic_likes}</span>"
html << "</div>"
html << "</a>\n"
end
html << "</div>\n</div></section>\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 << "<section class=\"cl-creators cl-anim\" id=\"cl-contributors\"#{section_style(contrib_bg, contrib_border)}><div class=\"cl-container\">\n"
html << "<h2 class=\"cl-section-title\">#{e(s.community_landing_contributors_title)}</h2>\n"
html << "<div class=\"cl-creators__list\">\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 << "<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__activity\">#{activity_count} Activity</span>\n"
html << "</a>\n"
end
html << "</div>\n</div></section>\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 << "<section class=\"cl-spaces cl-anim\" id=\"cl-groups\"#{section_style(groups_bg, groups_border)}><div class=\"cl-container\">\n"
html << "<h2 class=\"cl-section-title\">#{e(s.community_landing_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"
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 << "<section class=\"cl-app-cta cl-anim\" id=\"cl-app-cta\"#{section_style(app_bg, app_border)}><div class=\"cl-container\">\n"
html << "<div class=\"cl-app-cta__inner\">\n"
html << "<div class=\"cl-app-cta__content\">\n"
html << "<h2 class=\"cl-app-cta__headline\">#{e(s.community_landing_app_cta_headline)}</h2>\n"
html << "<p class=\"cl-app-cta__subtext\">#{e(s.community_landing_app_cta_subtext)}</p>\n" if s.community_landing_app_cta_subtext.present?
html << "<div class=\"cl-app-cta__badges\">\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 << "<a href=\"#{s.community_landing_ios_app_url}\" class=\"cl-app-badge-img #{style_class}\" target=\"_blank\" rel=\"noopener noreferrer\">"
html << "<img src=\"#{ios_custom}\" alt=\"App Store\" style=\"height: #{badge_h}px; width: auto;\">"
html << "</a>\n"
else
html << "<a href=\"#{s.community_landing_ios_app_url}\" class=\"cl-app-badge cl-app-badge--#{badge_style == 'pill' ? 'pill' : badge_style == 'square' ? 'square' : 'rounded'}\" target=\"_blank\" rel=\"noopener noreferrer\">"
html << "<span class=\"cl-app-badge__icon\">#{IOS_BADGE_SVG}</span>"
html << "<span class=\"cl-app-badge__label\">App Store</span>"
html << "</a>\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 << "<a href=\"#{s.community_landing_android_app_url}\" class=\"cl-app-badge-img #{style_class}\" target=\"_blank\" rel=\"noopener noreferrer\">"
html << "<img src=\"#{android_custom}\" alt=\"Google Play\" style=\"height: #{badge_h}px; width: auto;\">"
html << "</a>\n"
else
html << "<a href=\"#{s.community_landing_android_app_url}\" class=\"cl-app-badge cl-app-badge--#{badge_style == 'pill' ? 'pill' : badge_style == 'square' ? 'square' : 'rounded'}\" target=\"_blank\" rel=\"noopener noreferrer\">"
html << "<span class=\"cl-app-badge__icon\">#{ANDROID_BADGE_SVG}</span>"
html << "<span class=\"cl-app-badge__label\">Google Play</span>"
html << "</a>\n"
end
end
html << "</div>\n"
html << "</div>\n"
if app_image
html << "<div class=\"cl-app-cta__image\">\n"
html << "<img src=\"#{app_image}\" alt=\"App preview\" class=\"cl-app-cta__img\">\n"
html << "</div>\n"
end
html << "</div>\n"
html << "</div></section>\n"
end
# ── 9. FOOTER DESCRIPTION ──
if s.community_landing_footer_description.present?
html << "<div class=\"cl-footer-desc\"><div class=\"cl-container\">\n"
html << "<p class=\"cl-footer-desc__text\">#{s.community_landing_footer_description}</p>\n"
html << "</div></div>\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 << "<footer class=\"cl-footer\" id=\"cl-footer\"#{footer_style_attr}>\n"
html << "<div class=\"cl-container\">\n"
html << "<div class=\"cl-footer__row\">\n"
html << "<div class=\"cl-footer__left\">\n"
html << "<div class=\"cl-footer__brand\">"
if footer_logo_url
html << "<img src=\"#{footer_logo_url}\" alt=\"#{e(site_name)}\" class=\"cl-footer__logo\" style=\"height: #{logo_h}px;\">"
elsif has_logo
html << render_logo(logo_dark_url, logo_light_url, site_name, "cl-footer__logo", logo_h)
else
html << "<span class=\"cl-footer__site-name\">#{e(site_name)}</span>"
end
html << "</div>\n"
html << "<div class=\"cl-footer__links\">\n"
begin
links = JSON.parse(s.community_landing_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"
html << "</div>\n"
html << "<div class=\"cl-footer__right\">\n"
html << "<span class=\"cl-footer__copy\">&copy; #{Time.now.year} #{e(site_name)}</span>\n"
html << "</div>\n"
html << "</div>\n"
if s.community_landing_footer_text.present?
html << "<div class=\"cl-footer__text\">#{s.community_landing_footer_text}</div>\n"
end
html << "</div></footer>\n"
html << "<script>\n#{js}\n</script>\n"
html << "</body>\n</html>"
html
end
def stats_counter_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
end end
Discourse::Application.routes.prepend do Discourse::Application.routes.prepend do