commit d0040edbf993beacd78549c1b2d66e508be549c0 Author: DPN MW Date: Fri Mar 6 15:13:24 2026 -0400 Initial commit diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e3200e0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +*.gem +*.rbc +/.config +/coverage/ +/InstalledFiles +/pkg/ +/spec/reports/ +/spec/examples.txt +/test/tmp/ +/test/version_tmp/ +/tmp/ + +# Used by dotenv library to load environment variables. +# .env + +# Ignore Byebug command history file. +.byebug_history + +## Specific to RubyMotion: +.dat* +.repl_history +build/ +*.bridgesupport +build-iPhoneOS/ +build-iPhoneSimulator/ + +## Specific to RubyMotion (use of CocoaPods): +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +# vendor/Pods/ + +## Documentation cache and generated files: +/.yardoc/ +/_yardoc/ +/doc/ +/rdoc/ + +## Environment normalization: +/.bundle/ +/vendor/bundle +/lib/bundler/man/ + +# for a library or gem, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# Gemfile.lock +# .ruby-version +# .ruby-gemset + +# unless supporting rvm < 1.11.0 or doing something fancy, ignore this: +.rvmrc + +# Used by RuboCop. Remote config files pulled in from inherit_from directive. +# .rubocop-https?--* diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d81bf5b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 DPN MW + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/assets/javascripts/community_landing/landing.js b/assets/javascripts/community_landing/landing.js new file mode 100644 index 0000000..72909d2 --- /dev/null +++ b/assets/javascripts/community_landing/landing.js @@ -0,0 +1,148 @@ +// ═══════════════════════════════════════════════════════════════════ +// Community Landing — JS +// Theme detection, scroll animations, stat counters +// ═══════════════════════════════════════════════════════════════════ +(function () { + "use strict"; + + function $(s, c) { return (c || document).querySelector(s); } + function $$(s, c) { return Array.from((c || document).querySelectorAll(s)); } + + // ═══════════════════════════════════════════════════════════════════ + // 1. THEME DETECTION + TOGGLE + // ═══════════════════════════════════════════════════════════════════ + (function initTheme() { + var stored = localStorage.getItem("cl-theme"); + if (stored) { + document.documentElement.setAttribute("data-theme", stored); + } + })(); + + $$(".cl-theme-toggle").forEach(function (btn) { + btn.addEventListener("click", function () { + var current = document.documentElement.getAttribute("data-theme"); + var isDark; + + if (current) { + isDark = current === "dark"; + } else { + isDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + } + + var next = isDark ? "light" : "dark"; + document.documentElement.setAttribute("data-theme", next); + localStorage.setItem("cl-theme", next); + }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // 2. RANDOM HERO IMAGE + // ═══════════════════════════════════════════════════════════════════ + (function initHeroImage() { + var container = $(".cl-hero__image[data-hero-images]"); + if (!container) return; + try { + var images = JSON.parse(container.getAttribute("data-hero-images")); + if (!images || images.length < 2) return; + var img = $(".cl-hero__image-img", container); + if (!img) return; + var pick = images[Math.floor(Math.random() * images.length)]; + img.style.opacity = "0"; + img.src = pick; + img.onload = function () { img.style.opacity = ""; img.classList.add("loaded"); }; + img.onerror = function () { img.src = images[0]; img.style.opacity = ""; img.classList.add("loaded"); }; + } catch (e) { /* invalid JSON, keep default first image */ } + })(); + + // ═══════════════════════════════════════════════════════════════════ + // 3. NAVBAR SCROLL + // ═══════════════════════════════════════════════════════════════════ + var navbar = $("#cl-navbar"); + if (navbar) { + var onScroll = function () { + navbar.classList.toggle("scrolled", window.scrollY > 50); + }; + window.addEventListener("scroll", onScroll, { passive: true }); + onScroll(); + } + + var hamburger = $("#cl-hamburger"); + var navLinks = $("#cl-nav-links"); + if (hamburger && navLinks) { + hamburger.addEventListener("click", function () { + hamburger.classList.toggle("active"); + navLinks.classList.toggle("open"); + }); + $$("a, button", navLinks).forEach(function (el) { + el.addEventListener("click", function () { + hamburger.classList.remove("active"); + navLinks.classList.remove("open"); + }); + }); + } + + // ═══════════════════════════════════════════════════════════════════ + // 4. SCROLL REVEAL ANIMATIONS + // ═══════════════════════════════════════════════════════════════════ + if ("IntersectionObserver" in window) { + var revealObserver = new IntersectionObserver( + function (entries) { + entries.forEach(function (entry) { + if (entry.isIntersecting) { + entry.target.classList.add("visible"); + } + }); + }, + { threshold: 0.1, rootMargin: "0px 0px -40px 0px" } + ); + + $$(".cl-reveal, .cl-reveal-stagger").forEach(function (el) { + revealObserver.observe(el); + }); + } + + // ═══════════════════════════════════════════════════════════════════ + // 5. STAT COUNTER ANIMATION + // ═══════════════════════════════════════════════════════════════════ + function formatNumber(n) { return n.toLocaleString("en-US"); } + + function animateCount(el) { + if (el.classList.contains("counted")) return; + el.classList.add("counted"); + var target = parseInt(el.getAttribute("data-count"), 10); + if (isNaN(target) || target === 0) { el.textContent = "0"; return; } + + var duration = 1800; + var start = null; + function ease(t) { return 1 - Math.pow(1 - t, 4); } + function step(ts) { + if (!start) start = ts; + var p = Math.min((ts - start) / duration, 1); + el.textContent = formatNumber(Math.floor(target * ease(p))); + if (p < 1) requestAnimationFrame(step); + else el.textContent = formatNumber(target); + } + requestAnimationFrame(step); + } + + if ("IntersectionObserver" in window) { + var statsObs = new IntersectionObserver(function (entries) { + entries.forEach(function (e) { + if (e.isIntersecting) { + $$("[data-count]", e.target).forEach(animateCount); + if (e.target.hasAttribute("data-count")) animateCount(e.target); + } + }); + }, { threshold: 0.2 }); + + var sr = $("#cl-stats-row"); if (sr) statsObs.observe(sr); + } + + // ═══════════════════════════════════════════════════════════════════ + // 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"); }); + else if (/Android/.test(ua)) $$(".cl-app-badge--android").forEach(function (e) { e.classList.add("highlighted"); }); + +})(); diff --git a/assets/stylesheets/community_landing/landing.css b/assets/stylesheets/community_landing/landing.css new file mode 100644 index 0000000..93418d5 --- /dev/null +++ b/assets/stylesheets/community_landing/landing.css @@ -0,0 +1,534 @@ +/* ═══════════════════════════════════════════════════════════════════ + Community Landing — Modern CSS + ═══════════════════════════════════════════════════════════════════ */ + +:root, [data-theme="dark"] { + --cl-bg: #06060f; + --cl-bg-elevated: #0d0d1a; + --cl-card: rgba(15, 15, 35, 0.6); + --cl-accent: #7c6aff; + --cl-accent-hover: #9485ff; + --cl-accent-glow: rgba(124, 106, 255, 0.35); + --cl-accent-subtle: rgba(124, 106, 255, 0.08); + --cl-text: #b0b0ca; + --cl-text-strong: #eeeef6; + --cl-muted: #5e5e78; + --cl-border: rgba(255, 255, 255, 0.05); + --cl-border-hover: rgba(124, 106, 255, 0.25); + --cl-hero-bg: #06060f; + --cl-hero-text: #ffffff; + --cl-footer-bg: #030308; + --cl-shadow: rgba(0, 0, 0, 0.5); + --cl-glass: rgba(15, 15, 35, 0.5); + --cl-glass-border: rgba(255, 255, 255, 0.06); + --cl-orb-1: rgba(124, 106, 255, 0.12); + --cl-orb-2: rgba(99, 215, 255, 0.06); + --cl-orb-3: rgba(255, 106, 213, 0.04); + --cl-gradient-text: linear-gradient(135deg, #a78bfa, #7c6aff, #63d7ff); + --cl-section-alt: rgba(255, 255, 255, 0.015); + --cl-scrolled-nav: rgba(6, 6, 15, 0.95); + --cl-success: #34d399; + --cl-error: #f87171; + --cl-radius: 12px; + --cl-radius-sm: 8px; + color-scheme: dark; +} + +[data-theme="light"] { + --cl-bg: #f8f9fc; + --cl-bg-elevated: #ffffff; + --cl-card: rgba(255, 255, 255, 0.7); + --cl-accent: #6c5ce7; + --cl-accent-hover: #5a4bd1; + --cl-accent-glow: rgba(108, 92, 231, 0.2); + --cl-accent-subtle: rgba(108, 92, 231, 0.06); + --cl-text: #4a4a6a; + --cl-text-strong: #1a1a2e; + --cl-muted: #8888a0; + --cl-border: rgba(0, 0, 0, 0.06); + --cl-border-hover: rgba(108, 92, 231, 0.3); + --cl-hero-bg: #f0f0ff; + --cl-hero-text: #1a1a2e; + --cl-footer-bg: #eeeef5; + --cl-shadow: rgba(0, 0, 0, 0.06); + --cl-glass: rgba(255, 255, 255, 0.6); + --cl-glass-border: rgba(0, 0, 0, 0.05); + --cl-orb-1: rgba(108, 92, 231, 0.06); + --cl-orb-2: rgba(56, 189, 248, 0.04); + --cl-orb-3: rgba(236, 72, 153, 0.03); + --cl-gradient-text: linear-gradient(135deg, #6c5ce7, #4f46e5, #38bdf8); + --cl-section-alt: rgba(0, 0, 0, 0.015); + --cl-scrolled-nav: rgba(248, 249, 252, 0.95); + --cl-success: #10b981; + --cl-error: #ef4444; + color-scheme: light; +} + +@media (prefers-color-scheme: light) { + :root:not([data-theme="dark"]) { + --cl-bg: #f8f9fc; + --cl-bg-elevated: #ffffff; + --cl-card: rgba(255, 255, 255, 0.7); + --cl-accent: #6c5ce7; + --cl-accent-hover: #5a4bd1; + --cl-accent-glow: rgba(108, 92, 231, 0.2); + --cl-accent-subtle: rgba(108, 92, 231, 0.06); + --cl-text: #4a4a6a; + --cl-text-strong: #1a1a2e; + --cl-muted: #8888a0; + --cl-border: rgba(0, 0, 0, 0.06); + --cl-border-hover: rgba(108, 92, 231, 0.3); + --cl-hero-bg: #f0f0ff; + --cl-hero-text: #1a1a2e; + --cl-footer-bg: #eeeef5; + --cl-shadow: rgba(0, 0, 0, 0.06); + --cl-glass: rgba(255, 255, 255, 0.6); + --cl-glass-border: rgba(0, 0, 0, 0.05); + --cl-orb-1: rgba(108, 92, 231, 0.06); + --cl-orb-2: rgba(56, 189, 248, 0.04); + --cl-orb-3: rgba(236, 72, 153, 0.03); + --cl-gradient-text: linear-gradient(135deg, #6c5ce7, #4f46e5, #38bdf8); + --cl-section-alt: rgba(0, 0, 0, 0.015); + --cl-scrolled-nav: rgba(248, 249, 252, 0.95); + --cl-success: #10b981; + --cl-error: #ef4444; + color-scheme: light; + } +} + +/* ── Reset ── */ +.cl-body { + margin: 0; padding: 0; + background: var(--cl-bg); color: var(--cl-text); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + line-height: 1.6; + -webkit-font-smoothing: antialiased; + overflow-x: hidden; + transition: background 0.3s, color 0.3s; + -webkit-text-size-adjust: 100%; +} +.cl-body *, .cl-body *::before, .cl-body *::after { box-sizing: border-box; } + +.cl-container { max-width: 1140px; margin: 0 auto; padding: 0 1.25rem; } +@media (min-width: 768px) { .cl-container { padding: 0 2rem; } } + +.cl-section-title { + font-size: 1.15rem; font-weight: 700; color: var(--cl-text-strong); + margin: 0 0 1rem; letter-spacing: -0.01em; +} + +/* ── Scroll Reveal ── */ +.cl-reveal { opacity: 0; transform: translateY(24px); transition: opacity 0.6s cubic-bezier(0.16,1,0.3,1), transform 0.6s cubic-bezier(0.16,1,0.3,1); } +.cl-reveal.visible { opacity: 1; transform: translateY(0); } + +/* ═══════════════════════════════════════════════════════════════════ + BUTTONS + ═══════════════════════════════════════════════════════════════════ */ +.cl-btn { + display: inline-flex; align-items: center; justify-content: center; + padding: 0.65rem 1.5rem; border: none; border-radius: var(--cl-radius-sm); + font-size: 0.88rem; font-weight: 600; cursor: pointer; + transition: all 0.2s ease; text-decoration: none; white-space: nowrap; +} +.cl-btn--primary { background: var(--cl-accent); color: #fff; } +.cl-btn--primary:hover { background: var(--cl-accent-hover); transform: translateY(-1px); box-shadow: 0 4px 20px var(--cl-accent-glow); } +.cl-btn--ghost { background: transparent; color: var(--cl-text-strong); border: 1px solid var(--cl-border); } +.cl-btn--ghost:hover { background: var(--cl-accent-subtle); border-color: var(--cl-border-hover); } +.cl-btn--lg { padding: 0.8rem 2rem; font-size: 0.95rem; border-radius: var(--cl-radius); } + +/* ═══════════════════════════════════════════════════════════════════ + NAVBAR + ═══════════════════════════════════════════════════════════════════ */ +.cl-navbar { + position: fixed; top: 0; left: 0; right: 0; z-index: 1000; + padding: 0.85rem 0; + transition: all 0.3s ease; +} +.cl-navbar.scrolled { + background: var(--cl-scrolled-nav); + backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); + box-shadow: 0 1px 0 var(--cl-border); + padding: 0.55rem 0; +} +.cl-navbar__inner { + max-width: 1140px; margin: 0 auto; padding: 0 1.25rem; + display: flex; align-items: center; justify-content: space-between; +} +@media (min-width: 768px) { .cl-navbar__inner { padding: 0 2rem; } } + +.cl-navbar__brand { display: flex; align-items: center; gap: 0.6rem; text-decoration: none; color: var(--cl-text-strong); } +.cl-navbar__logo { width: auto; object-fit: contain; } +.cl-navbar__site-name { font-size: 1.05rem; font-weight: 700; letter-spacing: -0.02em; } + +/* Logo theme switching */ +.cl-logo--light { display: none; } +[data-theme="light"] .cl-logo--dark { display: none; } +[data-theme="light"] .cl-logo--light { display: inline; } +@media (prefers-color-scheme: light) { + :root:not([data-theme="dark"]) .cl-logo--dark { display: none; } + :root:not([data-theme="dark"]) .cl-logo--light { display: inline; } +} + +.cl-navbar__hamburger { + display: flex; flex-direction: column; gap: 4px; + background: none; border: none; cursor: pointer; padding: 8px; z-index: 1001; + min-width: 44px; min-height: 44px; align-items: center; justify-content: center; +} +.cl-navbar__hamburger span { display: block; width: 20px; height: 2px; background: var(--cl-text-strong); border-radius: 2px; transition: all 0.3s; } +.cl-navbar__hamburger.active span:nth-child(1) { transform: rotate(45deg) translate(4px, 4px); } +.cl-navbar__hamburger.active span:nth-child(2) { opacity: 0; } +.cl-navbar__hamburger.active span:nth-child(3) { transform: rotate(-45deg) translate(4px, -4px); } +@media (min-width: 768px) { .cl-navbar__hamburger { display: none; } } + +.cl-navbar__links { + display: none; position: fixed; inset: 0; + background: var(--cl-bg); flex-direction: column; + align-items: center; justify-content: center; gap: 1.25rem; z-index: 1000; +} +.cl-navbar__links.open { display: flex; } +@media (min-width: 768px) { + .cl-navbar__links { display: flex; position: static; flex-direction: row; background: none; gap: 0.4rem; } +} + +.cl-navbar__link { + font-size: 0.85rem; padding: 0.45rem 1rem; + text-decoration: none; color: var(--cl-text); + border-radius: var(--cl-radius-sm); transition: all 0.2s; font-weight: 500; +} +.cl-navbar__link:hover { color: var(--cl-text-strong); } +.cl-navbar__link.cl-btn--primary { background: var(--cl-accent); color: #fff; } +.cl-navbar__link.cl-btn--primary:hover { background: var(--cl-accent-hover); } +.cl-navbar__link.cl-btn--ghost { border: 1px solid var(--cl-border); } +.cl-navbar__link.cl-btn--ghost:hover { background: var(--cl-accent-subtle); border-color: var(--cl-border-hover); } + +/* Theme Toggle */ +.cl-theme-toggle { + background: none; border: 1px solid var(--cl-border); + color: var(--cl-muted); cursor: pointer; padding: 0.35rem; + border-radius: var(--cl-radius-sm); display: flex; align-items: center; justify-content: center; + transition: all 0.2s; width: 34px; height: 34px; +} +.cl-theme-toggle:hover { background: var(--cl-accent-subtle); border-color: var(--cl-border-hover); color: var(--cl-accent); } +.cl-theme-toggle svg { width: 16px; height: 16px; } +.cl-theme-toggle .cl-icon-sun { display: none; } +.cl-theme-toggle .cl-icon-moon { display: block; } +[data-theme="dark"] .cl-theme-toggle .cl-icon-sun { display: block; } +[data-theme="dark"] .cl-theme-toggle .cl-icon-moon { display: none; } +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) .cl-theme-toggle .cl-icon-sun { display: block; } + :root:not([data-theme]) .cl-theme-toggle .cl-icon-moon { display: none; } +} + +/* ═══════════════════════════════════════════════════════════════════ + HERO + ═══════════════════════════════════════════════════════════════════ */ +.cl-hero { + min-height: 100vh; min-height: 100dvh; display: flex; align-items: center; + background: var(--cl-hero-bg); + position: relative; overflow: hidden; + padding: 5rem 0 2.5rem; +} +@media (min-width: 768px) { .cl-hero { padding: 6rem 0 3rem; } } +@media (min-width: 1024px) { .cl-hero { padding: 0; } } + +.cl-hero::before, .cl-hero::after { + content: ""; position: absolute; border-radius: 50%; + pointer-events: none; filter: blur(80px); +} +.cl-hero::before { width: 400px; height: 400px; top: -120px; right: -80px; background: var(--cl-orb-1); animation: cl-float 8s ease-in-out infinite; } +.cl-hero::after { width: 350px; height: 350px; bottom: -80px; left: -80px; background: var(--cl-orb-2); animation: cl-float 10s ease-in-out infinite reverse; } +@media (min-width: 768px) { + .cl-hero::before { width: 600px; height: 600px; } + .cl-hero::after { width: 500px; height: 500px; } +} +.cl-hero__orb { + position: absolute; width: 300px; height: 300px; + top: 50%; left: 50%; transform: translate(-50%, -50%); + background: var(--cl-orb-3); border-radius: 50%; + pointer-events: none; filter: blur(100px); + animation: cl-float 12s ease-in-out infinite 2s; +} + +@keyframes cl-float { + 0%, 100% { transform: translate(0, 0) scale(1); } + 33% { transform: translate(20px, -30px) scale(1.05); } + 66% { transform: translate(-15px, 15px) scale(0.95); } +} + +.cl-hero__inner { + max-width: 1140px; margin: 0 auto; padding: 0 1.25rem; + width: 100%; display: flex; flex-direction: column; gap: 1.5rem; + position: relative; z-index: 1; +} +@media (min-width: 768px) { .cl-hero__inner { padding: 0 2rem; } } +@media (min-width: 1024px) { .cl-hero__inner { flex-direction: row; align-items: center; gap: 2.5rem; } } + +.cl-hero__content { flex-shrink: 0; animation: cl-fade-up 0.8s cubic-bezier(0.16,1,0.3,1) both; } +@media (min-width: 1024px) { .cl-hero__content { flex: 0 0 38%; } } + +.cl-hero__image { animation: cl-fade-up 0.8s cubic-bezier(0.16,1,0.3,1) 0.1s both; display: flex; align-items: center; justify-content: center; } +@media (min-width: 1024px) { .cl-hero__image { flex: 1; min-height: 0; } } + +.cl-hero__image-img { + width: 100%; height: auto; + border-radius: var(--cl-radius); object-fit: contain; + filter: drop-shadow(0 16px 48px var(--cl-shadow)); + transition: opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1); +} +.cl-hero__image-img.loaded { + opacity: 1 !important; +} +@media (min-width: 1024px) { + .cl-hero__image-img { max-height: 80vh; } +} + +@keyframes cl-fade-up { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +.cl-hero__title { + font-size: clamp(2rem, 5vw, 3.5rem); + font-weight: 800; color: var(--cl-hero-text); + margin: 0 0 1rem; line-height: 1.1; letter-spacing: -0.03em; +} +.cl-hero__title-accent { + background: var(--cl-gradient-text); + -webkit-background-clip: text; background-clip: text; + -webkit-text-fill-color: transparent; +} +.cl-hero__subtitle { + font-size: clamp(0.92rem, 1.8vw, 1.15rem); + color: var(--cl-muted); margin: 0 0 1.5rem; max-width: 420px; line-height: 1.6; +} +.cl-hero__actions { display: flex; flex-wrap: wrap; gap: 0.6rem; } + +/* ═══════════════════════════════════════════════════════════════════ + STATS ROW — full-width counter cards below hero + ═══════════════════════════════════════════════════════════════════ */ +.cl-stats-row { padding: 2.5rem 0; border-bottom: 1px solid var(--cl-border); } +.cl-stats-row__grid { + display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.75rem; +} +@media (min-width: 640px) { .cl-stats-row__grid { grid-template-columns: repeat(3, 1fr); } } +@media (min-width: 1024px) { .cl-stats-row__grid { grid-template-columns: repeat(5, 1fr); } } + +.cl-stats-counter { + display: flex; flex-direction: column; align-items: center; gap: 0.35rem; + padding: 1.25rem 0.75rem; + background: var(--cl-card); border: 1px solid var(--cl-border); + border-radius: var(--cl-radius); text-align: center; + transition: all 0.25s ease; +} +.cl-stats-counter:hover { border-color: var(--cl-border-hover); transform: translateY(-3px); box-shadow: 0 8px 24px rgba(0,0,0,0.1); } +.cl-stats-counter__icon { color: var(--cl-accent); opacity: 0.8; } +.cl-stats-counter__icon svg { width: 24px; height: 24px; } +.cl-stats-counter__value { + font-size: 1.5rem; font-weight: 800; + color: var(--cl-text-strong); letter-spacing: -0.02em; +} +.cl-stats-counter__label { + font-size: 0.7rem; color: var(--cl-muted); + text-transform: uppercase; letter-spacing: 0.08em; font-weight: 600; +} + +/* ═══════════════════════════════════════════════════════════════════ + CONTENT GRID — two columns + ═══════════════════════════════════════════════════════════════════ */ +.cl-content { padding: 2.5rem 0 3rem; } + +.cl-content__grid { + display: grid; grid-template-columns: 1fr; gap: 1.5rem; +} +@media (min-width: 1024px) { + .cl-content__grid { grid-template-columns: 1fr 1fr; gap: 1.5rem; } +} + +.cl-content__left, .cl-content__right { + display: flex; flex-direction: column; gap: 1.5rem; +} +.cl-content__bottom { grid-column: 1 / -1; } + +/* ═══════════════════════════════════════════════════════════════════ + ABOUT — Quote Card + ═══════════════════════════════════════════════════════════════════ */ +.cl-about__card { + background: var(--cl-card); + border: 1px solid var(--cl-border); + border-radius: var(--cl-radius); + padding: 1.5rem; + position: relative; +} +.cl-about__quote-mark { + color: var(--cl-accent); opacity: 0.3; + width: 28px; height: 28px; margin-bottom: 0.5rem; +} +.cl-about__body { + color: var(--cl-text); font-size: 0.92rem; line-height: 1.7; + 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: 40px; height: 40px; border-radius: 50%; object-fit: cover; + border: 2px solid var(--cl-border); +} +.cl-about__meta-text { display: flex; flex-direction: column; } +.cl-about__author { font-size: 0.85rem; font-weight: 600; color: var(--cl-text-strong); } +.cl-about__role { font-size: 0.75rem; color: var(--cl-muted); } + +/* ═══════════════════════════════════════════════════════════════════ + TOPICS + ═══════════════════════════════════════════════════════════════════ */ +.cl-topics__list { + display: flex; flex-direction: column; gap: 1px; + border-radius: var(--cl-radius); overflow: hidden; + border: 1px solid var(--cl-border); +} +.cl-topic-row { + display: flex; align-items: center; gap: 0.6rem; + padding: 0.7rem 1rem; background: var(--cl-card); + text-decoration: none; color: var(--cl-text); + transition: all 0.15s ease; + border-left: 2px solid transparent; +} +.cl-topic-row:hover { background: var(--cl-accent-subtle); border-left-color: var(--cl-accent); } +@media (max-width: 767px) { .cl-topic-row { flex-wrap: wrap; } } + +.cl-topic-row__cat { + display: inline-block; padding: 0.1rem 0.5rem; border-radius: 4px; + font-size: 0.62rem; font-weight: 700; color: #fff; + background: var(--cat-color); text-transform: uppercase; letter-spacing: 0.03em; + white-space: nowrap; flex-shrink: 0; +} +.cl-topic-row__title { + flex: 1; font-weight: 600; color: var(--cl-text-strong); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.85rem; +} +@media (max-width: 767px) { .cl-topic-row__title { white-space: normal; width: 100%; order: 2; } } +.cl-topic-row__meta { + display: flex; align-items: center; gap: 0.2rem; flex-shrink: 0; + color: var(--cl-muted); font-size: 0.75rem; +} +.cl-topic-row__meta svg { opacity: 0.5; } + +/* ═══════════════════════════════════════════════════════════════════ + CONTRIBUTORS — compact avatar grid, no post counts + ═══════════════════════════════════════════════════════════════════ */ +.cl-contributors__list { + display: flex; flex-wrap: wrap; gap: 0.5rem; +} +.cl-contributor { + display: flex; align-items: center; gap: 0.5rem; + padding: 0.4rem 0.65rem 0.4rem 0.4rem; + background: var(--cl-card); border: 1px solid var(--cl-border); + border-radius: 50px; text-decoration: none; color: var(--cl-text-strong); + transition: all 0.2s ease; font-size: 0.8rem; font-weight: 500; +} +.cl-contributor:hover { border-color: var(--cl-border-hover); background: var(--cl-accent-subtle); } +.cl-contributor__avatar { + width: 28px; height: 28px; border-radius: 50%; object-fit: cover; +} +.cl-contributor__name { white-space: nowrap; } + +/* ═══════════════════════════════════════════════════════════════════ + GROUPS — card grid + ═══════════════════════════════════════════════════════════════════ */ +.cl-groups__grid { + display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.6rem; +} +@media (min-width: 640px) { .cl-groups__grid { grid-template-columns: repeat(3, 1fr); } } +@media (min-width: 1024px) { .cl-groups__grid { grid-template-columns: repeat(5, 1fr); } } + +.cl-group-card { + display: flex; flex-direction: column; align-items: center; gap: 0.4rem; + padding: 1.15rem 0.75rem; + background: var(--cl-card); border: 1px solid var(--cl-border); + border-radius: var(--cl-radius); + text-decoration: none; text-align: center; + transition: all 0.25s ease; +} +.cl-group-card:hover { border-color: var(--cl-border-hover); background: var(--cl-accent-subtle); transform: translateY(-3px); box-shadow: 0 8px 24px rgba(0,0,0,0.1); } +.cl-group-card__icon { width: 40px; height: 40px; } +.cl-group-card__icon img { width: 100%; height: 100%; border-radius: 10px; object-fit: cover; } +.cl-group-card__initial { + display: flex; align-items: center; justify-content: center; + width: 40px; height: 40px; border-radius: 10px; + font-size: 1rem; font-weight: 700; color: #fff; +} +.cl-group-card__name { + font-size: 0.82rem; font-weight: 600; color: var(--cl-text-strong); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; +} +.cl-group-card__count { font-size: 0.7rem; color: var(--cl-muted); } + +/* ═══════════════════════════════════════════════════════════════════ + APP CTA + ═══════════════════════════════════════════════════════════════════ */ +.cl-app-cta { + padding: 2.5rem 0; text-align: center; position: relative; overflow: hidden; + background: linear-gradient(135deg, var(--cl-accent), var(--cl-accent-hover)); +} +.cl-app-cta::before { + content: ""; position: absolute; inset: 0; + background: radial-gradient(circle at 30% 50%, rgba(255,255,255,0.08) 0%, transparent 50%); +} +.cl-app-cta__headline { font-size: 1.3rem; font-weight: 700; color: #fff; margin: 0 0 0.35rem; position: relative; } +.cl-app-cta__subtext { color: rgba(255,255,255,0.7); font-size: 0.9rem; margin: 0 0 1.5rem; position: relative; } +.cl-app-cta__badges { display: flex; justify-content: center; gap: 0.75rem; flex-wrap: wrap; position: relative; } +.cl-app-badge { + display: inline-flex; align-items: center; overflow: hidden; + transition: transform 0.2s; opacity: 0.9; +} +.cl-app-badge:hover { transform: translateY(-2px); opacity: 1; } +.cl-app-badge__img { height: 100%; width: auto; display: block; } +.cl-app-badge--rounded { border-radius: var(--cl-radius); } +.cl-app-badge--pill { border-radius: 50px; } +.cl-app-badge--square { border-radius: 4px; } + +/* ═══════════════════════════════════════════════════════════════════ + FOOTER — horizontal links, logo not stretched + ═══════════════════════════════════════════════════════════════════ */ +.cl-footer { + background: var(--cl-footer-bg); padding: 2rem 0 0; + border-top: 1px solid var(--cl-border); +} +.cl-footer__top { + display: flex; align-items: center; justify-content: space-between; + flex-wrap: wrap; gap: 1rem; + padding-bottom: 1.25rem; border-bottom: 1px solid var(--cl-border); +} +.cl-footer__brand { display: flex; align-items: center; gap: 0.5rem; } +.cl-footer__logo { width: auto; object-fit: contain; } +.cl-footer__site-name { font-size: 0.95rem; font-weight: 700; color: var(--cl-text-strong); } + +.cl-footer__links { + display: flex; align-items: center; gap: 1.25rem; flex-wrap: wrap; +} +.cl-footer__link { + color: var(--cl-muted); text-decoration: none; font-size: 0.82rem; + transition: color 0.2s; font-weight: 500; +} +.cl-footer__link:hover { color: var(--cl-accent); } + +.cl-footer__text { color: var(--cl-muted); font-size: 0.82rem; padding: 1rem 0 0; } +.cl-footer__copy { + padding: 1rem 0; font-size: 0.75rem; color: var(--cl-muted); + padding-bottom: max(1rem, env(safe-area-inset-bottom)); +} + +/* ═══════════════════════════════════════════════════════════════════ + REDUCED MOTION + ═══════════════════════════════════════════════════════════════════ */ +@media (prefers-reduced-motion: reduce) { + .cl-reveal, .cl-hero__content, .cl-hero__image, .cl-btn { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } + .cl-reveal { opacity: 1; transform: none; } + .cl-hero::before, .cl-hero::after, .cl-hero__orb { animation: none; } +} diff --git a/config/locales/en.yml b/config/locales/en.yml new file mode 100644 index 0000000..cc539b7 --- /dev/null +++ b/config/locales/en.yml @@ -0,0 +1,78 @@ +en: + site_settings: + # ── Master Switch ── + community_landing_enabled: "Activate the community landing page. When enabled, logged-out visitors see a branded welcome page instead of the default Discourse homepage." + + # ── Branding: Logo ── + community_landing_logo_dark_url: "Logo image URL for dark mode. Shown in the navbar and footer. Leave blank to display 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." + community_landing_logo_height: "Logo height in pixels (16–80). 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 used." + + # ── Navbar ── + community_landing_navbar_signin_label: "Text for the sign-in link in the navigation bar." + community_landing_navbar_join_label: "Text for the join/register button in the navigation bar." + + # ── Hero Section: Content ── + community_landing_hero_title: "The large headline displayed in the hero section. The last word is automatically highlighted with your accent color gradient." + community_landing_hero_subtitle: "Supporting text shown below the hero title. Use this to briefly describe your community's purpose or value proposition." + + # ── Hero Section: Imagery ── + community_landing_hero_background_image_url: "Full-bleed background image behind the entire hero section. A dark overlay is applied automatically to maintain text readability." + community_landing_hero_image_urls: "Images displayed on the right side of the hero section. Add up to 5 image URLs — a random one is shown on each visit. Click + to add each image row." + community_landing_hero_image_max_height: "Maximum height (in pixels) for the hero image. Keeps tall images from overwhelming the hero section. Range: 100–1200px." + + # ── Hero Section: Call-to-Action Buttons ── + community_landing_hero_primary_button_label: "Text on the primary (filled) call-to-action button in the hero section." + community_landing_hero_primary_button_url: "Where 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 in the hero section." + community_landing_hero_secondary_button_url: "Where the secondary button links to. Defaults to the login page." + + # ── Appearance: Color Scheme ── + community_landing_accent_color: "Primary accent color used for buttons, links, highlights, and gradients. Enter a hex value (e.g. #7c6aff)." + community_landing_accent_hover_color: "Accent color on hover states. Should be slightly lighter or brighter than the primary accent. Hex value (e.g. #9485ff)." + community_landing_dark_bg_color: "Background color for dark mode. Sets the overall page tone in dark theme. Hex value (e.g. #06060f)." + community_landing_light_bg_color: "Background color for light mode. Sets the overall page tone in light theme. Hex value (e.g. #f8f9fc)." + + # ── Section: App Download Banner ── + community_landing_show_app_ctas: "Show a mobile app download banner above the footer. Requires at least one app store URL to be set." + community_landing_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." + community_landing_ios_app_icon_url: "Custom image URL for the iOS app badge (e.g. an uploaded SVG or PNG). If not set, a default text badge is shown." + community_landing_android_app_icon_url: "Custom image URL for the Android app badge (e.g. an uploaded SVG or PNG). If not set, a default text badge is shown." + community_landing_app_badge_height: "Height of app badges in pixels (30–80). Applies to both default and custom icon badges." + community_landing_app_badge_style: "Border-radius style for app badges: rounded (12px corners), pill (fully rounded ends), or square (minimal rounding)." + community_landing_app_cta_headline: "Bold headline text shown in the app download banner." + community_landing_app_cta_subtext: "Supporting text shown below the app banner headline." + + # ── Section: About ── + community_landing_about_enabled: "Show the About section with a quote card describing your community." + community_landing_about_title: "Author name or title displayed below the about quote card. Typically the community name or admin name." + community_landing_about_role: "Subtitle shown below the about author name (e.g. 'Community Manager', 'Founded 2020'). If blank, the site name is used." + community_landing_about_body: "Main body text for the About section. Supports basic HTML tags: p, a, strong, em, ul, li, br." + community_landing_about_image_url: "Small avatar image shown next to the about section author name. Works best with a square image." + community_landing_stat_members_label: "Custom label for the Members stat counter shown in the stats row below the hero." + community_landing_stat_topics_label: "Custom label for the Topics stat counter." + community_landing_stat_posts_label: "Custom label for the Posts stat counter." + community_landing_stat_likes_label: "Custom label for the Likes stat counter." + community_landing_stat_chats_label: "Custom label for the Chats stat counter. Shows total chat messages if the Chat plugin is enabled." + + # ── Section: Top Contributors ── + community_landing_contributors_enabled: "Show a section highlighting your most active community members with their avatars and usernames." + community_landing_contributors_title: "Heading text for the Top Contributors section." + community_landing_contributors_days: "Lookback period in days for calculating top contributors. A larger number gives a broader view of active members." + community_landing_contributors_count: "How many top contributors to display. Recommended: 6–12 for a balanced layout." + + # ── Section: Groups ── + community_landing_groups_enabled: "Show public groups as browsable cards. Only groups with public visibility are displayed." + community_landing_groups_title: "Heading text for the Groups section." + community_landing_groups_count: "Number of group cards to display (default 5). Only public, non-automatic groups are shown." + + # ── Section: Trending Discussions ── + community_landing_topics_enabled: "Show a list of trending discussions based on recent activity and reply count." + community_landing_topics_title: "Heading text for the Trending Discussions section." + community_landing_topics_count: "Number of trending topics to display. Shows the most-replied topics from the last 30 days." + + # ── Footer ── + community_landing_footer_text: "Optional text displayed in the footer area. Supports basic HTML: 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"}]' diff --git a/config/settings.yml b/config/settings.yml new file mode 100644 index 0000000..dfa59d8 --- /dev/null +++ b/config/settings.yml @@ -0,0 +1,189 @@ +plugins: + # ── Master Switch ── + community_landing_enabled: + default: true + type: bool + + # ── Branding: Logo ── + community_landing_logo_dark_url: + default: "" + type: string + community_landing_logo_light_url: + default: "" + type: string + community_landing_logo_height: + default: 30 + type: integer + min: 16 + max: 80 + community_landing_footer_logo_url: + default: "" + type: string + + # ── Navbar ── + community_landing_navbar_signin_label: + default: "Sign In" + type: string + community_landing_navbar_join_label: + default: "Join Free" + type: string + + # ── Hero Section: Content ── + community_landing_hero_title: + default: "Welcome to Our Community" + type: string + community_landing_hero_subtitle: + default: "Join thousands of members in the conversation" + type: string + + # ── Hero Section: Imagery ── + community_landing_hero_background_image_url: + default: "" + type: string + community_landing_hero_image_urls: + default: "" + type: list + community_landing_hero_image_max_height: + default: 500 + type: integer + min: 100 + max: 1200 + + # ── Hero Section: Call-to-Action Buttons ── + community_landing_hero_primary_button_label: + default: "Browse the Forum" + type: string + community_landing_hero_primary_button_url: + default: "/latest" + type: string + community_landing_hero_secondary_button_label: + default: "Join the Community" + type: string + community_landing_hero_secondary_button_url: + default: "/login" + type: string + + # ── Appearance: Color Scheme ── + community_landing_accent_color: + default: "#7c6aff" + type: string + community_landing_accent_hover_color: + default: "#9485ff" + type: string + community_landing_dark_bg_color: + default: "#06060f" + type: string + community_landing_light_bg_color: + default: "#f8f9fc" + type: string + + # ── Section: App Download Banner ── + community_landing_show_app_ctas: + default: true + type: bool + community_landing_ios_app_url: + default: "" + type: string + community_landing_android_app_url: + default: "" + type: string + community_landing_ios_app_icon_url: + default: "" + type: string + community_landing_android_app_icon_url: + default: "" + type: string + community_landing_app_badge_height: + default: 45 + type: integer + min: 30 + max: 80 + community_landing_app_badge_style: + default: "rounded" + type: enum + choices: + - rounded + - pill + - square + community_landing_app_cta_headline: + default: "Get the best experience on our app" + type: string + community_landing_app_cta_subtext: + default: "Available free on iOS and Android" + type: string + + # ── Section: About ── + community_landing_about_enabled: + default: true + type: bool + community_landing_about_title: + default: "About Our Community" + type: string + community_landing_about_role: + default: "" + type: string + community_landing_about_body: + default: "" + type: string + community_landing_about_image_url: + default: "" + type: string + community_landing_stat_members_label: + default: "Members" + type: string + community_landing_stat_topics_label: + default: "Topics" + type: string + community_landing_stat_posts_label: + default: "Posts" + type: string + community_landing_stat_likes_label: + default: "Likes" + type: string + community_landing_stat_chats_label: + default: "Chats" + type: string + + # ── Section: Top Contributors ── + community_landing_contributors_enabled: + default: true + type: bool + community_landing_contributors_title: + default: "Top Contributors" + type: string + community_landing_contributors_days: + default: 90 + type: integer + community_landing_contributors_count: + default: 10 + type: integer + + # ── Section: Groups ── + community_landing_groups_enabled: + default: true + type: bool + community_landing_groups_title: + default: "Our Groups" + type: string + community_landing_groups_count: + default: 5 + type: integer + + # ── Section: Trending Discussions ── + community_landing_topics_enabled: + default: true + type: bool + community_landing_topics_title: + default: "Trending Discussions" + type: string + community_landing_topics_count: + default: 5 + type: integer + + # ── Footer ── + community_landing_footer_text: + default: "" + type: string + community_landing_footer_links: + default: '[{"label":"Terms","url":"/tos"},{"label":"Privacy","url":"/privacy"}]' + type: string diff --git a/plugin.rb b/plugin.rb new file mode 100644 index 0000000..926c424 --- /dev/null +++ b/plugin.rb @@ -0,0 +1,481 @@ +# frozen_string_literal: true + +# name: community-landing +# about: Branded public landing page for unauthenticated visitors +# version: 1.0.0 +# authors: Community +# url: https://github.com/community/community-landing + +enabled_site_setting :community_landing_enabled + +after_initialize do + module ::CommunityLanding + PLUGIN_NAME = "community-landing" + PLUGIN_DIR = File.expand_path("..", __FILE__) + end + + class ::CommunityLanding::LandingController < ::ApplicationController + requires_plugin CommunityLanding::PLUGIN_NAME + + skip_before_action :check_xhr + skip_before_action :redirect_to_login_if_required + skip_before_action :preload_json, raise: false + content_security_policy false + + def index + fetch_community_data + + css = load_file("assets", "stylesheets", "community_landing", "landing.css") + js = load_file("assets", "javascripts", "community_landing", "landing.js") + + base_url = Discourse.base_url + csp = "default-src 'self' #{base_url}; " \ + "script-src 'self' 'unsafe-inline'; " \ + "style-src 'self' 'unsafe-inline'; " \ + "img-src 'self' #{base_url} data: https:; " \ + "font-src 'self' #{base_url}; " \ + "frame-ancestors 'self'" + response.headers["Content-Security-Policy"] = csp + + render html: build_html(css, js).html_safe, layout: false, content_type: "text/html" + end + + private + + def load_file(*path_parts) + File.read(File.join(CommunityLanding::PLUGIN_DIR, *path_parts)) + rescue StandardError => e + "/* Error loading #{path_parts.last}: #{e.message} */" + end + + def fetch_community_data + s = SiteSetting + + if s.community_landing_contributors_enabled + @top_contributors = User + .joins(:posts) + .where(posts: { created_at: s.community_landing_contributors_days.days.ago.. }) + .where.not(username: %w[system discobot]) + .where(active: true, staged: false) + .group("users.id") + .order("COUNT(posts.id) DESC") + .limit(s.community_landing_contributors_count) + .select("users.*, COUNT(posts.id) AS post_count") + end + + if s.community_landing_groups_enabled + @groups = Group + .where(visibility_level: Group.visibility_levels[:public]) + .where(automatic: false) + .limit(s.community_landing_groups_count) + end + + if s.community_landing_topics_enabled + @hot_topics = Topic + .listable_topics + .where(visible: true) + .where("topics.created_at > ?", 30.days.ago) + .order(posts_count: :desc) + .limit(s.community_landing_topics_count) + .includes(:category, :user) + end + + chat_count = 0 + begin + chat_count = Chat::Message.count if defined?(Chat::Message) + rescue + chat_count = 0 + end + + @stats = { + members: User.real.count, + topics: Topic.listable_topics.count, + posts: Post.where(user_deleted: false).count, + likes: Post.sum(:like_count), + chats: chat_count, + } + end + + def e(text) + ERB::Util.html_escape(text.to_s) + end + + SUN_SVG = '' + MOON_SVG = '' + QUOTE_SVG = '' + + STAT_MEMBERS_SVG = '' + STAT_TOPICS_SVG = '' + STAT_POSTS_SVG = '' + STAT_LIKES_SVG = '' + STAT_CHATS_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 || "#7c6aff" + accent_hover = s.community_landing_accent_hover_color.presence || "#9485ff" + dark_bg = s.community_landing_dark_bg_color.presence || "#06060f" + light_bg = s.community_landing_light_bg_color.presence || "#f8f9fc" + accent_rgb = hex_to_rgb(accent) + + "\n" + end + + # ── Logo helpers ── + + def logo_img(url, alt, css_class, height) + "\"#{e(alt)}\"" + end + + def render_logo(dark_url, light_url, site_name, base_class, height) + if dark_url && light_url + logo_img(dark_url, site_name, "#{base_class} cl-logo--dark", height) + + logo_img(light_url, site_name, "#{base_class} cl-logo--light", height) + else + logo_img(dark_url || light_url, site_name, base_class, height) + end + end + + # ── App badge helper ── + + def render_app_badge(store_url, custom_icon_url, default_svg, badge_h, badge_style) + 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 = "" + if custom_icon_url + html << "\"\"" + else + html << default_svg + end + html << "\n" + html + end + + def build_html(css, js) + s = SiteSetting + site_name = s.title + login_url = "/login" + + # Logo URLs + logo_dark_url = s.community_landing_logo_dark_url.presence + logo_light_url = s.community_landing_logo_light_url.presence + # Fallback: if only light is set, treat it as the universal logo + 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 + footer_logo_url = s.community_landing_footer_logo_url.presence + + html = +"" + html << "\n\n\n" + html << "\n" + html << "\n" + html << "\n" + html << "#{e(s.community_landing_hero_title)} | #{e(site_name)}\n" + html << "\n" + html << "\n" + html << "\n" + html << "\n" + html << "\n" if og_logo + html << "\n" + html << "\n" + html << "\n" + html << build_color_overrides(s) + html << "\n\n" + + # Navbar labels + signin_label = s.community_landing_navbar_signin_label.presence || "Sign In" + join_label = s.community_landing_navbar_join_label.presence || "Join Free" + + # ── NAVBAR ── + html << "\n" + + # ── HERO — text left, image right ── + hero_style = "" + if s.community_landing_hero_background_image_url.present? + hero_style = " style=\"background-image: linear-gradient(rgba(6,6,15,0.8), rgba(6,6,15,0.8)), url('#{s.community_landing_hero_background_image_url}');\"" + end + html << "
\n" + html << "
\n" + html << "
\n" + html << "
\n" + + title_words = s.community_landing_hero_title.to_s.split(" ") + if title_words.length > 1 + html << "

