Major overhaul v2

This commit is contained in:
2026-03-08 12:58:09 -04:00
parent 26d0a7b0a3
commit 6e527502db
8 changed files with 485 additions and 192 deletions

View File

@@ -1,4 +1,4 @@
// community-landing-admin-tabs v2.4.0
// community-landing-admin-tabs v2.5.0
import { withPluginApi } from "discourse/lib/plugin-api";
// Setting descriptions — injected into the admin DOM since the newer
@@ -120,7 +120,7 @@ const DESCRIPTIONS = {
participation_title_enabled: "Show heading above participation cards.",
participation_title: "Heading text above participation cards.",
participation_bio_max_length: "Max characters from each user's bio (50500). Longer bios are truncated.",
participation_icon_color: "Color for the decorative quote icon on cards. Leave blank for accent color.",
participation_icon_color: "Color for the decorative quote icon and avatar border. Leave blank for accent color.",
participation_card_bg_dark: "Participation card background for dark mode.",
participation_card_bg_light: "Participation card background for light mode.",
participation_bg_dark: "Section background for dark mode. Leave blank for default.",
@@ -128,6 +128,11 @@ const DESCRIPTIONS = {
participation_min_height: "Minimum section height in pixels. 0 = auto.",
participation_border_style: "Border style at the bottom of the section.",
participation_title_size: "Section title font size in pixels. 0 = use default.",
participation_stat_color: "Color for stat numbers (Topics, Posts, Likes). Leave blank for default text color.",
participation_stat_label_color: "Color for stat labels below the numbers. Leave blank for muted text.",
participation_bio_color: "Color for the bio excerpt text. Leave blank for default text color.",
participation_name_color: "Color for the @username. Leave blank for default strong text color.",
participation_meta_color: "Color for the join date and location line. Leave blank for accent color.",
// ── Stats ──
stats_enabled: "Show the stats section with live community counters.",
@@ -304,7 +309,10 @@ const TABS = [
"participation_icon_color",
"participation_card_bg_dark", "participation_card_bg_light",
"participation_bg_dark", "participation_bg_light",
"participation_min_height", "participation_border_style"
"participation_min_height", "participation_border_style",
"participation_stat_color", "participation_stat_label_color",
"participation_bio_color", "participation_name_color",
"participation_meta_color"
])
},
{
@@ -333,7 +341,7 @@ const TABS = [
},
{
id: "topics",
label: "Trending",
label: "Topics",
settings: new Set([
"topics_enabled", "topics_title_enabled", "topics_title", "topics_title_size",
"topics_count",
@@ -343,7 +351,7 @@ const TABS = [
},
{
id: "groups",
label: "Spaces",
label: "Groups",
settings: new Set([
"groups_enabled", "groups_title_enabled", "groups_title", "groups_title_size",
"groups_count", "groups_selected",
@@ -386,43 +394,6 @@ const TABS = [
}
];
// Dark/light color pairs — light row gets merged into dark row (same-row display)
const BG_PAIRS = [
// Navbar
["navbar_signin_color_dark", "navbar_signin_color_light"],
["navbar_join_color_dark", "navbar_join_color_light"],
// Hero
["hero_primary_btn_color_dark", "hero_primary_btn_color_light"],
["hero_secondary_btn_color_dark", "hero_secondary_btn_color_light"],
["hero_bg_dark", "hero_bg_light"],
["hero_card_bg_dark", "hero_card_bg_light"],
["contributors_pill_bg_dark", "contributors_pill_bg_light"],
// Participation
["participation_card_bg_dark", "participation_card_bg_light"],
["participation_bg_dark", "participation_bg_light"],
// Stats
["stat_card_bg_dark", "stat_card_bg_light"],
["stats_bg_dark", "stats_bg_light"],
// About
["about_card_color_dark", "about_card_color_light"],
["about_bg_dark", "about_bg_light"],
// Trending
["topics_card_bg_dark", "topics_card_bg_light"],
["topics_bg_dark", "topics_bg_light"],
// Spaces
["groups_card_bg_dark", "groups_card_bg_light"],
["groups_bg_dark", "groups_bg_light"],
// FAQ
["faq_card_bg_dark", "faq_card_bg_light"],
// App CTA
["app_cta_gradient_start_dark", "app_cta_gradient_start_light"],
["app_cta_gradient_mid_dark", "app_cta_gradient_mid_light"],
["app_cta_gradient_end_dark", "app_cta_gradient_end_light"],
["app_cta_bg_dark", "app_cta_bg_light"],
// Footer
["footer_bg_dark", "footer_bg_light"],
];
// Tabs that depend on a section-enable toggle.
// When the toggle is OFF the tab shows a notice instead of the full settings list.
const TAB_ENABLE_SETTINGS = {
@@ -436,12 +407,29 @@ const TAB_ENABLE_SETTINGS = {
participation: { setting: "participation_enabled", label: "Participation" },
stats: { setting: "stats_enabled", label: "Stats" },
about: { setting: "about_enabled", label: "About" },
topics: { setting: "topics_enabled", label: "Trending" },
groups: { setting: "groups_enabled", label: "Spaces" },
topics: { setting: "topics_enabled", label: "Topics" },
groups: { setting: "groups_enabled", label: "Groups" },
faq: { setting: "faq_enabled", label: "FAQ" },
appcta: { setting: "show_app_ctas", label: "App CTA" },
};
// Image URL settings that get upload buttons injected in the admin panel.
// "multi: true" means pipe-separated list (appends rather than replaces).
const IMAGE_UPLOAD_SETTINGS = {
og_image_url: { label: "Upload Image", multi: false },
favicon_url: { label: "Upload Favicon", multi: false },
logo_dark_url: { label: "Upload Logo", multi: false },
logo_light_url: { label: "Upload Logo", multi: false },
footer_logo_url: { label: "Upload Logo", multi: false },
hero_background_image_url: { label: "Upload Image", multi: false },
hero_image_urls: { label: "Add Image", multi: true },
about_image_url: { label: "Upload Image", multi: false },
about_background_image_url: { label: "Upload Image", multi: false },
ios_app_badge_image_url: { label: "Upload Badge", multi: false },
android_app_badge_image_url: { label: "Upload Badge", multi: false },
app_cta_image_url: { label: "Upload Image", multi: false },
};
let currentTab = "settings";
let filterActive = false;
let isActive = false;
@@ -487,9 +475,14 @@ function updateActiveStates(activeId) {
});
// Native nav: the original Settings <li>
// Discourse's router keeps aria-current="true" on the native <a>, so we
// must also add a class to suppress its active styling when another tab
// is selected.
const nativeItem = document.querySelector(".cl-native-settings-item");
if (nativeItem) {
nativeItem.classList.toggle("active", activeId === "settings");
const isSettings = activeId === "settings";
nativeItem.classList.toggle("active", isSettings);
nativeItem.classList.toggle("cl-tab-inactive", !isSettings);
}
// Standalone fallback: <button> tabs
@@ -523,6 +516,7 @@ function handleTabClick(container, tabId) {
clearDisabledNotice(container);
updateActiveStates(tabId);
applyTabFilter();
injectUploadButtons();
updateDisabledNotice(container);
}
@@ -537,7 +531,7 @@ function cleanupTabs() {
// Restore native Settings <li> — remove our hook class
const nativeItem = document.querySelector(".cl-native-settings-item");
if (nativeItem) {
nativeItem.classList.remove("cl-native-settings-item", "active");
nativeItem.classList.remove("cl-native-settings-item", "active", "cl-tab-inactive");
}
// Remove filter-active class from native nav
@@ -560,11 +554,6 @@ function cleanupTabs() {
el.classList.remove("cl-tab-hidden");
});
// Remove merge classes
container.querySelectorAll(".cl-merged-dark, .cl-merged-light").forEach((el) => {
el.classList.remove("cl-merged-dark", "cl-merged-light");
});
// Remove disabled notices
clearDisabledNotice(container);
}
@@ -602,46 +591,43 @@ function injectDescriptions() {
}
/**
* Merge dark/light bg color pairs into a single visual row.
* CSS-only approach — elements stay in their original DOM positions
* (preserving Ember bindings, undo/reset buttons, and re-renders).
* For boolean (checkbox) settings:
* 1. Strip "enable" / "enabled" from the setting-label heading.
* 2. Add an "Enable" text label next to the checkbox in setting-value.
*/
function mergeBgPairs() {
function cleanBooleanLabels() {
const container = getContainer();
if (!container) return;
BG_PAIRS.forEach(([darkName, lightName]) => {
const darkRow = container.querySelector(`.row.setting[data-setting="${darkName}"]`);
const lightRow = container.querySelector(`.row.setting[data-setting="${lightName}"]`);
if (!darkRow || !lightRow) return;
// Already merged
if (darkRow.classList.contains("cl-merged-dark")) return;
container.querySelectorAll(".row.setting[data-setting]").forEach((row) => {
const cb = row.querySelector('.setting-value input[type="checkbox"]');
if (!cb) return; // not a boolean setting
// Rename the dark row label (remove " dark" suffix)
const darkH3 = darkRow.querySelector(".setting-label h3");
if (darkH3) {
darkH3.textContent = darkH3.textContent.replace(/\s*dark$/i, "").trim();
// Already processed
if (cb.dataset.clLabelCleaned) return;
cb.dataset.clLabelCleaned = "1";
// 1. Clean the heading: remove "enable" / "enabled" (case-insensitive)
const h3 = row.querySelector(".setting-label h3");
if (h3) {
h3.childNodes.forEach((node) => {
if (node.nodeType === Node.TEXT_NODE) {
node.textContent = node.textContent
.replace(/\benabled?\b/gi, "")
.replace(/\s{2,}/g, " ")
.trim();
}
});
}
// Add "Dark" / "Light" labels to each row's setting-value
const darkValue = darkRow.querySelector(".setting-value");
const lightValue = lightRow.querySelector(".setting-value");
if (darkValue && !darkValue.querySelector(".cl-color-col__label")) {
// 2. Add "Enable" label next to the checkbox
const valueDiv = row.querySelector(".setting-value");
if (valueDiv && !valueDiv.querySelector(".cl-enable-label")) {
const lbl = document.createElement("span");
lbl.className = "cl-color-col__label";
lbl.textContent = "Dark";
darkValue.insertBefore(lbl, darkValue.firstChild);
lbl.className = "cl-enable-label";
lbl.textContent = "Enable";
cb.insertAdjacentElement("afterend", lbl);
}
if (lightValue && !lightValue.querySelector(".cl-color-col__label")) {
const lbl = document.createElement("span");
lbl.className = "cl-color-col__label";
lbl.textContent = "Light";
lightValue.insertBefore(lbl, lightValue.firstChild);
}
// Just add classes — NO DOM moves, preserves all Ember bindings
darkRow.classList.add("cl-merged-dark");
lightRow.classList.add("cl-merged-light");
});
}
@@ -777,7 +763,8 @@ function buildTabsUI() {
container.classList.add("cl-tabs-active");
injectDescriptions();
mergeBgPairs();
cleanBooleanLabels();
injectUploadButtons();
applyTabFilter();
updateDisabledNotice(container);
listenForEnableToggles(container);
@@ -825,7 +812,8 @@ function buildTabsUI() {
container.classList.add("cl-tabs-active");
injectDescriptions();
mergeBgPairs();
cleanBooleanLabels();
injectUploadButtons();
applyTabFilter();
updateDisabledNotice(container);
listenForEnableToggles(container);
@@ -850,6 +838,197 @@ function listenForEnableToggles(container) {
});
}
// ── Image Upload Helpers ──
function getCsrfToken() {
const meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute("content") : "";
}
async function uploadFile(file) {
const formData = new FormData();
formData.append("file", file);
formData.append("type", "composer");
formData.append("synchronous_uploads", "true");
const response = await fetch("/uploads.json", {
method: "POST",
headers: { "X-CSRF-Token": getCsrfToken() },
body: formData,
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Upload failed (${response.status}): ${text}`);
}
return response.json();
}
async function pinUpload(uploadId, settingName) {
const response = await fetch("/community-landing/admin/pin-upload", {
method: "POST",
headers: {
"X-CSRF-Token": getCsrfToken(),
"Content-Type": "application/json",
},
body: JSON.stringify({ upload_id: uploadId, setting_name: settingName }),
});
if (!response.ok) {
console.warn("[CL] Pin upload failed:", response.status);
}
}
function setSettingValue(row, newValue, multi) {
// For list-type settings (hero_image_urls), Discourse renders a .values .value-list
// or a plain text input. Try the text input first.
const input =
row.querySelector('.setting-value input[type="text"]') ||
row.querySelector(".setting-value textarea");
if (!input) return;
if (multi) {
const current = input.value.trim();
input.value = current ? current + "|" + newValue : newValue;
} else {
input.value = newValue;
}
// Dispatch events so Discourse's admin UI picks up the change
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
// Some Discourse admin UIs require a keydown Enter to register
input.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", keyCode: 13, bubbles: true })
);
}
function updatePreviewThumbnail(wrapper, settingName) {
const row = wrapper.closest(".row.setting");
if (!row) return;
const input =
row.querySelector('.setting-value input[type="text"]') ||
row.querySelector(".setting-value textarea");
const url = input ? input.value.trim() : "";
const preview = wrapper.querySelector(".cl-upload-preview");
if (!preview) return;
const cfg = IMAGE_UPLOAD_SETTINGS[settingName];
if (cfg && cfg.multi) {
// For multi-image, show the last image in the list
const urls = url.split("|").filter(Boolean);
const lastUrl = urls.length > 0 ? urls[urls.length - 1] : "";
preview.src = lastUrl;
preview.style.display = lastUrl ? "" : "none";
} else {
preview.src = url;
preview.style.display = url ? "" : "none";
}
}
function injectUploadButtons() {
const container = getContainer();
if (!container) return;
Object.entries(IMAGE_UPLOAD_SETTINGS).forEach(([settingName, cfg]) => {
const row = container.querySelector(
`.row.setting[data-setting="${settingName}"]`
);
if (!row) return;
if (row.dataset.clUploadInjected) return;
row.dataset.clUploadInjected = "1";
const valueDiv = row.querySelector(".setting-value");
if (!valueDiv) return;
// Build wrapper
const wrapper = document.createElement("div");
wrapper.className = "cl-upload-wrapper";
// Upload button
const btn = document.createElement("button");
btn.type = "button";
btn.className = "cl-upload-btn";
btn.textContent = cfg.label;
btn.addEventListener("click", () => {
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = "image/*";
fileInput.style.display = "none";
document.body.appendChild(fileInput);
fileInput.addEventListener("change", async () => {
const file = fileInput.files[0];
if (!file) return;
fileInput.remove();
// Show uploading status
let status = wrapper.querySelector(".cl-upload-status");
if (!status) {
status = document.createElement("span");
status.className = "cl-upload-status";
wrapper.appendChild(status);
}
status.textContent = "Uploading…";
btn.disabled = true;
try {
const data = await uploadFile(file);
await pinUpload(data.id, settingName);
setSettingValue(row, data.url, cfg.multi);
updatePreviewThumbnail(wrapper, settingName);
status.textContent = "";
} catch (err) {
status.textContent = "Upload failed";
console.error("[CL] Upload error:", err);
} finally {
btn.disabled = false;
}
});
fileInput.click();
});
wrapper.appendChild(btn);
// Preview thumbnail
const preview = document.createElement("img");
preview.className = "cl-upload-preview";
preview.alt = "Preview";
wrapper.appendChild(preview);
// Remove button (single-image only)
if (!cfg.multi) {
const removeBtn = document.createElement("button");
removeBtn.type = "button";
removeBtn.className = "cl-upload-remove";
removeBtn.textContent = "Remove";
removeBtn.addEventListener("click", () => {
setSettingValue(row, "", false);
updatePreviewThumbnail(wrapper, settingName);
});
wrapper.appendChild(removeBtn);
}
valueDiv.appendChild(wrapper);
// Initialize preview from current value
updatePreviewThumbnail(wrapper, settingName);
// Listen for manual URL changes to update preview
const input =
row.querySelector('.setting-value input[type="text"]') ||
row.querySelector(".setting-value textarea");
if (input) {
input.addEventListener("input", () => {
updatePreviewThumbnail(wrapper, settingName);
});
}
});
}
// ── Global filter detection via event delegation ──
// This survives DOM re-renders because it's on document, not on a specific input
document.addEventListener(

View File

@@ -1,6 +1,6 @@
/* ═══════════════════════════════════════════════════════════════════
Community Landing — Admin Settings Panel Styles v2.4.0
Tab navigation + fallback separators + bg-pair layout
Community Landing — Admin Settings Panel Styles v2.5.0
Tab navigation + fallback separators + image upload buttons
═══════════════════════════════════════════════════════════════════ */
/* ── Tab-hidden class (used instead of inline display:none) ── */
@@ -23,6 +23,19 @@
cursor: pointer;
}
/* Active state for our injected tabs */
.d-nav-submenu__tabs li.cl-admin-tab.active a {
color: var(--tertiary, #0088cc);
}
/* Suppress native Settings tab active styling when another CL tab is selected.
Discourse keeps aria-current on the native <a>, so we override it. */
.d-nav-submenu__tabs li.cl-tab-inactive a,
.d-nav-submenu__tabs li.cl-tab-inactive a[aria-current="true"] {
color: var(--primary-medium, #888) !important;
border-bottom-color: transparent !important;
}
/* Dimmed state when Discourse filter/search is active */
.d-nav-submenu__tabs.cl-filter-active > li {
opacity: 0.4;
@@ -82,97 +95,14 @@ html.dark-scheme .cl-admin-tabs .cl-admin-tab:hover {
color: var(--primary, #ddd);
}
/* ── Merged dark/light color pairs (CSS-only, no DOM moves) ── */
/* ── Boolean "Enable" label next to checkboxes ── */
.cl-tabs-active .row.setting.cl-merged-dark {
float: left;
width: calc(50% - 8px);
margin-right: 16px;
padding-bottom: 0 !important;
margin-bottom: 0 !important;
}
.cl-tabs-active .row.setting.cl-merged-light {
float: left;
width: calc(50% - 8px);
padding-bottom: 0 !important;
margin-bottom: 20px !important;
}
/* Hide the light row's label + description — dark row's label covers both */
.cl-tabs-active .row.setting.cl-merged-light > .setting-label {
display: none;
}
.cl-tabs-active .row.setting.cl-merged-light .desc {
display: none;
}
/* Light row's value area fills the full width since label is hidden */
.cl-tabs-active .row.setting.cl-merged-light > .setting-value {
width: 100%;
float: none;
}
/* Dark row: label + value stack vertically inside the half-width */
.cl-tabs-active .row.setting.cl-merged-dark > .setting-label {
float: none;
width: 100%;
margin-bottom: 4px;
}
.cl-tabs-active .row.setting.cl-merged-dark > .setting-value {
float: none;
width: 100%;
padding-right: 0;
}
/* Controls (reset/undo) inside merged rows — inline after the color picker */
.cl-tabs-active .row.setting.cl-merged-dark > .setting-controls,
.cl-tabs-active .row.setting.cl-merged-light > .setting-controls {
float: none;
display: inline-block;
margin-top: 4px;
}
/* Clearfix after the light row to restore normal flow */
.cl-tabs-active .row.setting.cl-merged-light::after {
content: "";
display: block;
clear: both;
}
/* Insert a clear break after each pair to prevent stacking issues */
.cl-tabs-active .row.setting.cl-merged-light + .row.setting:not(.cl-merged-light) {
clear: both;
}
/* Also clear when a merged-light is followed by anything else */
.cl-tabs-active .cl-merged-light + *:not(.cl-merged-light):not(.cl-tab-hidden) {
clear: both;
}
.cl-color-col__label {
display: block;
font-size: var(--font-down-1);
font-weight: 600;
color: var(--primary-medium);
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
@media (max-width: 767px) {
.cl-tabs-active .row.setting.cl-merged-dark,
.cl-tabs-active .row.setting.cl-merged-light {
float: none;
width: 100%;
margin-right: 0;
}
.cl-tabs-active .row.setting.cl-merged-light > .setting-label {
display: none;
}
.cl-enable-label {
margin-left: 6px;
font-size: var(--font-0);
color: var(--primary-high);
vertical-align: middle;
cursor: default;
}
/* ── Disabled-section notice banner ── */
@@ -449,3 +379,72 @@ html.dark-scheme .admin-detail:not(.cl-tabs-active) .row.setting[data-setting="f
width: 100% !important;
}
}
/* ── Image upload buttons & preview ── */
.cl-upload-wrapper {
display: flex;
align-items: center;
gap: 10px;
margin-top: 8px;
flex-wrap: wrap;
}
.cl-upload-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
font-size: var(--font-down-1);
font-weight: 600;
color: var(--secondary);
background: var(--tertiary);
border: none;
border-radius: 4px;
cursor: pointer;
transition: background 0.15s ease, opacity 0.15s ease;
}
.cl-upload-btn:hover {
background: var(--tertiary-hover);
}
.cl-upload-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.cl-upload-preview {
max-width: 80px;
max-height: 48px;
border-radius: 4px;
border: 1px solid var(--primary-low);
object-fit: contain;
background: var(--primary-very-low);
}
.cl-upload-preview[src=""],
.cl-upload-preview:not([src]) {
display: none;
}
.cl-upload-remove {
padding: 4px 8px;
font-size: var(--font-down-2);
color: var(--danger);
background: none;
border: 1px solid var(--danger-low);
border-radius: 4px;
cursor: pointer;
transition: background 0.15s ease;
}
.cl-upload-remove:hover {
background: var(--danger-low);
}
.cl-upload-status {
font-size: var(--font-down-2);
color: var(--primary-medium);
font-style: italic;
}

View File

@@ -1303,24 +1303,62 @@ html {
border-color: var(--cl-border-hover);
}
.cl-participation-card__header {
display: flex;
align-items: flex-start;
gap: 0.75rem;
flex: 1;
}
.cl-participation-card__quote {
color: var(--cl-participation-icon-color, var(--cl-accent));
opacity: 0.6;
flex-shrink: 0;
padding-top: 2px;
}
.cl-participation-card__quote svg {
width: 28px;
height: 28px;
width: 24px;
height: 24px;
}
.cl-participation-card__bio {
font-size: 0.95rem;
line-height: 1.65;
color: var(--cl-text);
color: var(--cl-participation-bio-color, var(--cl-text));
margin: 0;
flex: 1;
}
.cl-participation-card__stats {
display: flex;
justify-content: space-around;
padding: 0.5rem 0;
}
.cl-participation-stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
flex: 1;
}
.cl-participation-stat__value {
font-size: 1.1rem;
font-weight: 700;
color: var(--cl-participation-stat-color, var(--cl-text-strong));
line-height: 1.2;
}
.cl-participation-stat__label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--cl-participation-stat-label-color, var(--cl-text-muted, var(--cl-text)));
opacity: 0.6;
}
.cl-participation-card__footer {
display: flex;
align-items: center;
@@ -1347,7 +1385,7 @@ html {
.cl-participation-card__name {
font-weight: 600;
font-size: 0.9rem;
color: var(--cl-text-strong);
color: var(--cl-participation-name-color, var(--cl-text-strong));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -1355,7 +1393,7 @@ html {
.cl-participation-card__count {
font-size: 0.8rem;
color: var(--cl-participation-icon-color, var(--cl-accent));
color: var(--cl-participation-meta-color, var(--cl-participation-icon-color, var(--cl-accent)));
}
/* ═══════════════════════════════════════════════════════════════════

View File

@@ -606,6 +606,21 @@ plugins:
type: integer
min: 0
max: 80
participation_stat_color:
default: ""
type: color
participation_stat_label_color:
default: ""
type: color
participation_bio_color:
default: ""
type: color
participation_name_color:
default: ""
type: color
participation_meta_color:
default: ""
type: color
# ══════════════════════════════════════════
# 7. Community Spaces Section

View File

@@ -11,7 +11,7 @@ module CommunityLanding
if s.contributors_enabled || (s.participation_enabled rescue true)
User
.joins(:posts)
.includes(:user_profile)
.includes(:user_profile, :user_stat)
.where(posts: { created_at: s.contributors_days.days.ago.. })
.where.not(username: %w[system discobot])
.where(active: true, staged: false)

View File

@@ -419,9 +419,6 @@ module CommunityLanding
title_text = @s.participation_title.presence || "Participation"
border = @s.participation_border_style rescue "none"
min_h = @s.participation_min_height rescue 0
count_label = @s.contributors_count_label.presence || ""
show_count = @s.contributors_count_label_enabled rescue true
html = +""
html << "<section class=\"cl-participation cl-anim\" id=\"cl-participation\"#{section_style(border, min_h)}><div class=\"cl-container\">\n"
html << "<h2 class=\"cl-section-title\"#{title_style(:participation_title_size)}>#{e(title_text)}</h2>\n" if show_title
@@ -430,19 +427,30 @@ module CommunityLanding
users_with_bio.each do |user|
avatar_url = user.avatar_template.to_s.gsub("{size}", "120")
activity_count = user.attributes["post_count"].to_i rescue 0
bio_raw = user.user_profile.bio_excerpt.to_s
bio_text = bio_raw.length > bio_max ? "#{bio_raw[0...bio_max]}..." : bio_raw
count_prefix = show_count && count_label.present? ? "#{e(count_label)} " : ""
join_date = user.created_at.strftime("Joined %b %Y") rescue "Member"
location = (user.user_profile&.location.presence rescue nil)
meta_line = location ? "#{join_date} · #{e(location)}" : join_date
topic_count = (user.user_stat&.topic_count.to_i rescue 0)
post_count = (user.user_stat&.post_count.to_i rescue 0)
likes_received = (user.user_stat&.likes_received.to_i rescue 0)
html << "<div class=\"cl-participation-card\">\n"
html << "<div class=\"cl-participation-card__header\">\n"
html << "<div class=\"cl-participation-card__quote\">#{Icons::QUOTE_SVG}</div>\n"
html << "<p class=\"cl-participation-card__bio\">#{e(bio_text)}</p>\n"
html << "</div>\n"
html << "<div class=\"cl-participation-card__stats\">\n"
html << "<div class=\"cl-participation-stat\"><span class=\"cl-participation-stat__value\">#{topic_count}</span><span class=\"cl-participation-stat__label\">Topics</span></div>\n"
html << "<div class=\"cl-participation-stat\"><span class=\"cl-participation-stat__value\">#{post_count}</span><span class=\"cl-participation-stat__label\">Posts</span></div>\n"
html << "<div class=\"cl-participation-stat\"><span class=\"cl-participation-stat__value\">#{likes_received}</span><span class=\"cl-participation-stat__label\">Likes</span></div>\n"
html << "</div>\n"
html << "<div class=\"cl-participation-card__footer\">\n"
html << "<img src=\"#{avatar_url}\" alt=\"#{e(user.username)}\" class=\"cl-participation-card__avatar\" loading=\"lazy\">\n"
html << "<div class=\"cl-participation-card__meta\">\n"
html << "<span class=\"cl-participation-card__name\">@#{e(user.username)}</span>\n"
html << "<span class=\"cl-participation-card__count\">#{count_prefix}#{activity_count}</span>\n"
html << "<span class=\"cl-participation-card__count\">#{e(meta_line)}</span>\n"
html << "</div>\n"
html << "</div>\n"
html << "</div>\n"

View File

@@ -56,6 +56,11 @@ module CommunityLanding
part_card_dark = safe_hex(:participation_card_bg_dark)
part_card_light = safe_hex(:participation_card_bg_light)
part_icon_color = safe_hex(:participation_icon_color)
part_stat_color = safe_hex(:participation_stat_color)
part_stat_lbl_color = safe_hex(:participation_stat_label_color)
part_bio_color = safe_hex(:participation_bio_color)
part_name_color = safe_hex(:participation_name_color)
part_meta_color = safe_hex(:participation_meta_color)
orb_color = safe_hex(:orb_color)
orb_opacity = [[@s.orb_opacity.to_i, 0].max, 100].min rescue 50
@@ -128,6 +133,11 @@ module CommunityLanding
--cl-about-card-bg: #{about_dark_css};
--cl-participation-card-bg: #{part_card_dark || 'var(--cl-card)'};
--cl-participation-icon-color: #{part_icon_color || 'var(--cl-accent)'};
--cl-participation-stat-color: #{part_stat_color || 'var(--cl-text-strong)'};
--cl-participation-stat-label-color: #{part_stat_lbl_color || 'var(--cl-text-muted, var(--cl-text))'};
--cl-participation-bio-color: #{part_bio_color || 'var(--cl-text)'};
--cl-participation-name-color: #{part_name_color || 'var(--cl-text-strong)'};
--cl-participation-meta-color: #{part_meta_color || 'var(--cl-participation-icon-color)'};
--cl-faq-card-bg: #{faq_card_dark || 'var(--cl-card)'};
--cl-app-gradient: linear-gradient(135deg, #{app_g1_dark}, #{app_g2_dark}, #{app_g3_dark});#{dark_extras}
}
@@ -153,6 +163,11 @@ module CommunityLanding
--cl-about-card-bg: #{about_light_css};
--cl-participation-card-bg: #{part_card_light || part_card_dark || 'var(--cl-card)'};
--cl-participation-icon-color: #{part_icon_color || 'var(--cl-accent)'};
--cl-participation-stat-color: #{part_stat_color || 'var(--cl-text-strong)'};
--cl-participation-stat-label-color: #{part_stat_lbl_color || 'var(--cl-text-muted, var(--cl-text))'};
--cl-participation-bio-color: #{part_bio_color || 'var(--cl-text)'};
--cl-participation-name-color: #{part_name_color || 'var(--cl-text-strong)'};
--cl-participation-meta-color: #{part_meta_color || 'var(--cl-participation-icon-color)'};
--cl-faq-card-bg: #{faq_card_light || faq_card_dark || 'var(--cl-card)'};
--cl-app-gradient: linear-gradient(135deg, #{app_g1_light || app_g1_dark}, #{app_g2_light || app_g2_dark}, #{app_g3_light || app_g3_dark});#{light_extras}
}
@@ -176,6 +191,11 @@ module CommunityLanding
--cl-about-card-bg: #{about_light_css};
--cl-participation-card-bg: #{part_card_light || part_card_dark || 'var(--cl-card)'};
--cl-participation-icon-color: #{part_icon_color || 'var(--cl-accent)'};
--cl-participation-stat-color: #{part_stat_color || 'var(--cl-text-strong)'};
--cl-participation-stat-label-color: #{part_stat_lbl_color || 'var(--cl-text-muted, var(--cl-text))'};
--cl-participation-bio-color: #{part_bio_color || 'var(--cl-text)'};
--cl-participation-name-color: #{part_name_color || 'var(--cl-text-strong)'};
--cl-participation-meta-color: #{part_meta_color || 'var(--cl-participation-icon-color)'};
--cl-faq-card-bg: #{faq_card_light || faq_card_dark || 'var(--cl-card)'};
--cl-app-gradient: linear-gradient(135deg, #{app_g1_light || app_g1_dark}, #{app_g2_light || app_g2_dark}, #{app_g3_light || app_g3_dark});#{light_extras}
}

View File

@@ -2,7 +2,7 @@
# name: community-landing
# about: Branded public landing page for unauthenticated visitors
# version: 2.4.0
# version: 2.5.0
# authors: DPN MEDiA WORKS
# url: https://github.com/dpnmw/community-landing
# meta_url: https://dpnmediaworks.com
@@ -78,7 +78,41 @@ after_initialize do
end
end
class ::CommunityLanding::AdminUploadsController < ::ApplicationController
requires_plugin CommunityLanding::PLUGIN_NAME
before_action :ensure_admin
ALLOWED_IMAGE_SETTINGS = %w[
og_image_url favicon_url logo_dark_url logo_light_url footer_logo_url
hero_background_image_url hero_image_urls about_image_url
about_background_image_url ios_app_badge_image_url
android_app_badge_image_url app_cta_image_url
].freeze
# POST /community-landing/admin/pin-upload
def pin_upload
upload = Upload.find(params[:upload_id])
setting_name = params[:setting_name].to_s
raise Discourse::InvalidParameters unless ALLOWED_IMAGE_SETTINGS.include?(setting_name)
key = "upload_pin_#{setting_name}"
existing = PluginStore.get("community-landing", key)
existing_ids = existing ? existing.to_s.split(",").map(&:to_i) : []
existing_ids << upload.id unless existing_ids.include?(upload.id)
PluginStore.set("community-landing", key, existing_ids.join(","))
row = PluginStoreRow.find_by(plugin_name: "community-landing", key: key)
UploadReference.ensure_exist!(upload_ids: existing_ids, target: row) if row
render json: { success: true, upload_id: upload.id }
end
end
Discourse::Application.routes.prepend do
post "/community-landing/admin/pin-upload" =>
"community_landing/admin_uploads#pin_upload",
constraints: AdminConstraint.new
root to: "community_landing/landing#index",
constraints: ->(req) {
req.cookies["_t"].blank? &&