Files
community-landing/assets/javascripts/discourse/initializers/community-landing-admin-tabs.js

1393 lines
58 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// community-landing-admin-tabs v2.6.1
import { withPluginApi } from "discourse/lib/plugin-api";
// Setting descriptions — injected into the admin DOM since the newer
// plugin settings page does not render .desc elements automatically.
const DESCRIPTIONS = {
// ── Master Switch ──
community_landing_enabled: "Enable the community landing page for logged-out visitors.",
// ── Layout ──
section_order: "Order of content sections. Drag to reorder. Available: hero, stats, about, participation, topics, groups, app_cta.",
custom_css: "Raw CSS injected after all plugin styles. Use for overrides and tweaks. No style tags needed.",
// ── SEO & Meta ──
meta_description: "Meta description for search engines and social sharing. If blank, the hero subtitle is used.",
og_image_url: "Open Graph image URL for social sharing (1200×630px recommended). If blank, the site logo is used.",
favicon_url: "Custom favicon URL (.ico, .png, .svg). If blank, the browser default is used.",
json_ld_enabled: "Add JSON-LD structured data (Organization + WebSite schema) for search engines.",
// ── Branding: Logo ──
logo_dark_url: "Logo image URL for dark mode. Shown in navbar and footer. Leave blank to show site name as text.",
logo_light_url: "Logo image URL for light mode. If not set, the dark logo is used for both themes.",
logo_height: "Logo height in pixels (1680). Applies to both navbar and footer logos.",
logo_use_accent_color: "Tint the logo to match the accent color. Works best with monochrome SVG or PNG logos.",
footer_logo_url: "Override logo for the footer only. If not set, the navbar logo is reused.",
// ── Colors ──
accent_color: "Primary accent color: buttons, links, highlights, gradients, stat icons. Hex value.",
accent_hover_color: "Accent color on hover. Should be slightly lighter or darker than the accent.",
dark_bg_color: "Page background color for dark mode.",
light_bg_color: "Page background color for light mode.",
orb_color: "Color of decorative background orbs. Leave blank to use the accent color.",
orb_opacity: "Opacity of the background orbs (0100). Default: 50.",
// ── Scroll Animations ──
scroll_animation: "How sections animate into view on scroll: fade_up, fade_in, slide_left, slide_right, zoom_in, flip_up, or none.",
staggered_reveal_enabled: "Animate child elements (cards, stats) with a staggered delay for a cascading reveal effect.",
dynamic_background_enabled: "Enable parallax background orbs that drift as the user scrolls.",
mouse_parallax_enabled: "Enable subtle parallax movement of background elements in response to mouse position.",
scroll_progress_enabled: "Show a thin progress bar at the top of the page indicating scroll position.",
// ── Fonts ──
google_font_name: "Google Font family for body text. Must match exact Google Fonts name (e.g. 'Inter', 'Poppins'). Default: Outfit.",
title_font_name: "Separate Google Font for titles and headings. Leave blank to use the body font.",
// ── Preloader ──
preloader_enabled: "Show a loading overlay with logo and progress counter while the page loads.",
preloader_logo_url: "Custom logo for the preloader. Leave blank to use the site logo.",
preloader_bg_dark: "Preloader background for dark mode.",
preloader_bg_light: "Preloader background for light mode.",
preloader_text_color_dark: "Percentage text color for dark mode.",
preloader_text_color_light: "Percentage text color for light mode.",
preloader_bar_color: "Progress bar color. Leave blank for accent color.",
preloader_min_duration: "Minimum display time (ms). Prevents a flash on fast connections.",
// ── Icons ──
icon_library: "Icon library for buttons and titles. 'fontawesome' = FA 6 Free, 'google' = Material Symbols Outlined. Syntax: \"icon | Label\" or \"icon | size | Label\" (size in px).",
// ── Navbar ──
navbar_signin_label: "Sign-in link text. Use 'icon | Label' for FA icon before or 'Label | icon' for after (e.g. 'right-to-bracket | Sign In').",
navbar_signin_enabled: "Show the sign-in link in the navbar.",
navbar_signin_color_dark: "Sign-in link color for dark mode. Leave blank for default.",
navbar_signin_color_light: "Sign-in link color for light mode.",
navbar_join_label: "Join button text. Use 'icon | Label' for FA icon before or 'Label | icon' for after (e.g. 'user-plus | Get Started').",
navbar_join_enabled: "Show the join/register button in the navbar.",
navbar_join_color_dark: "Join button background color for dark mode. Leave blank for accent color.",
navbar_join_color_light: "Join button background color for light mode.",
navbar_bg_color: "Custom navbar background when scrolled. Leave blank for frosted glass effect.",
navbar_border_style: "Border style at the bottom of the navbar when scrolled.",
social_twitter_url: "Twitter / X profile URL. Leave blank to hide. Icons appear in navbar before auth buttons.",
social_facebook_url: "Facebook page or profile URL. Leave blank to hide.",
social_instagram_url: "Instagram profile URL. Leave blank to hide.",
social_youtube_url: "YouTube channel URL. Leave blank to hide.",
social_tiktok_url: "TikTok profile URL. Leave blank to hide.",
social_github_url: "GitHub organization or profile URL. Leave blank to hide.",
// ── Hero ──
hero_title: "Main headline text in the hero section.",
hero_title_size: "Hero title font size in pixels. 0 = use default responsive size.",
hero_accent_word: "Which word gets the accent shimmer. 0 = last word, 1 = first, 2 = second, etc.",
hero_subtitle: "Supporting text below the headline. Describe your community's purpose.",
hero_card_enabled: "Display hero content inside a rounded card with border and shadow.",
hero_image_first: "Show hero image above text on mobile / left on desktop. Off = text first.",
hero_image_weight: "Image size weight (13). Higher values give the image more space relative to the text. Default: 1 (equal).",
hero_background_image_url: "Full-bleed background image behind the hero. In card mode, fills the card with overlay.",
hero_image_url: "Single hero image displayed on the right side of the hero. Use the upload button or paste a URL.",
hero_multiple_images_enabled: "Enable multiple hero images (up to 5) that rotate randomly on each page load. Disables the single image upload.",
hero_image_urls: "Images for the right side of the hero. Paste a URL and click Create to add. Up to 5 — one is shown randomly per page load.",
hero_image_max_height: "Maximum height for the hero image in pixels (1001200).",
hero_primary_button_enabled: "Show the primary CTA button in the hero.",
hero_primary_button_label: "Primary button text. Use 'icon | Label' for FA icon before or 'Label | icon' for after (e.g. 'rocket | Get Started').",
hero_primary_button_url: "URL the primary button links to. Relative path or absolute URL.",
hero_secondary_button_enabled: "Show the secondary CTA button in the hero.",
hero_secondary_button_label: "Secondary button text. Use 'icon | Label' for FA icon before or 'Label | icon' for after (e.g. 'arrow-right | Learn More').",
hero_secondary_button_url: "URL the secondary button links to.",
hero_primary_btn_color_dark: "Primary button background for dark mode. Leave blank for accent color.",
hero_primary_btn_color_light: "Primary button background for light mode.",
hero_secondary_btn_color_dark: "Secondary button background for dark mode. Leave blank for glass style.",
hero_secondary_btn_color_light: "Secondary button background for light mode.",
hero_video_upload: "Upload a video file to Discourse. Check your site's allowed file types and maximum file size in site settings before uploading.",
hero_video_url_enabled: "Use an external video URL instead of uploading a file.",
hero_video_url: "Hero video URL (MP4 or YouTube). Play button opens a lightbox modal.",
hero_video_button_color: "Custom color for the video play button. Leave blank for accent color.",
hero_video_blur_on_hover: "Blur the hero image when hovering the play button.",
hero_bg_dark: "Hero section background for dark mode. Leave blank for default.",
hero_bg_light: "Hero section background for light mode.",
hero_min_height: "Minimum hero section height in pixels. 0 = auto height.",
hero_border_style: "Border style at the bottom of the hero section.",
hero_card_bg_dark: "Hero card overlay background for dark mode. Only in card mode.",
hero_card_bg_light: "Hero card overlay background for light mode.",
hero_card_opacity: "Hero card background opacity (01). Lower = more transparent. Default: 0.85.",
// ── Contributors (Hero Creators) ──
contributors_enabled: "Show top 3 creators in the hero with gold, silver, bronze badges.",
contributors_title: "Heading above the creators list.",
contributors_title_enabled: "Show the heading above the creators list.",
contributors_count_label: "Label before each creator's count (e.g. 'Cheers'). Blank = no prefix.",
contributors_count_label_enabled: "Show the count label prefix before activity counts.",
contributors_alignment: "Horizontal alignment of the creators list: center or left.",
contributors_pill_max_width: "Max width per creator pill card in pixels (200600).",
contributors_pill_bg_dark: "Creator pill background for dark mode. Leave blank for glass styling.",
contributors_pill_bg_light: "Creator pill background for light mode.",
contributors_days: "Lookback period in days for calculating top contributors.",
contributors_count: "Number of top contributors to fetch (top 3 in hero, 410 in Participation).",
// ── Participation ──
participation_enabled: "Show Participation section: testimonial cards with leaderboard bios (positions 410).",
participation_title_enabled: "Show heading above participation cards.",
participation_title: "Heading text above participation cards. Use 'icon | Title' for FA icon (e.g. 'users | Participation').",
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 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.",
participation_bg_light: "Section background for light mode.",
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_topics_label: "Label for the Topics stat. Use 'icon | Label' for FA icon (e.g. 'file-lines | Topics'). Default SVG used when no icon specified.",
participation_posts_label: "Label for the Posts stat. Use 'icon | Label' for FA icon (e.g. 'comment | Posts'). Default SVG used when no icon specified.",
participation_likes_label: "Label for the Likes stat. Use 'icon | Label' for FA icon (e.g. 'heart | Likes'). Default SVG used when no icon specified.",
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.",
stat_labels_enabled: "Show text labels below stat counters (e.g. 'Members'). Off = numbers and icons only.",
stats_title_enabled: "Show section heading above the stats row.",
stats_title: "Section heading text above the stats. Use 'icon | Title' for FA icon (e.g. 'chart-bar | Premium Stats').",
stats_title_size: "Stats title font size in pixels. 0 = use default.",
stat_card_style: "Stat card style: rectangle, rounded, pill, or minimal (no background).",
stat_icon_color: "Color for stat counter icons.",
stat_icon_bg_color: "Background behind each stat icon. Leave blank for subtle accent tint.",
stat_icon_shape: "Icon background shape: circle or rounded square.",
stat_counter_color: "Color for stat counter numbers. Leave blank for default text color.",
stat_members_label: "Custom label for the Members stat.",
stat_topics_label: "Custom label for the Topics stat.",
stat_posts_label: "Custom label for the Posts stat.",
stat_likes_label: "Custom label for the Likes stat.",
stat_chats_label: "Custom label for the Chats stat. Shows chat messages if Chat plugin is active.",
stat_round_numbers: "Round large numbers: 1000 → 1K, 12345 → 12.3K, 1234567 → 1.2M.",
stat_card_bg_dark: "Stat card background for dark mode.",
stat_card_bg_light: "Stat card background for light mode.",
stats_bg_dark: "Section background for dark mode. Leave blank for default.",
stats_bg_light: "Section background for light mode.",
stats_min_height: "Minimum section height in pixels. 0 = auto.",
stats_border_style: "Border style at the bottom of the stats section.",
// ── About ──
about_enabled: "Show the About section: card with heading, quote icon, description, and author attribution.",
about_heading_enabled: "Show the bold heading at the top of the About card.",
about_heading: "Heading text at the top of the About card. Use 'icon | Title' for FA icon (e.g. 'info-circle | About Community').",
about_title: "Author or community name in the card's bottom attribution.",
about_title_size: "About heading font size in pixels. 0 = use default.",
about_role: "Subtitle below author name (e.g. 'Community Manager'). Blank = site name.",
about_body: "Main body text. Supports HTML: p, a, strong, em, ul, li, br.",
about_image_url: "Avatar image next to author name. Square images work best.",
about_card_color_dark: "About card background for dark mode.",
about_card_color_light: "About card background for light mode.",
about_background_image_url: "Background image on the card. Use a subtle pattern or texture.",
about_bg_dark: "Section background for dark mode. Leave blank for default.",
about_bg_light: "Section background for light mode.",
about_min_height: "Minimum section height in pixels. 0 = auto.",
about_border_style: "Border style at the bottom of the about section.",
// ── Trending ──
topics_enabled: "Show Trending Discussions: scrollable row of active topic cards with live data.",
topics_title_enabled: "Show heading above the topic cards.",
topics_title: "Heading text above the topic cards. Use 'icon | Title' for FA icon (e.g. 'fire | Trending Discussions').",
topics_title_size: "Trending title font size in pixels. 0 = use default.",
topics_count: "Number of trending topic cards to display.",
topics_card_bg_dark: "Topic card background for dark mode.",
topics_card_bg_light: "Topic card background for light mode.",
topics_bg_dark: "Section background for dark mode. Leave blank for default.",
topics_bg_light: "Section background for light mode.",
topics_min_height: "Minimum section height in pixels. 0 = auto.",
topics_border_style: "Border style at the bottom of the trending section.",
// ── Spaces ──
groups_enabled: "Show Community Spaces: grid of group cards with icon, name, and member count.",
groups_title_enabled: "Show heading above group cards.",
groups_title: "Heading text above group cards. Use 'icon | Title' for FA icon (e.g. 'layer-group | Community Spaces').",
groups_title_size: "Spaces title font size in pixels. 0 = use default.",
groups_count: "Number of group cards to display.",
groups_selected: "Show only specific groups. Enter names separated by pipes (e.g. designers|developers). Blank = auto-select.",
groups_show_description: "Show group description text below the group name on each card.",
groups_description_max_length: "Max characters for group descriptions (30500). Longer text is truncated.",
groups_card_bg_dark: "Space card background for dark mode.",
groups_card_bg_light: "Space card background for light mode.",
splits_background_image_url: "Background image for the splits section (Groups + FAQ container).",
splits_bg_dark: "Background color for the splits section (dark mode).",
splits_bg_light: "Background color for the splits section (light mode).",
splits_min_height: "Minimum height in pixels for the splits section. 0 = auto.",
// ── FAQ ──
faq_enabled: "Show FAQ accordion alongside the Spaces section. One item opens at a time.",
faq_title_enabled: "Show heading above the FAQ accordion.",
faq_title: "Heading text above the FAQ. Use 'icon | Title' for FA icon (e.g. 'circle-question | FAQ').",
faq_title_size: "FAQ title font size in pixels. 0 = use default.",
faq_items: 'FAQ items as JSON array: [{\"q\":\"Question\",\"a\":\"Answer\"}]. HTML supported in answers.',
faq_card_bg_dark: "FAQ card background for dark mode.",
faq_card_bg_light: "FAQ card background for light mode.",
faq_mobile_max_height: "Max height of FAQ container on mobile (px). Scrollable if content overflows. 0 = no limit.",
// ── App CTA ──
show_app_ctas: "Show App Download CTA: gradient banner with headline, badges, and promo image.",
ios_app_url: "Apple App Store URL. Leave blank to hide iOS badge.",
android_app_url: "Google Play Store URL. Leave blank to hide Android badge.",
ios_app_badge_image_url: "Custom iOS badge image. Leave blank for default.",
android_app_badge_image_url: "Custom Android badge image. Leave blank for default.",
app_badge_height: "Badge height in pixels (3080).",
app_badge_style: "Badge border-radius: rounded, pill, or square.",
app_cta_headline: "Bold headline in the app download banner. Use 'icon | Title' for FA icon (e.g. 'mobile-screen | Get the App').",
app_cta_title_size: "App CTA headline font size in pixels. 0 = use default.",
app_cta_subtext: "Supporting text below the headline.",
app_cta_gradient_start_dark: "Gradient start color for dark mode. Leave blank for accent.",
app_cta_gradient_start_light: "Gradient start color for light mode.",
app_cta_gradient_mid_dark: "Gradient middle color for dark mode.",
app_cta_gradient_mid_light: "Gradient middle color for light mode.",
app_cta_gradient_end_dark: "Gradient end color for dark mode.",
app_cta_gradient_end_light: "Gradient end color for light mode.",
app_cta_image_url: "Promo image on the right (e.g. phone mockup). PNG for transparent bg.",
app_cta_headline_color_dark: "Headline text color for dark mode. Default: white.",
app_cta_headline_color_light: "Headline text color for light mode. Default: dark navy.",
app_cta_subtext_color_dark: "Subtext color for dark mode. Default: white at 75%.",
app_cta_subtext_color_light: "Subtext color for light mode. Default: dark navy at 70%.",
app_cta_bg_dark: "Section background for dark mode. Leave blank for default.",
app_cta_bg_light: "Section background for light mode.",
app_cta_min_height: "Minimum section height in pixels. 0 = auto.",
app_cta_border_style: "Border style at the bottom of the app CTA section.",
// ── Footer ──
footer_description: "Description paragraph above the footer bar.",
footer_text: "Optional HTML text inside the footer bar. Supports: p, a, strong, em, ul, li, br.",
footer_links: 'Footer links as JSON array: [{\"label\":\"Terms\",\"url\":\"/tos\"}].',
footer_bg_dark: "Footer background for dark mode. Leave blank for default.",
footer_bg_light: "Footer background for light mode.",
footer_text_color_dark: "Footer text color for dark mode. Applies to site name, links, copyright, and description.",
footer_text_color_light: "Footer text color for light mode.",
footer_border_style: "Border style at the top of the footer bar.",
};
const TABS = [
{
id: "settings",
label: "Settings",
settings: new Set([
"community_landing_enabled",
"section_order", "custom_css",
"meta_description", "og_image_url", "favicon_url", "json_ld_enabled",
"logo_dark_url", "logo_light_url", "logo_height", "logo_use_accent_color", "footer_logo_url",
"accent_color", "accent_hover_color", "dark_bg_color", "light_bg_color",
"orb_color", "orb_opacity",
"scroll_animation", "staggered_reveal_enabled", "dynamic_background_enabled",
"mouse_parallax_enabled", "scroll_progress_enabled",
"google_font_name", "title_font_name", "icon_library",
"preloader_enabled", "preloader_logo_url",
"preloader_bg_dark", "preloader_bg_light",
"preloader_text_color_dark", "preloader_text_color_light",
"preloader_bar_color", "preloader_min_duration"
])
},
{
id: "navbar",
label: "Navbar",
settings: new Set([
"navbar_signin_label", "navbar_signin_enabled",
"navbar_signin_color_dark", "navbar_signin_color_light",
"navbar_join_label", "navbar_join_enabled",
"navbar_join_color_dark", "navbar_join_color_light",
"navbar_bg_color", "navbar_border_style",
"social_twitter_url", "social_facebook_url", "social_instagram_url",
"social_youtube_url", "social_tiktok_url", "social_github_url"
])
},
{
id: "hero",
label: "Hero",
settings: new Set([
"hero_title", "hero_accent_word", "hero_subtitle", "hero_title_size",
"hero_card_enabled", "hero_image_first", "hero_image_weight",
"hero_background_image_url", "hero_image_url", "hero_multiple_images_enabled", "hero_image_urls", "hero_image_max_height",
"hero_primary_button_enabled", "hero_primary_button_label", "hero_primary_button_url",
"hero_secondary_button_enabled", "hero_secondary_button_label", "hero_secondary_button_url",
"hero_primary_btn_color_dark", "hero_primary_btn_color_light",
"hero_secondary_btn_color_dark", "hero_secondary_btn_color_light",
"hero_video_upload", "hero_video_url_enabled", "hero_video_url",
"hero_video_button_color", "hero_video_blur_on_hover",
"hero_bg_dark", "hero_bg_light", "hero_min_height", "hero_border_style",
"hero_card_bg_dark", "hero_card_bg_light", "hero_card_opacity",
"contributors_enabled", "contributors_title", "contributors_title_enabled",
"contributors_count_label", "contributors_count_label_enabled",
"contributors_alignment", "contributors_pill_max_width",
"contributors_pill_bg_dark", "contributors_pill_bg_light",
"contributors_days", "contributors_count"
])
},
{
id: "participation",
label: "Participation",
settings: new Set([
"participation_enabled", "participation_title_enabled",
"participation_title", "participation_title_size",
"participation_bio_max_length",
"participation_topics_label", "participation_posts_label", "participation_likes_label",
"participation_icon_color",
"participation_card_bg_dark", "participation_card_bg_light",
"participation_bg_dark", "participation_bg_light",
"participation_min_height", "participation_border_style",
"participation_stat_color", "participation_stat_label_color",
"participation_bio_color", "participation_name_color",
"participation_meta_color"
])
},
{
id: "stats",
label: "Stats",
settings: new Set([
"stats_enabled", "stat_labels_enabled", "stats_title_enabled",
"stats_title", "stats_title_size", "stat_card_style",
"stat_icon_color", "stat_icon_bg_color", "stat_icon_shape", "stat_counter_color",
"stat_members_label", "stat_topics_label", "stat_posts_label",
"stat_likes_label", "stat_chats_label", "stat_round_numbers",
"stat_card_bg_dark", "stat_card_bg_light",
"stats_bg_dark", "stats_bg_light", "stats_min_height", "stats_border_style"
])
},
{
id: "about",
label: "About",
settings: new Set([
"about_enabled", "about_heading_enabled", "about_heading",
"about_title", "about_title_size", "about_role", "about_body", "about_image_url",
"about_card_color_dark", "about_card_color_light",
"about_background_image_url",
"about_bg_dark", "about_bg_light", "about_min_height", "about_border_style"
])
},
{
id: "topics",
label: "Topics",
settings: new Set([
"topics_enabled", "topics_title_enabled", "topics_title", "topics_title_size",
"topics_count",
"topics_card_bg_dark", "topics_card_bg_light",
"topics_bg_dark", "topics_bg_light", "topics_min_height", "topics_border_style"
])
},
{
id: "splits",
label: "Splits",
settings: new Set([
"splits_background_image_url",
"splits_bg_dark", "splits_bg_light", "splits_min_height",
"groups_enabled", "groups_title_enabled", "groups_title", "groups_title_size",
"groups_count", "groups_selected",
"groups_show_description", "groups_description_max_length",
"groups_card_bg_dark", "groups_card_bg_light",
"faq_enabled", "faq_title_enabled", "faq_title", "faq_title_size",
"faq_items",
"faq_card_bg_dark", "faq_card_bg_light",
"faq_mobile_max_height"
])
},
{
id: "appcta",
label: "App CTA",
settings: new Set([
"show_app_ctas", "ios_app_url", "android_app_url",
"ios_app_badge_image_url", "android_app_badge_image_url",
"app_badge_height", "app_badge_style",
"app_cta_headline", "app_cta_title_size", "app_cta_subtext",
"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_image_url",
"app_cta_headline_color_dark", "app_cta_headline_color_light",
"app_cta_subtext_color_dark", "app_cta_subtext_color_light",
"app_cta_bg_dark", "app_cta_bg_light", "app_cta_min_height", "app_cta_border_style"
])
},
{
id: "footer",
label: "Footer",
settings: new Set([
"footer_description", "footer_text", "footer_links",
"footer_bg_dark", "footer_bg_light",
"footer_text_color_dark", "footer_text_color_light",
"footer_border_style"
])
}
];
// 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 = {
hero: { setting: "contributors_enabled", label: "Contributors", onlySettings: [
"contributors_enabled", "contributors_title", "contributors_title_enabled",
"contributors_count_label", "contributors_count_label_enabled",
"contributors_alignment", "contributors_pill_max_width",
"contributors_pill_bg_dark", "contributors_pill_bg_light",
"contributors_days", "contributors_count"
]},
participation: { setting: "participation_enabled", label: "Participation" },
stats: { setting: "stats_enabled", label: "Stats" },
about: { setting: "about_enabled", label: "About" },
topics: { setting: "topics_enabled", label: "Topics" },
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_url: { label: "Upload Image", multi: false },
hero_video_upload: { label: "Upload Video", multi: false, accept: "video/*" },
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 },
splits_background_image_url: { label: "Upload Image", multi: false },
preloader_logo_url: { label: "Upload Logo", multi: false },
};
let currentTab = "settings";
let filterActive = false;
let isActive = false;
let recheckTimer = null;
let _siteSettings = null;
function getContainer() {
return (
document.querySelector(".admin-plugin-config-area") ||
document.querySelector(".admin-detail")
);
}
function applyTabFilter() {
const container = getContainer();
if (!container) return;
const tab = TABS.find((t) => t.id === currentTab);
if (!tab) return;
container.querySelectorAll(".row.setting[data-setting]").forEach((row) => {
const name = row.getAttribute("data-setting");
row.classList.toggle(
"cl-tab-hidden",
!filterActive && !tab.settings.has(name)
);
});
// Update filter-active dimming on native nav or standalone tab bar
const nativeNav = document.querySelector(".d-nav-submenu__tabs");
if (nativeNav) {
nativeNav.classList.toggle("cl-filter-active", filterActive);
}
const standaloneBar = document.querySelector(".cl-admin-tabs");
if (standaloneBar) {
standaloneBar.classList.toggle("filter-active", filterActive);
}
}
function updateActiveStates(activeId) {
// Native nav: our injected <li> tabs
document.querySelectorAll("li.cl-admin-tab").forEach((li) => {
li.classList.toggle("active", li.dataset.tab === 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) {
const isSettings = activeId === "settings";
nativeItem.classList.toggle("active", isSettings);
nativeItem.classList.toggle("cl-tab-inactive", !isSettings);
}
// Standalone fallback: <button> tabs
document.querySelectorAll("button.cl-admin-tab").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.tab === activeId);
});
}
function findFilterInput(container) {
for (const input of container.querySelectorAll("input")) {
if (input.closest(".row.setting") || input.closest(".cl-admin-tabs")) {
continue;
}
const t = (input.type || "text").toLowerCase();
if (t === "text" || t === "search") return input;
}
return null;
}
function handleTabClick(container, tabId) {
currentTab = tabId;
filterActive = false;
// Clear Discourse filter input so it doesn't conflict
const fi = findFilterInput(container);
if (fi && fi.value) {
fi.value = "";
fi.dispatchEvent(new Event("input", { bubbles: true }));
}
clearDisabledNotice(container);
updateActiveStates(tabId);
applyTabFilter();
applyConditionalVisibility(container);
injectUploadButtons();
updateDisabledNotice(container);
listenForConditionalToggles(container);
}
/**
* Remove all injected tabs and restore clean state.
* Called when navigating away from the community-landing settings page.
*/
function cleanupTabs() {
// Remove injected <li> tabs from native nav
document.querySelectorAll("li.cl-admin-tab").forEach((li) => li.remove());
// 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", "cl-tab-inactive");
}
// Remove filter-active class from native nav
const nativeNav = document.querySelector(".d-nav-submenu__tabs");
if (nativeNav) {
nativeNav.classList.remove("cl-filter-active");
}
// Remove standalone tab bar if present
const standaloneBar = document.querySelector(".cl-admin-tabs");
if (standaloneBar) {
standaloneBar.remove();
}
// Remove cl-tabs-active from container and un-hide all settings
const container = getContainer();
if (container) {
container.classList.remove("cl-tabs-active");
container.querySelectorAll(".cl-tab-hidden").forEach((el) => {
el.classList.remove("cl-tab-hidden");
});
// Remove disabled notices
clearDisabledNotice(container);
}
// Reset state
currentTab = "settings";
filterActive = false;
}
/**
* Inject description text into each setting row.
* The newer Discourse plugin admin page doesn't render .desc elements,
* so we add them from the DESCRIPTIONS map.
*/
function injectDescriptions() {
const container = getContainer();
if (!container) return;
container.querySelectorAll(".row.setting[data-setting]").forEach((row) => {
const name = row.getAttribute("data-setting");
const text = DESCRIPTIONS[name];
if (!text) return;
const valueDiv = row.querySelector(".setting-value");
if (!valueDiv) return;
// Already injected
if (valueDiv.querySelector(".cl-desc")) return;
const desc = document.createElement("div");
desc.className = "cl-desc";
desc.textContent = text;
valueDiv.appendChild(desc);
});
}
/**
* 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 cleanBooleanLabels() {
const container = getContainer();
if (!container) 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
// 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();
}
});
}
// 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-enable-label";
lbl.textContent = "Enable";
cb.insertAdjacentElement("afterend", lbl);
}
});
}
/**
* Read a boolean setting's current value from its DOM checkbox.
*/
function isSettingEnabled(container, settingName) {
const row = container.querySelector(`.row.setting[data-setting="${settingName}"]`);
if (!row) return true; // if row not found, assume enabled (safe default)
const cb = row.querySelector('input[type="checkbox"]');
if (!cb) return true;
return cb.checked;
}
/**
* Show or remove the "section disabled" notice for the current tab.
* When a section's master toggle is OFF, we hide all sub-settings (except
* the enable toggle itself) and show a notice prompting the user to enable it.
*/
function updateDisabledNotice(container) {
// Remove any existing notice
const existing = container.querySelector(".cl-disabled-notice");
if (existing) existing.remove();
const dep = TAB_ENABLE_SETTINGS[currentTab];
if (!dep) return; // tab has no dependency
const enabled = isSettingEnabled(container, dep.setting);
if (enabled) return; // section is on — nothing to do
// Determine which settings to hide (all tab settings except the enable toggle)
const tab = TABS.find((t) => t.id === currentTab);
if (!tab) return;
// For "hero" tab, only hide/dim the contributor sub-settings, not all hero settings
const affectedSettings = dep.onlySettings
? new Set(dep.onlySettings)
: tab.settings;
container.querySelectorAll(".row.setting[data-setting]").forEach((row) => {
const name = row.getAttribute("data-setting");
if (name === dep.setting) return; // keep the enable toggle visible
if (affectedSettings.has(name)) {
row.classList.add("cl-disabled-dim");
}
});
// Insert notice after the enable toggle row
const toggleRow = container.querySelector(`.row.setting[data-setting="${dep.setting}"]`);
if (!toggleRow) return;
const notice = document.createElement("div");
notice.className = "cl-disabled-notice";
notice.innerHTML =
'<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" style="flex-shrink:0">' +
'<path d="M8 1a7 7 0 100 14A7 7 0 008 1zm-.75 3.5a.75.75 0 011.5 0v4a.75.75 0 01-1.5 0v-4zm.75 7a.75.75 0 110-1.5.75.75 0 010 1.5z"/>' +
"</svg>" +
"<span><strong>" + dep.label + "</strong> is currently disabled. " +
"Enable it above to configure these settings.</span>";
toggleRow.insertAdjacentElement("afterend", notice);
}
/**
* Remove disabled-notice and dim classes.
*/
function clearDisabledNotice(container) {
const notice = container.querySelector(".cl-disabled-notice");
if (notice) notice.remove();
container.querySelectorAll(".cl-disabled-dim").forEach((el) => {
el.classList.remove("cl-disabled-dim");
});
}
function buildTabsUI() {
const container = getContainer();
if (!container) return false;
// Already injected — just re-apply filter
if (document.querySelector(".cl-admin-tab")) {
applyTabFilter();
return true;
}
const allRows = container.querySelectorAll(".row.setting[data-setting]");
if (allRows.length < 5) return false;
// Verify our plugin settings are present
const hasOurs = Array.from(allRows).some((row) =>
TABS[0].settings.has(row.getAttribute("data-setting"))
);
if (!hasOurs) return false;
// ── Strategy 1: Inject into native Discourse nav tab bar ──
// Native structure: <ul class="nav-pills action-list d-nav-submenu__tabs">
// <li class="admin-plugin-config-page__top-nav-item"><a>Settings</a></li>
const nativeTabsList = document.querySelector(".d-nav-submenu__tabs");
if (nativeTabsList) {
// Hook the native "Settings" <li> so clicking it activates our Settings tab
const nativeSettingsItem = nativeTabsList.querySelector(
".admin-plugin-config-page__top-nav-item"
);
if (nativeSettingsItem) {
nativeSettingsItem.classList.add("cl-native-settings-item");
const nativeLink = nativeSettingsItem.querySelector("a");
if (nativeLink) {
nativeLink.addEventListener("click", (e) => {
e.preventDefault();
handleTabClick(container, "settings");
});
}
}
// Inject our section tabs as <li> items (skip "settings" — native handles it)
TABS.forEach((tab) => {
if (tab.id === "settings") return;
const li = document.createElement("li");
li.className = "admin-plugin-config-page__top-nav-item cl-admin-tab";
li.dataset.tab = tab.id;
li.title = tab.label;
const a = document.createElement("a");
a.textContent = tab.label;
a.addEventListener("click", (e) => {
e.preventDefault();
handleTabClick(container, tab.id);
});
li.appendChild(a);
nativeTabsList.appendChild(li);
});
container.classList.add("cl-tabs-active");
injectDescriptions();
cleanBooleanLabels();
injectUploadButtons();
applyTabFilter();
applyConditionalVisibility(container);
updateDisabledNotice(container);
listenForEnableToggles(container);
listenForConditionalToggles(container);
return true;
}
// ── Strategy 2 (fallback): Standalone tab bar for older Discourse ──
const tabBar = document.createElement("div");
tabBar.className = "cl-admin-tabs";
TABS.forEach((tab) => {
const btn = document.createElement("button");
btn.className = "cl-admin-tab" + (tab.id === currentTab ? " active" : "");
btn.textContent = tab.label;
btn.dataset.tab = tab.id;
btn.addEventListener("click", () => handleTabClick(container, tab.id));
tabBar.appendChild(btn);
});
let inserted = false;
const contentArea = container.querySelector(
".admin-plugin-config-area__content"
);
if (contentArea) {
const form = contentArea.querySelector("form");
const target = form || contentArea;
target.insertBefore(tabBar, target.firstChild);
inserted = true;
}
if (!inserted) {
const filterArea = container.querySelector(
".admin-site-settings-filter-controls, .setting-filter"
);
if (filterArea) {
filterArea.parentNode.insertBefore(tabBar, filterArea);
inserted = true;
}
}
if (!inserted) {
allRows[0].parentNode.insertBefore(tabBar, allRows[0]);
}
container.classList.add("cl-tabs-active");
injectDescriptions();
cleanBooleanLabels();
injectUploadButtons();
applyTabFilter();
applyConditionalVisibility(container);
updateDisabledNotice(container);
listenForEnableToggles(container);
listenForConditionalToggles(container);
return true;
}
/**
* Listen for changes on section-enable checkboxes so the notice
* updates live when the user toggles a section on or off.
*/
function listenForEnableToggles(container) {
Object.values(TAB_ENABLE_SETTINGS).forEach(({ setting }) => {
const row = container.querySelector(`.row.setting[data-setting="${setting}"]`);
if (!row) return;
const cb = row.querySelector('input[type="checkbox"]');
if (!cb || cb.dataset.clToggleListening) return;
cb.dataset.clToggleListening = "1";
cb.addEventListener("change", () => {
clearDisabledNotice(container);
updateDisabledNotice(container);
});
});
}
// ── Conditional Visibility: Hero Single vs Multi Image ──
// Toggle groups: each toggle shows one set of settings and hides the other.
const CONDITIONAL_TOGGLES = [
{
toggle: "hero_multiple_images_enabled",
whenOff: ["hero_image_url"],
whenOn: ["hero_image_urls"],
},
{
toggle: "hero_video_url_enabled",
whenOff: ["hero_video_upload"],
whenOn: ["hero_video_url"],
},
];
function applyConditionalVisibility(container) {
const tab = TABS.find((t) => t.id === currentTab);
CONDITIONAL_TOGGLES.forEach((group) => {
const toggleRow = container.querySelector(
`.row.setting[data-setting="${group.toggle}"]`
);
if (!toggleRow) return;
const cb = toggleRow.querySelector('input[type="checkbox"]');
const isOn = cb ? cb.checked : false;
group.whenOff.forEach((name) => {
const row = container.querySelector(`.row.setting[data-setting="${name}"]`);
if (!row) return;
// Only unhide if this setting belongs to the current tab
const inTab = tab && tab.settings.has(name);
row.classList.toggle("cl-tab-hidden", !inTab || isOn);
});
group.whenOn.forEach((name) => {
const row = container.querySelector(`.row.setting[data-setting="${name}"]`);
if (!row) return;
const inTab = tab && tab.settings.has(name);
row.classList.toggle("cl-tab-hidden", !inTab || !isOn);
});
});
// Check if file attachments are allowed for video upload toggle
injectVideoUploadNotice(container);
}
function injectVideoUploadNotice(container) {
const toggleRow = container.querySelector(
'.row.setting[data-setting="hero_video_upload"]'
);
if (!toggleRow) return;
if (toggleRow.dataset.clVideoNoticeInjected) return;
toggleRow.dataset.clVideoNoticeInjected = "1";
// Check Discourse site settings for file attachment support
const authorizedExtensions = (
(_siteSettings && _siteSettings.authorized_extensions) || ""
).toLowerCase();
const attachmentsAllowed =
authorizedExtensions.includes("mp4") ||
authorizedExtensions.includes("webm") ||
authorizedExtensions.includes("mov") ||
authorizedExtensions.includes("*");
const notice = document.createElement("div");
notice.className = "cl-upload-notice";
if (!attachmentsAllowed) {
notice.classList.add("cl-upload-notice--warn");
notice.textContent =
"Video file uploads require video extensions (mp4, webm, mov) to be added to your site\u2019s authorized extensions in Settings \u2192 Files.";
// Disable the upload button if injected
const uploadBtn = toggleRow.querySelector(".cl-upload-btn");
if (uploadBtn) uploadBtn.disabled = true;
} else {
const maxSize = _siteSettings && _siteSettings.max_attachment_size_kb;
const maxMB = maxSize ? (maxSize / 1024).toFixed(0) : "?";
notice.textContent =
`Check allowed video file types and max upload size (${maxMB} MB) in Settings \u2192 Files before uploading.`;
}
const valueDiv = toggleRow.querySelector(".setting-value") || toggleRow;
valueDiv.appendChild(notice);
}
function listenForConditionalToggles(container) {
CONDITIONAL_TOGGLES.forEach((group) => {
const toggleRow = container.querySelector(
`.row.setting[data-setting="${group.toggle}"]`
);
if (!toggleRow) return;
const cb = toggleRow.querySelector('input[type="checkbox"]');
if (!cb || cb.dataset.clConditionalListening) return;
cb.dataset.clConditionalListening = "1";
cb.addEventListener("change", () => {
applyConditionalVisibility(container);
});
});
}
// ── Image Upload Helpers ──
function getCsrfToken() {
const meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute("content") : "";
}
async function uploadFile(file, { isVideo = false } = {}) {
console.log("[CL Upload] Starting upload:", {
name: file.name,
type: file.type,
size: `${(file.size / 1024 / 1024).toFixed(2)} MB`,
isVideo,
});
const formData = new FormData();
formData.append("file", file);
if (isVideo) {
// Discourse rejects non-image files when for_site_setting is true
formData.append("upload_type", "composer");
} else {
formData.append("upload_type", "site_setting");
formData.append("for_site_setting", "true");
}
formData.append("synchronous_uploads", "true");
const csrfToken = getCsrfToken();
console.log("[CL Upload] CSRF token present:", !!csrfToken);
const response = await fetch("/uploads.json", {
method: "POST",
headers: { "X-CSRF-Token": csrfToken },
body: formData,
});
console.log("[CL Upload] Response status:", response.status, response.statusText);
if (!response.ok) {
const text = await response.text();
console.error("[CL Upload] Error response body:", text);
throw new Error(`Upload failed (${response.status}): ${text}`);
}
const data = await response.json();
console.log("[CL Upload] Success:", { id: data.id, url: data.url, short_url: data.short_url });
return data;
}
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) {
const input =
row.querySelector(".setting-value textarea") ||
row.querySelector('.setting-value input[type="text"]');
if (!input) return;
if (multi) {
// hero_image_urls is a textarea — append URL on a new line
const current = input.value.trim();
input.value = current ? current + "\n" + newValue : newValue;
} else {
input.value = newValue;
}
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { 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 cfg = IMAGE_UPLOAD_SETTINGS[settingName];
if (cfg && cfg.multi) {
// Multi-image: render a sortable image list
renderMultiImageList(wrapper, row, input, settingName);
// Hide single preview if it exists
const preview = wrapper.querySelector(".cl-upload-preview");
if (preview) preview.style.display = "none";
} else {
const isVideo = cfg && (cfg.accept || "").includes("video");
if (isVideo) {
// Video: show text label instead of broken img preview
let label = wrapper.querySelector(".cl-upload-preview-label");
if (!label) {
label = document.createElement("span");
label.className = "cl-upload-preview-label";
// Insert before remove button if it exists
const removeBtn = wrapper.querySelector(".cl-upload-remove");
if (removeBtn) {
wrapper.insertBefore(label, removeBtn);
} else {
wrapper.appendChild(label);
}
}
label.textContent = url ? "Video uploaded" : "";
label.style.display = url ? "" : "none";
// Hide img preview if exists
const preview = wrapper.querySelector(".cl-upload-preview");
if (preview) preview.style.display = "none";
} else {
const preview = wrapper.querySelector(".cl-upload-preview");
if (!preview) return;
preview.src = url;
preview.style.display = url ? "" : "none";
}
}
}
function renderMultiImageList(wrapper, row, input, settingName) {
let list = wrapper.querySelector(".cl-multi-image-list");
if (!list) {
list = document.createElement("div");
list.className = "cl-multi-image-list";
wrapper.appendChild(list);
}
const raw = input ? input.value.trim() : "";
const urls = raw.split(/[\n\r]+/).map((u) => u.trim()).filter(Boolean);
list.innerHTML = "";
if (urls.length === 0) return;
urls.forEach((url, idx) => {
const item = document.createElement("div");
item.className = "cl-multi-image-item";
item.draggable = true;
item.dataset.idx = idx;
const thumb = document.createElement("img");
thumb.src = url;
thumb.alt = `Image ${idx + 1}`;
thumb.className = "cl-multi-image-thumb";
item.appendChild(thumb);
const label = document.createElement("span");
label.className = "cl-multi-image-label";
label.textContent = `${idx + 1}`;
item.appendChild(label);
const actions = document.createElement("span");
actions.className = "cl-multi-image-actions";
// Move up
if (idx > 0) {
const upBtn = document.createElement("button");
upBtn.type = "button";
upBtn.className = "cl-multi-image-move";
upBtn.innerHTML = "&#9650;";
upBtn.title = "Move up";
upBtn.addEventListener("click", () => {
[urls[idx - 1], urls[idx]] = [urls[idx], urls[idx - 1]];
input.value = urls.join("\n");
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
renderMultiImageList(wrapper, row, input, settingName);
});
actions.appendChild(upBtn);
}
// Move down
if (idx < urls.length - 1) {
const downBtn = document.createElement("button");
downBtn.type = "button";
downBtn.className = "cl-multi-image-move";
downBtn.innerHTML = "&#9660;";
downBtn.title = "Move down";
downBtn.addEventListener("click", () => {
[urls[idx], urls[idx + 1]] = [urls[idx + 1], urls[idx]];
input.value = urls.join("\n");
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
renderMultiImageList(wrapper, row, input, settingName);
});
actions.appendChild(downBtn);
}
// Remove
const removeBtn = document.createElement("button");
removeBtn.type = "button";
removeBtn.className = "cl-multi-image-remove";
removeBtn.innerHTML = "&times;";
removeBtn.title = "Remove image";
removeBtn.addEventListener("click", () => {
urls.splice(idx, 1);
input.value = urls.join("\n");
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
renderMultiImageList(wrapper, row, input, settingName);
});
actions.appendChild(removeBtn);
item.appendChild(actions);
// Drag-and-drop reorder
item.addEventListener("dragstart", (e) => {
e.dataTransfer.setData("text/plain", idx.toString());
item.classList.add("cl-dragging");
});
item.addEventListener("dragend", () => {
item.classList.remove("cl-dragging");
});
item.addEventListener("dragover", (e) => {
e.preventDefault();
item.classList.add("cl-drag-over");
});
item.addEventListener("dragleave", () => {
item.classList.remove("cl-drag-over");
});
item.addEventListener("drop", (e) => {
e.preventDefault();
item.classList.remove("cl-drag-over");
const fromIdx = parseInt(e.dataTransfer.getData("text/plain"), 10);
const toIdx = idx;
if (fromIdx === toIdx) return;
const [moved] = urls.splice(fromIdx, 1);
urls.splice(toIdx, 0, moved);
input.value = urls.join("\n");
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
renderMultiImageList(wrapper, row, input, settingName);
});
list.appendChild(item);
});
}
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 = cfg.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 isVideo = (cfg.accept || "").includes("video");
const data = await uploadFile(file, { isVideo });
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(
"input",
(e) => {
if (!isActive) return;
const t = e.target;
if (!t || !t.closest) return;
if (t.closest(".row.setting") || t.closest(".cl-admin-tabs")) return;
const container = getContainer();
if (!container || !container.contains(t)) return;
const hasText = t.value.trim().length > 0;
if (hasText !== filterActive) {
filterActive = hasText;
applyTabFilter();
}
},
true
);
export default {
name: "community-landing-admin-tabs",
initialize() {
withPluginApi("1.0", (api) => {
_siteSettings = api.container.lookup("service:site-settings");
api.onPageChange((url) => {
if (
url.includes("community-landing") ||
url.includes("community_landing")
) {
isActive = true;
filterActive = false;
// Initial injection with retries
let attempts = 0;
const tryInject = () => {
if (!isActive) return; // Guard: user navigated away during retries
if (buildTabsUI() || attempts > 15) return;
attempts++;
setTimeout(tryInject, 200);
};
tryInject();
// Periodic re-check: re-injects tabs if Discourse re-renders the DOM
if (!recheckTimer) {
recheckTimer = setInterval(() => {
if (!isActive) {
clearInterval(recheckTimer);
recheckTimer = null;
return;
}
const c = getContainer();
if (c && !document.querySelector(".cl-admin-tab")) {
buildTabsUI();
}
}, 500);
}
} else {
// Left plugin settings page — clean up injected tabs
if (isActive) {
cleanupTabs();
}
isActive = false;
if (recheckTimer) {
clearInterval(recheckTimer);
recheckTimer = null;
}
}
});
});
},
};