#{e(title_words[0..-2].join(" "))} #{e(title_words.last)}

\n" + else + html << "

#{e(s.community_landing_hero_title)}

\n" + end + + html << "

#{e(s.community_landing_hero_subtitle)}

\n" + + primary_label = s.community_landing_hero_primary_button_label.presence || "Browse the Forum" + primary_url = s.community_landing_hero_primary_button_url.presence || "/latest" + secondary_label = s.community_landing_hero_secondary_button_label.presence || "Join the Community" + secondary_url = s.community_landing_hero_secondary_button_url.presence || login_url + + html << "
\n" + html << "#{e(primary_label)}\n" + html << "#{e(secondary_label)}\n" + html << "
\n" + + html << "
\n" # end cl-hero__content + + hero_image_urls_raw = s.community_landing_hero_image_urls.presence + if hero_image_urls_raw + urls = hero_image_urls_raw.split("|").map(&:strip).reject(&:empty?).first(5) + if urls.any? + img_max_h = s.community_landing_hero_image_max_height rescue 500 + html << "
\n" + html << "\"#{e(site_name)}\"\n" + html << "
\n" + end + end + + html << "
\n" # end hero + + # ── STATS ROW — full-width counter cards ── + html << "
\n" + html << "
\n" + html << stats_counter_card(STAT_MEMBERS_SVG, @stats[:members], s.community_landing_stat_members_label) + html << stats_counter_card(STAT_TOPICS_SVG, @stats[:topics], s.community_landing_stat_topics_label) + html << stats_counter_card(STAT_POSTS_SVG, @stats[:posts], s.community_landing_stat_posts_label) + html << stats_counter_card(STAT_LIKES_SVG, @stats[:likes], s.community_landing_stat_likes_label) + html << stats_counter_card(STAT_CHATS_SVG, @stats[:chats], s.community_landing_stat_chats_label) + html << "
\n
\n" + + # ── TWO-COLUMN CONTENT AREA ── + html << "
\n" + html << "
\n" + + # ── LEFT COLUMN — About + Contributors ── + html << "
\n" + + # About — quote card + 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 + html << "
\n" + html << "
\n" + html << QUOTE_SVG + if about_body.present? + html << "
#{about_body}
\n" + end + html << "
\n" + if about_image + html << "\"\"\n" + end + html << "
\n" + html << "#{e(s.community_landing_about_title)}\n" + html << "#{e(about_role)}\n" + html << "
\n" + html << "
\n" + end + + # Top Contributors + if s.community_landing_contributors_enabled && @top_contributors&.any? + html << "
\n" + html << "

