From 8ce1c36bb983cca2d7fec9470094683533e08e8a Mon Sep 17 00:00:00 2001 From: DPN MW Date: Fri, 6 Mar 2026 22:02:23 -0400 Subject: [PATCH] Settings Navigation Repositioned --- .../javascripts/community_landing/landing.js | 85 +-------- .../community-landing-admin-tabs.js | 162 +++++++++++++++--- .../stylesheets/community_landing/admin.css | 25 ++- .../stylesheets/community_landing/landing.css | 127 +++++++++----- config/locales/en.yml | 8 +- config/settings.yml | 25 ++- lib/community_landing/data_fetcher.rb | 13 +- lib/community_landing/icons.rb | 2 +- lib/community_landing/page_builder.rb | 55 +++--- lib/community_landing/style_builder.rb | 14 ++ 10 files changed, 321 insertions(+), 195 deletions(-) diff --git a/assets/javascripts/community_landing/landing.js b/assets/javascripts/community_landing/landing.js index 0215fc4..1876fee 100644 --- a/assets/javascripts/community_landing/landing.js +++ b/assets/javascripts/community_landing/landing.js @@ -153,90 +153,7 @@ } // ═══════════════════════════════════════════════════════════════════ - // 6. SMOOTH HORIZONTAL DRAG — trending discussions - // Prevents link clicks during drag, smooth momentum scrolling - // ═══════════════════════════════════════════════════════════════════ - $$(".cl-topics__scroll").forEach(function (scrollEl) { - var isDown = false; - var startX = 0; - var scrollStart = 0; - var moved = false; - var velX = 0; - var lastX = 0; - var lastTime = 0; - var momentumId = null; - - function stopMomentum() { - if (momentumId) { - cancelAnimationFrame(momentumId); - momentumId = null; - } - } - - function doMomentum() { - if (Math.abs(velX) < 0.5) return; - scrollEl.scrollLeft -= velX; - velX *= 0.92; - momentumId = requestAnimationFrame(doMomentum); - } - - scrollEl.addEventListener("mousedown", function (ev) { - stopMomentum(); - isDown = true; - moved = false; - startX = ev.pageX; - scrollStart = scrollEl.scrollLeft; - lastX = ev.pageX; - lastTime = Date.now(); - velX = 0; - scrollEl.style.scrollSnapType = "none"; - scrollEl.style.userSelect = "none"; - }); - - window.addEventListener("mousemove", function (ev) { - if (!isDown) return; - var dx = ev.pageX - startX; - if (Math.abs(dx) > 3) moved = true; - var now = Date.now(); - var dt = now - lastTime; - if (dt > 0) { - velX = (ev.pageX - lastX) / dt * 16; - } - lastX = ev.pageX; - lastTime = now; - scrollEl.scrollLeft = scrollStart - dx; - }); - - function endDrag() { - if (!isDown) return; - isDown = false; - scrollEl.style.userSelect = ""; - if (Math.abs(velX) > 1) { - doMomentum(); - } - // Re-enable snap after a tick - setTimeout(function () { - scrollEl.style.scrollSnapType = ""; - }, 100); - } - - window.addEventListener("mouseup", endDrag); - scrollEl.addEventListener("mouseleave", endDrag); - - // Prevent link navigation if we dragged - scrollEl.addEventListener("click", function (ev) { - if (moved) { - ev.preventDefault(); - ev.stopPropagation(); - moved = false; - } - }, true); - - // Touch support — native scrolling works, no extra handling needed - }); - - // ═══════════════════════════════════════════════════════════════════ - // 7. APP BADGE DETECTION + // 6. APP BADGE DETECTION // ═══════════════════════════════════════════════════════════════════ var ua = navigator.userAgent || ""; if (/iPhone|iPad|iPod/.test(ua)) $$(".cl-app-badge--ios").forEach(function (e) { e.classList.add("highlighted"); }); diff --git a/assets/javascripts/discourse/initializers/community-landing-admin-tabs.js b/assets/javascripts/discourse/initializers/community-landing-admin-tabs.js index 805a0b2..81a87f9 100644 --- a/assets/javascripts/discourse/initializers/community-landing-admin-tabs.js +++ b/assets/javascripts/discourse/initializers/community-landing-admin-tabs.js @@ -33,7 +33,7 @@ const TABS = [ id: "stats", label: "Stats", settings: new Set([ - "stats_title", "stat_icon_color", + "stats_title", "stat_icon_color", "stat_icon_bg_color", "stat_icon_shape", "stat_counter_color", "stat_members_label", "stat_topics_label", "stat_posts_label", "stat_likes_label", "stat_chats_label", "stats_bg_dark", "stats_bg_light", "stats_min_height", "stats_border_style" @@ -70,7 +70,7 @@ const TABS = [ id: "groups", label: "Spaces", settings: new Set([ - "groups_enabled", "groups_title", "groups_count", + "groups_enabled", "groups_title", "groups_count", "groups_selected", "groups_card_bg_color", "groups_bg_dark", "groups_bg_light", "groups_min_height", "groups_border_style" ]) }, @@ -94,46 +94,73 @@ const TABS = [ "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"; +let filterActive = false; +let isActive = false; +let recheckTimer = null; + +function getContainer() { + return ( + document.querySelector(".admin-plugin-config-area") || + document.querySelector(".admin-detail") + ); +} + +function applyTabFilter() { + const container = getContainer(); + if (!container) return; -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"; + row.classList.toggle( + "cl-tab-hidden", + !filterActive && !tab.settings.has(name) + ); }); + + const tabBar = container.querySelector(".cl-admin-tabs"); + if (tabBar) { + tabBar.classList.toggle("filter-active", filterActive); + } +} + +function findFilterInput(container) { + for (const input of container.querySelectorAll("input")) { + if (input.closest(".row.setting") || input.closest(".cl-admin-tabs")) { + continue; + } + const t = (input.type || "text").toLowerCase(); + if (t === "text" || t === "search") return input; + } + return null; } function buildTabsUI() { - const container = - document.querySelector(".admin-plugin-config-area") || - document.querySelector(".admin-detail"); + const container = getContainer(); if (!container) return false; - // Already injected? - if (container.querySelector(".cl-admin-tabs")) return true; + // Already injected — just re-apply filter + if (container.querySelector(".cl-admin-tabs")) { + applyTabFilter(); + 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]; + // Verify our plugin settings are present const hasOurs = Array.from(allRows).some((row) => - firstTab.settings.has(row.getAttribute("data-setting")) + TABS[0].settings.has(row.getAttribute("data-setting")) ); if (!hasOurs) return false; - // Create tab bar + // Build tab bar const tabBar = document.createElement("div"); tabBar.className = "cl-admin-tabs"; @@ -141,30 +168,85 @@ function buildTabsUI() { 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.dataset.tab = tab.id; btn.addEventListener("click", () => { currentTab = tab.id; + filterActive = false; + + // Clear Discourse filter input so it doesn't conflict + const fi = findFilterInput(container); + if (fi && fi.value) { + fi.value = ""; + fi.dispatchEvent(new Event("input", { bubbles: true })); + } + tabBar .querySelectorAll(".cl-admin-tab") .forEach((b) => b.classList.remove("active")); btn.classList.add("active"); - applyTabFilter(container); + applyTabFilter(); }); tabBar.appendChild(btn); }); - // Insert tab bar before the first setting row - const settingsParent = allRows[0].parentNode; - settingsParent.insertBefore(tabBar, allRows[0]); + // ── Insertion strategy: place tabs as high as possible ── + + let inserted = false; + + // Strategy 1: Top of .admin-plugin-config-area__content (above filter bar) + const contentArea = container.querySelector( + ".admin-plugin-config-area__content" + ); + if (contentArea) { + const form = contentArea.querySelector("form"); + const target = form || contentArea; + target.insertBefore(tabBar, target.firstChild); + inserted = true; + } + + // Strategy 2: Before the filter controls + if (!inserted) { + const filterArea = container.querySelector( + ".admin-site-settings-filter-controls, .setting-filter" + ); + if (filterArea) { + filterArea.parentNode.insertBefore(tabBar, filterArea); + inserted = true; + } + } + + // Strategy 3: Before the first setting row (fallback) + if (!inserted) { + allRows[0].parentNode.insertBefore(tabBar, allRows[0]); + } - // Add class to disable separator borders container.classList.add("cl-tabs-active"); - - // Apply initial filter - applyTabFilter(container); + applyTabFilter(); return true; } +// ── Global filter detection via event delegation ── +// This survives DOM re-renders because it's on document, not on a specific input +document.addEventListener( + "input", + (e) => { + if (!isActive) return; + const t = e.target; + if (!t || !t.closest) return; + if (t.closest(".row.setting") || t.closest(".cl-admin-tabs")) return; + + const container = getContainer(); + if (!container || !container.contains(t)) return; + + const hasText = t.value.trim().length > 0; + if (hasText !== filterActive) { + filterActive = hasText; + applyTabFilter(); + } + }, + true +); + export default { name: "community-landing-admin-tabs", @@ -175,6 +257,10 @@ export default { url.includes("community-landing") || url.includes("community_landing") ) { + isActive = true; + filterActive = false; + + // Initial injection with retries let attempts = 0; const tryInject = () => { if (buildTabsUI() || attempts > 15) return; @@ -182,6 +268,28 @@ export default { setTimeout(tryInject, 200); }; tryInject(); + + // Periodic re-check: re-injects tab bar if Discourse re-renders the DOM + if (!recheckTimer) { + recheckTimer = setInterval(() => { + if (!isActive) { + clearInterval(recheckTimer); + recheckTimer = null; + return; + } + const c = getContainer(); + if (c && !c.querySelector(".cl-admin-tabs")) { + buildTabsUI(); + } + }, 500); + } + } else { + // Left plugin settings page — clean up + isActive = false; + if (recheckTimer) { + clearInterval(recheckTimer); + recheckTimer = null; + } } }); }); diff --git a/assets/stylesheets/community_landing/admin.css b/assets/stylesheets/community_landing/admin.css index 4bb73ae..4843fa4 100644 --- a/assets/stylesheets/community_landing/admin.css +++ b/assets/stylesheets/community_landing/admin.css @@ -3,13 +3,19 @@ Tab navigation + fallback separators ═══════════════════════════════════════════════════════════════════ */ +/* ── Tab-hidden class (used instead of inline display:none) ── */ + +.cl-tab-hidden { + display: none !important; +} + /* ── Tab Navigation ── */ .cl-admin-tabs { display: flex; flex-wrap: wrap; gap: 0; - margin: 0 0 24px 0; + margin: 0 0 20px 0; padding: 0; border-bottom: 1px solid var(--primary-low, rgba(0, 0, 0, 0.1)); } @@ -24,7 +30,7 @@ cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; - transition: color 0.15s ease, border-color 0.15s ease; + transition: color 0.15s ease, border-color 0.15s ease, opacity 0.15s ease; } .cl-admin-tab:hover { @@ -36,6 +42,15 @@ border-bottom-color: var(--tertiary, #0088cc); } +/* Dimmed state when Discourse filter/search is active */ +.cl-admin-tabs.filter-active .cl-admin-tab { + opacity: 0.4; +} + +.cl-admin-tabs.filter-active .cl-admin-tab.active { + border-bottom-color: transparent; +} + /* Dark mode tabs */ html.dark-scheme .cl-admin-tabs { border-bottom-color: rgba(255, 255, 255, 0.1); @@ -75,8 +90,7 @@ html.dark-scheme .cl-admin-tab:hover { .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"] { +.admin-detail:not(.cl-tabs-active) .row.setting[data-setting^="footer_"] { margin-bottom: 20px; } @@ -92,8 +106,7 @@ html.dark-scheme .cl-admin-tab:hover { .admin-detail:not(.cl-tabs-active) .row.setting[data-setting="contributors_enabled"], .admin-detail:not(.cl-tabs-active) .row.setting[data-setting="groups_enabled"], .admin-detail:not(.cl-tabs-active) .row.setting[data-setting="show_app_ctas"], -.admin-detail:not(.cl-tabs-active) .row.setting[data-setting="footer_description"], -.admin-detail:not(.cl-tabs-active) .row.setting[data-setting="custom_css"] { +.admin-detail:not(.cl-tabs-active) .row.setting[data-setting="footer_description"] { border-top: 2px solid rgba(0, 0, 0, 0.12); margin-top: 28px; padding-top: 24px; diff --git a/assets/stylesheets/community_landing/landing.css b/assets/stylesheets/community_landing/landing.css index 3982c37..ed7a0de 100644 --- a/assets/stylesheets/community_landing/landing.css +++ b/assets/stylesheets/community_landing/landing.css @@ -329,7 +329,7 @@ .cl-hero__actions { display: flex; flex-wrap: wrap; gap: 0.6rem; } /* ═══════════════════════════════════════════════════════════════════ - 3. PREMIUM STATS — icon + label on one line, counter below + 3. PREMIUM STATS — icon with bg, label, animated counter ═══════════════════════════════════════════════════════════════════ */ .cl-stats { padding: 2.5rem 0 2rem; } @@ -340,7 +340,7 @@ @media (min-width: 1024px) { .cl-stats__grid { grid-template-columns: repeat(5, 1fr); } } .cl-stat-card { - display: flex; flex-direction: column; align-items: center; gap: 0.5rem; + display: flex; flex-direction: column; align-items: center; gap: 0.6rem; padding: 1.5rem 1rem; background: var(--cl-card); border: 1px solid var(--cl-border); border-radius: var(--cl-radius); text-align: center; @@ -348,42 +348,74 @@ } .cl-stat-card:hover { border-color: var(--cl-border-hover); transform: translateY(-3px); box-shadow: 0 8px 24px rgba(0,0,0,0.08); } -/* Icon + Label on same line */ -.cl-stat-card__top { - display: flex; align-items: center; gap: 0.4rem; +/* Icon with background */ +.cl-stat-card__icon-wrap { + display: flex; align-items: center; justify-content: center; + width: 48px; height: 48px; + background: var(--cl-stat-icon-bg); + color: var(--cl-stat-icon-color); + transition: transform 0.2s ease; } -.cl-stat-card__icon { color: var(--cl-stat-icon-color); display: flex; align-items: center; } -.cl-stat-card__icon svg { width: 22px; height: 22px; } +.cl-stat-card:hover .cl-stat-card__icon-wrap { transform: scale(1.08); } +.cl-stat-card__icon-wrap svg { width: 24px; height: 24px; } +.cl-stat-icon--circle { border-radius: 50%; } +.cl-stat-icon--rounded { border-radius: 12px; } + .cl-stat-card__label { font-size: 0.78rem; color: var(--cl-muted); font-weight: 600; } -/* Counter below */ +/* Counter */ .cl-stat-card__value { font-size: 1.75rem; font-weight: 800; - color: var(--cl-text-strong); letter-spacing: -0.02em; + color: var(--cl-stat-counter-color); letter-spacing: -0.02em; line-height: 1; } /* ═══════════════════════════════════════════════════════════════════ - 4. ABOUT COMMUNITY — full-width gradient card + 4. ABOUT — split layout: image left on gradient, text right ═══════════════════════════════════════════════════════════════════ */ -.cl-about { padding: 1rem 0 2rem; } +.cl-about { padding: 1.5rem 0 2rem; } .cl-about__card { background: var(--cl-about-gradient); border: 1px solid var(--cl-border); border-radius: 20px; - padding: 2rem 2.5rem; - position: relative; + overflow: hidden; box-shadow: 0 2px 16px rgba(0,0,0,0.04); background-size: cover; background-position: center; + display: flex; flex-direction: column; +} +@media (min-width: 768px) { + .cl-about__card { flex-direction: row; min-height: 340px; } +} + +/* Left side — image on gradient */ +.cl-about__left { + display: flex; align-items: center; justify-content: center; + padding: 2rem; + position: relative; +} +@media (min-width: 768px) { + .cl-about__left { flex: 0 0 40%; max-width: 40%; } +} +.cl-about__image { + width: 100%; max-width: 280px; height: auto; + border-radius: 16px; object-fit: cover; + box-shadow: 0 8px 32px rgba(0,0,0,0.15); + position: relative; z-index: 1; +} + +/* Right side — text content */ +.cl-about__right { + flex: 1; padding: 2rem 2.5rem; + display: flex; flex-direction: column; justify-content: center; } .cl-about__heading { font-size: 1.35rem; font-weight: 800; color: var(--cl-text-strong); - margin: 0 0 1rem; + margin: 0 0 0.75rem; } .cl-about__quote-mark { @@ -393,43 +425,32 @@ .cl-about__body { color: var(--cl-text); font-size: 0.95rem; line-height: 1.75; - margin-bottom: 1rem; max-width: 800px; + margin-bottom: 1rem; } .cl-about__body p { margin: 0 0 0.75rem; } .cl-about__body a { color: var(--cl-accent); } .cl-about__meta { display: flex; align-items: center; gap: 0.75rem; - padding-top: 1rem; border-top: 1px solid var(--cl-border); -} -.cl-about__avatar { - width: 44px; height: 44px; border-radius: 50%; object-fit: cover; - border: 2px solid var(--cl-border); + padding-top: 1rem; border-top: 1px solid rgba(0,0,0,0.08); } .cl-about__meta-text { display: flex; flex-direction: column; } .cl-about__author { font-size: 0.88rem; font-weight: 600; color: var(--cl-text-strong); } .cl-about__role { font-size: 0.78rem; color: var(--cl-muted); } /* ═══════════════════════════════════════════════════════════════════ - 5. TRENDING DISCUSSIONS — horizontal scrollable cards + 5. TRENDING DISCUSSIONS — 4-per-row grid ═══════════════════════════════════════════════════════════════════ */ .cl-topics { padding: 1.5rem 0 2rem; } -.cl-topics__scroll { - display: flex; gap: 0.75rem; - overflow-x: auto; overflow-y: hidden; - scroll-snap-type: x mandatory; - -webkit-overflow-scrolling: touch; - padding-bottom: 0.75rem; - scrollbar-width: none; - cursor: grab; +.cl-topics__grid { + display: grid; grid-template-columns: repeat(1, 1fr); gap: 0.75rem; } -.cl-topics__scroll::-webkit-scrollbar { display: none; } -.cl-topics__scroll:active { cursor: grabbing; } +@media (min-width: 480px) { .cl-topics__grid { grid-template-columns: repeat(2, 1fr); } } +@media (min-width: 768px) { .cl-topics__grid { grid-template-columns: repeat(3, 1fr); } } +@media (min-width: 1024px) { .cl-topics__grid { grid-template-columns: repeat(4, 1fr); } } .cl-topic-card { - flex: 0 0 230px; - scroll-snap-align: start; display: flex; flex-direction: column; padding: 1.15rem 1.25rem; background: var(--cl-card); @@ -439,7 +460,6 @@ transition: all 0.2s ease; min-height: 145px; } -@media (min-width: 640px) { .cl-topic-card { flex: 0 0 250px; } } .cl-topic-card:hover { border-color: var(--cl-border-hover); transform: translateY(-2px); box-shadow: 0 6px 20px rgba(0,0,0,0.08); } .cl-topic-card__cat { @@ -500,7 +520,7 @@ } /* ═══════════════════════════════════════════════════════════════════ - 7. COMMUNITY SPACES — large colored icon cards + 7. COMMUNITY SPACES — colored header cards ═══════════════════════════════════════════════════════════════════ */ .cl-spaces { padding: 1.5rem 0 2rem; } @@ -512,31 +532,50 @@ @media (min-width: 1024px) { .cl-spaces__grid { grid-template-columns: repeat(5, 1fr); } } .cl-space-card { - display: flex; flex-direction: column; align-items: center; gap: 0.6rem; - padding: 1.5rem 1rem 1.25rem; - background: var(--cl-card); border: 1px solid var(--cl-border); + display: flex; flex-direction: column; + background: var(--cl-space-card-bg); border: 1px solid var(--cl-border); border-radius: var(--cl-radius); - text-decoration: none; text-align: center; + text-decoration: none; overflow: hidden; transition: all 0.25s ease; } .cl-space-card:hover { border-color: var(--cl-border-hover); transform: translateY(-3px); box-shadow: 0 8px 24px rgba(0,0,0,0.08); } +/* Colored header band */ +.cl-space-card__header { + background: var(--space-color); + padding: 1.25rem 1rem; + display: flex; align-items: center; justify-content: center; + position: relative; +} +.cl-space-card__header::after { + content: ""; position: absolute; inset: 0; + background: linear-gradient(180deg, rgba(255,255,255,0.1), rgba(0,0,0,0.05)); + pointer-events: none; +} + .cl-space-card__icon { - width: 64px; height: 64px; border-radius: 16px; + width: 52px; height: 52px; border-radius: 14px; display: flex; align-items: center; justify-content: center; overflow: hidden; - box-shadow: 0 4px 12px rgba(0,0,0,0.15); + background: rgba(255,255,255,0.2); + backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); + position: relative; z-index: 1; } .cl-space-card__icon img { width: 100%; height: 100%; object-fit: cover; } .cl-space-card__letter { - font-size: 1.6rem; font-weight: 800; color: #fff; + font-size: 1.4rem; font-weight: 800; color: #fff; line-height: 1; text-shadow: 0 1px 2px rgba(0,0,0,0.2); } +/* Card body */ +.cl-space-card__body { + padding: 0.85rem 1rem; + display: flex; flex-direction: column; gap: 0.15rem; +} .cl-space-card__name { - font-size: 0.85rem; font-weight: 700; color: var(--cl-text-strong); - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; + font-size: 0.82rem; font-weight: 700; color: var(--cl-text-strong); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .cl-space-card__sub { font-size: 0.72rem; color: var(--cl-muted); font-weight: 500; } diff --git a/config/locales/en.yml b/config/locales/en.yml index 9ae9ff4..3d7bab8 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -43,6 +43,9 @@ en: # ── 3. Stats Section ── 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." stat_icon_color: "Color for all stat counter icons. Hex value (e.g. #d4a24e)." + stat_icon_bg_color: "Background color behind each stat icon. Leave blank for a subtle accent tint." + stat_icon_shape: "Shape of the icon background: circle or rounded square." + stat_counter_color: "Color for the stat counter numbers. Leave blank for default text color." stat_members_label: "Custom label for the Members stat card." stat_topics_label: "Custom label for the Topics stat card." stat_posts_label: "Custom label for the Posts stat card." @@ -93,6 +96,8 @@ en: 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." groups_title: "Heading text above the group cards." groups_count: "Number of group cards to display." + groups_selected: "Show only specific groups. Enter group names separated by pipes (e.g. designers|developers|artists). Leave blank to auto-select public groups." + groups_card_bg_color: "Background color for each space card. Leave blank for default card styling." groups_bg_dark: "Background color for the spaces section in dark mode. Leave blank for default." 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." @@ -124,6 +129,3 @@ en: footer_bg_dark: "Background color for the footer bar in dark mode. Leave blank for default." 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." diff --git a/config/settings.yml b/config/settings.yml index 0bd7909..cfaa36f 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -140,6 +140,18 @@ plugins: stat_icon_color: default: "d4a24e" type: color + stat_icon_bg_color: + default: "" + type: string + stat_icon_shape: + default: "circle" + type: enum + choices: + - circle + - rounded + stat_counter_color: + default: "" + type: string stat_members_label: default: "Members" type: string @@ -310,6 +322,12 @@ plugins: groups_count: default: 5 type: integer + groups_selected: + default: "" + type: list + groups_card_bg_color: + default: "" + type: string groups_bg_dark: default: "" type: string @@ -424,10 +442,3 @@ plugins: - solid - dashed - dotted - - # ══════════════════════════════════════════ - # Custom CSS (last) - # ══════════════════════════════════════════ - custom_css: - default: "" - type: text diff --git a/lib/community_landing/data_fetcher.rb b/lib/community_landing/data_fetcher.rb index 5a5e03a..0a82a1f 100644 --- a/lib/community_landing/data_fetcher.rb +++ b/lib/community_landing/data_fetcher.rb @@ -19,12 +19,19 @@ module CommunityLanding .select("users.*, COUNT(posts.id) AS post_count") end - # Public groups + # Public groups — optionally filtered by selected names data[:groups] = if s.groups_enabled - Group + selected = s.groups_selected.presence + scope = Group .where(visibility_level: Group.visibility_levels[:public]) .where(automatic: false) - .limit(s.groups_count) + + if selected + names = selected.split("|").map(&:strip).reject(&:empty?) + scope = scope.where(name: names) if names.any? + end + + scope.limit(s.groups_count) end # Trending topics diff --git a/lib/community_landing/icons.rb b/lib/community_landing/icons.rb index ae2f9c6..678ff6d 100644 --- a/lib/community_landing/icons.rb +++ b/lib/community_landing/icons.rb @@ -4,7 +4,7 @@ module CommunityLanding module Icons SUN_SVG = '' MOON_SVG = '' - QUOTE_SVG = '' + QUOTE_SVG = '' STAT_MEMBERS_SVG = '' STAT_TOPICS_SVG = '' diff --git a/lib/community_landing/page_builder.rb b/lib/community_landing/page_builder.rb index c414459..2d81dc0 100644 --- a/lib/community_landing/page_builder.rb +++ b/lib/community_landing/page_builder.rb @@ -58,9 +58,6 @@ module CommunityLanding html << @styles.color_overrides html << @styles.section_backgrounds - custom_css = @s.custom_css.presence rescue nil - html << "\n" if custom_css - html << "\n" html end @@ -169,21 +166,22 @@ module CommunityLanding stats_title = @s.stats_title.presence || "Premium Stats" border = @s.stats_border_style rescue "none" min_h = @s.stats_min_height rescue 0 + icon_shape = @s.stat_icon_shape rescue "circle" html = +"" html << "
\n" html << "

