Initial commit

This commit is contained in:
2026-03-06 15:13:24 -04:00
commit d0040edbf9
8 changed files with 1509 additions and 0 deletions

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

56
.gitignore vendored Normal file
View File

@@ -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?--*

21
LICENSE Normal file
View File

@@ -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.

View File

@@ -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"); });
})();

View File

@@ -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; }
}

78
config/locales/en.yml Normal file
View File

@@ -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 (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 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: 1001200px."
# ── 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 (3080). 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: 612 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"}]'

189
config/settings.yml Normal file
View File

@@ -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

481
plugin.rb Normal file
View File

@@ -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 = '<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>'
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)
"<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);
}
[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);
}
@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);
}
}
</style>\n"
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
# ── 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 = "<a href=\"#{store_url}\" class=\"cl-app-badge #{style_class}\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"height: #{badge_h}px;\">"
if custom_icon_url
html << "<img src=\"#{custom_icon_url}\" alt=\"\" class=\"cl-app-badge__img\">"
else
html << default_svg
end
html << "</a>\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 << "<!DOCTYPE html>\n<html lang=\"en\">\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)
html << "</head>\n<body class=\"cl-body\">\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 << "<nav class=\"cl-navbar\" id=\"cl-navbar\"><div class=\"cl-navbar__inner\">\n"
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 << "<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__links\" 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></div></nav>\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 << "<section class=\"cl-hero\" id=\"cl-hero\"#{hero_style}>\n"
html << "<div class=\"cl-hero__orb\"></div>\n"
html << "<div class=\"cl-hero__inner\">\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 || "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 << "<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" # 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 << "<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" # end hero
# ── STATS ROW — full-width counter cards ──
html << "<section class=\"cl-stats-row cl-reveal\" id=\"cl-stats-row\"><div class=\"cl-container\">\n"
html << "<div class=\"cl-stats-row__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"
# ── TWO-COLUMN CONTENT AREA ──
html << "<div class=\"cl-content\"><div class=\"cl-container\">\n"
html << "<div class=\"cl-content__grid\">\n"
# ── LEFT COLUMN — About + Contributors ──
html << "<div class=\"cl-content__left\">\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 << "<section class=\"cl-about cl-reveal\" id=\"cl-about\">\n"
html << "<div class=\"cl-about__card\">\n"
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></div>\n"
html << "</section>\n"
end
# Top Contributors
if s.community_landing_contributors_enabled && @top_contributors&.any?
html << "<section class=\"cl-contributors cl-reveal\" id=\"cl-contributors\">\n"
html << "<h2 class=\"cl-section-title\">#{e(s.community_landing_contributors_title)}</h2>\n"
html << "<div class=\"cl-contributors__list\">\n"
@top_contributors.each do |user|
avatar_url = user.avatar_template.gsub("{size}", "120")
html << "<a href=\"#{login_url}\" class=\"cl-contributor\">\n"
html << "<img src=\"#{avatar_url}\" alt=\"#{e(user.username)}\" class=\"cl-contributor__avatar\" loading=\"lazy\">\n"
html << "<span class=\"cl-contributor__name\">#{e(user.username)}</span>\n"
html << "</a>\n"
end
html << "</div>\n</section>\n"
end
html << "</div>\n" # end left
# ── RIGHT COLUMN — Trending Discussions ──
html << "<div class=\"cl-content__right\">\n"
# Trending Discussions
if s.community_landing_topics_enabled && @hot_topics&.any?
html << "<section class=\"cl-topics cl-reveal\" id=\"cl-topics\">\n"
html << "<h2 class=\"cl-section-title\">#{e(s.community_landing_topics_title)}</h2>\n"
html << "<div class=\"cl-topics__list\">\n"
@hot_topics.each do |topic|
html << "<a href=\"#{login_url}\" class=\"cl-topic-row\">\n"
if topic.category
html << "<span class=\"cl-topic-row__cat\" style=\"--cat-color: ##{topic.category.color}\">#{e(topic.category.name)}</span>\n"
end
html << "<span class=\"cl-topic-row__title\">#{e(topic.title)}</span>\n"
html << "<span class=\"cl-topic-row__meta\">"
html << "<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>"
html << " #{topic.posts_count}"
html << "</span></a>\n"
end
html << "</div>\n</section>\n"
end
html << "</div>\n" # end right
# ── BOTTOM ROW — Groups (full-width) ──
if s.community_landing_groups_enabled && @groups&.any?
html << "<div class=\"cl-content__bottom\">\n"
html << "<section class=\"cl-groups cl-reveal\" id=\"cl-groups\">\n"
html << "<h2 class=\"cl-section-title\">#{e(s.community_landing_groups_title)}</h2>\n"
html << "<div class=\"cl-groups__grid\">\n"
@groups.each do |group|
display_name = group.name.tr("_-", " ").gsub(/\b\w/, &:upcase)
hue = group.name.bytes.sum % 360
html << "<a href=\"#{login_url}\" class=\"cl-group-card\">\n"
html << "<div class=\"cl-group-card__icon\">"
if group.flair_url.present?
html << "<img src=\"#{group.flair_url}\" alt=\"\">"
else
html << "<span class=\"cl-group-card__initial\" style=\"background: hsl(#{hue}, 55%, 50%)\">#{group.name[0].upcase}</span>"
end
html << "</div>\n"
html << "<span class=\"cl-group-card__name\">#{e(display_name)}</span>\n"
html << "<span class=\"cl-group-card__count\">#{group.user_count} members</span>\n"
html << "</a>\n"
end
html << "</div>\n</section>\n"
html << "</div>\n"
end
html << "</div></div></div>\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 = "<svg viewBox=\"0 0 120 40\" width=\"#{ios_w}\" height=\"#{badge_h}\"><rect width=\"120\" height=\"40\" fill=\"#000\"/><text x=\"60\" y=\"15\" text-anchor=\"middle\" fill=\"#fff\" font-size=\"7\" font-family=\"system-ui\">Download on the</text><text x=\"60\" y=\"28\" text-anchor=\"middle\" fill=\"#fff\" font-size=\"12\" font-weight=\"600\" font-family=\"system-ui\">App Store</text></svg>"
android_default_svg = "<svg viewBox=\"0 0 135 40\" width=\"#{android_w}\" height=\"#{badge_h}\"><rect width=\"135\" height=\"40\" fill=\"#000\"/><text x=\"67\" y=\"15\" text-anchor=\"middle\" fill=\"#fff\" font-size=\"7\" font-family=\"system-ui\">GET IT ON</text><text x=\"67\" y=\"28\" text-anchor=\"middle\" fill=\"#fff\" font-size=\"12\" font-weight=\"600\" font-family=\"system-ui\">Google Play</text></svg>"
html << "<section class=\"cl-app-cta cl-reveal\" id=\"cl-app-cta\"><div class=\"cl-container\">\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"
html << "<div class=\"cl-app-cta__badges\">\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 << "</div></div></section>\n"
end
# ── FOOTER ──
html << "<footer class=\"cl-footer\" id=\"cl-footer\">\n"
html << "<div class=\"cl-container\">\n"
html << "<div class=\"cl-footer__top\">\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"
if s.community_landing_footer_text.present?
html << "<div class=\"cl-footer__text\">#{s.community_landing_footer_text}</div>\n"
end
html << "<div class=\"cl-footer__copy\">&copy; #{Time.now.year} #{e(site_name)}</div>\n"
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-stats-counter\">\n" \
"<div class=\"cl-stats-counter__icon\">#{icon_svg}</div>\n" \
"<span class=\"cl-stats-counter__value\" data-count=\"#{count}\">0</span>\n" \
"<span class=\"cl-stats-counter__label\">#{e(label)}</span>\n" \
"</div>\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