#{e(s.community_landing_contributors_title)}

\n" + html << "
\n" + @top_contributors.each do |user| + avatar_url = user.avatar_template.gsub("{size}", "120") + html << "\n" + html << "\"#{e(user.username)}\"\n" + html << "#{e(user.username)}\n" + html << "\n" + end + html << "
\n
\n" + end + + html << "
\n" # end left + + # ── RIGHT COLUMN — Trending Discussions ── + html << "
\n" + + # Trending Discussions + if s.community_landing_topics_enabled && @hot_topics&.any? + html << "
\n" + html << "

#{e(s.community_landing_topics_title)}

\n" + html << "\n
\n" + end + + html << "
\n" # end right + + # ── BOTTOM ROW — Groups (full-width) ── + if s.community_landing_groups_enabled && @groups&.any? + html << "
\n" + html << "
\n" + html << "

#{e(s.community_landing_groups_title)}

\n" + html << "
\n" + @groups.each do |group| + display_name = group.name.tr("_-", " ").gsub(/\b\w/, &:upcase) + hue = group.name.bytes.sum % 360 + html << "\n" + html << "
" + if group.flair_url.present? + html << "\"\"" + else + html << "#{group.name[0].upcase}" + end + html << "
\n" + html << "#{e(display_name)}\n" + html << "#{group.user_count} members\n" + html << "
\n" + end + html << "
\n
\n" + html << "
\n" + end + + html << "
\n" # end content grid + + # ── APP CTA (above footer) ── + 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" + ios_icon = s.community_landing_ios_app_icon_url.presence + android_icon = s.community_landing_android_app_icon_url.presence + + ios_w = (badge_h * 3.0).to_i + android_w = (badge_h * 3.375).to_i + + ios_default_svg = "Download on theApp Store" + android_default_svg = "GET IT ONGoogle Play" + + html << "
\n" + html << "

#{e(s.community_landing_app_cta_headline)}

\n" + html << "

#{e(s.community_landing_app_cta_subtext)}

\n" + html << "
\n" + if s.community_landing_ios_app_url.present? + html << render_app_badge(s.community_landing_ios_app_url, ios_icon, ios_default_svg, badge_h, badge_style) + end + if s.community_landing_android_app_url.present? + html << render_app_badge(s.community_landing_android_app_url, android_icon, android_default_svg, badge_h, badge_style) + end + html << "
\n" + end + + # ── FOOTER ── + html << "\n" + + html << "\n" + html << "\n" + html + end + + def stats_counter_card(icon_svg, count, label) + "
\n" \ + "
#{icon_svg}
\n" \ + "0\n" \ + "#{e(label)}\n" \ + "
\n" + end + + end + + Discourse::Application.routes.prepend do + root to: "community_landing/landing#index", + constraints: ->(req) { + req.cookies["_t"].blank? && + SiteSetting.community_landing_enabled + } + end +end