#{e(stats_title)}

\n" html << "
\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 << stat_card(Icons::STAT_MEMBERS_SVG, stats[:members], @s.stat_members_label, icon_shape) + html << stat_card(Icons::STAT_TOPICS_SVG, stats[:topics], @s.stat_topics_label, icon_shape) + html << stat_card(Icons::STAT_POSTS_SVG, stats[:posts], @s.stat_posts_label, icon_shape) + html << stat_card(Icons::STAT_LIKES_SVG, stats[:likes], @s.stat_likes_label, icon_shape) + html << stat_card(Icons::STAT_CHATS_SVG, stats[:chats], @s.stat_chats_label, icon_shape) html << "
\n
\n" html end - # ── 4. ABOUT ── + # ── 4. ABOUT — split layout: image left on gradient, text right ── def render_about return "" unless @s.about_enabled @@ -193,21 +191,34 @@ module CommunityLanding 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" + about_bg_img = @s.about_background_image_url.presence border = @s.about_border_style rescue "none" min_h = @s.about_min_height rescue 0 html = +"" html << "
\n" html << "
\n" + + # Left side — image on gradient background + html << "
\n" + if about_image + html << "\"#{e(@s.about_title)}\"\n" + end + html << "
\n" + + # Right side — text content + html << "
\n" html << "

