mirror of
https://github.com/dpnmw/community-landing.git
synced 2026-03-18 09:27:16 +00:00
Major overhaul v2
This commit is contained in:
@@ -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 (50–500). 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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
36
plugin.rb
36
plugin.rb
@@ -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? &&
|
||||
|
||||
Reference in New Issue
Block a user