#{e(about_heading)}

\n" if about_heading_on html << Icons::QUOTE_SVG html << "
#{about_body}
\n" if about_body.present? html << "
\n" - html << "\"\"\n" if about_image html << "
\n" html << "#{e(@s.about_title)}\n" html << "#{e(about_role)}\n" - html << "
\n
\n
\n" + html << "\n" + html << "\n" + + html << "\n\n" html end @@ -223,7 +234,7 @@ module CommunityLanding html = +"" html << "
\n" html << "

#{e(@s.topics_title)}

\n" - html << "
\n" + html << "
\n" topics.each do |topic| topic_likes = topic.like_count rescue 0 @@ -288,21 +299,26 @@ module CommunityLanding html << "
\n" groups.each do |group| - display_name = group.name.tr("_-", " ").gsub(/\b\w/, &:upcase) + display_name = group.full_name.presence || 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) + sat = 55 + (group.name.bytes.first.to_i % 15) + light = 45 + (group.name.bytes.last.to_i % 12) + icon_color = "hsl(#{hue}, #{sat}%, #{light}%)" html << "\n" - html << "
" + html << "
\n" + html << "
" if group.flair_url.present? html << "\"\"" else html << "#{group.name[0].upcase}" end html << "
\n" + html << "
\n" + html << "
\n" html << "#{e(display_name)}\n" html << "#{group.user_count} members\n" + html << "
\n" html << "
\n" end @@ -397,12 +413,11 @@ module CommunityLanding # ── Shared helpers ── - def stat_card(icon_svg, count, label) + def stat_card(icon_svg, count, label, icon_shape = "circle") + shape_class = icon_shape == "rounded" ? "cl-stat-icon--rounded" : "cl-stat-icon--circle" "
\n" \ - "
\n" \ - "#{icon_svg}\n" \ + "
#{icon_svg}
\n" \ "#{e(label)}\n" \ - "
\n" \ "0\n" \ "
\n" end diff --git a/lib/community_landing/style_builder.rb b/lib/community_landing/style_builder.rb index fdb7c60..0b268f1 100644 --- a/lib/community_landing/style_builder.rb +++ b/lib/community_landing/style_builder.rb @@ -22,7 +22,15 @@ module CommunityLanding 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 + stat_icon_bg = hex(@s.stat_icon_bg_color.presence) rescue nil + stat_counter = hex(@s.stat_counter_color.presence) rescue nil + space_card_bg = hex(@s.groups_card_bg_color.presence) rescue nil accent_rgb = hex_to_rgb(accent) + stat_icon_rgb = hex_to_rgb(stat_icon) + + stat_icon_bg_val = stat_icon_bg || "rgba(#{stat_icon_rgb}, 0.1)" + stat_counter_val = stat_counter || "var(--cl-text-strong)" + space_card_bg_val = space_card_bg || "var(--cl-card)" about_bg_extra = about_bg_img ? ", url('#{about_bg_img}') center/cover no-repeat" : "" @@ -38,6 +46,9 @@ module CommunityLanding --cl-border-hover: rgba(#{accent_rgb}, 0.25); --cl-orb-1: rgba(#{accent_rgb}, 0.12); --cl-stat-icon-color: #{stat_icon}; + --cl-stat-icon-bg: #{stat_icon_bg_val}; + --cl-stat-counter-color: #{stat_counter_val}; + --cl-space-card-bg: #{space_card_bg_val}; --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}); } @@ -52,6 +63,9 @@ module CommunityLanding --cl-border-hover: rgba(#{accent_rgb}, 0.3); --cl-orb-1: rgba(#{accent_rgb}, 0.08); --cl-stat-icon-color: #{stat_icon}; + --cl-stat-icon-bg: #{stat_icon_bg_val}; + --cl-stat-counter-color: #{stat_counter_val}; + --cl-space-card-bg: #{space_card_bg_val}; --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}); }