FEATURE: fonts section for branding page (#32031)

New configure fonts section was added. Because now we have two sections
completed (logos and fonts), new /branding page was introduced and old
/logo and /font pages was removed.

When text size is changed, modal is displayed to ask if preferences of
existing users should be retrospectively updated.



https://github.com/user-attachments/assets/f6b0c92a-117f-4064-bd76-30fa05acc6d3

---------

Co-authored-by: Ella <ella.estigoy@gmail.com>
Co-authored-by: Alan Guo Xiang Tan <gxtan1990@gmail.com>
This commit is contained in:
Krzysztof Kotlarek
2025-04-07 10:28:42 +08:00
committed by GitHub
parent 637a221517
commit 928f9175f0
31 changed files with 944 additions and 286 deletions

View File

@ -0,0 +1,63 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { concat, fn } from "@ember/helper";
import { action } from "@ember/object";
import { dasherize } from "@ember/string";
import DButton from "discourse/components/d-button";
import concatClass from "discourse/helpers/concat-class";
import { i18n } from "discourse-i18n";
import { MAIN_FONTS, MORE_FONTS } from "admin/lib/constants";
import eq from "truth-helpers/helpers/eq";
export default class AdminBrandingFontChooser extends Component {
@tracked showMoreFonts = MORE_FONTS.map((font) => font.key).includes(
this.args.selectedFont
);
@action
setButtonValue(fieldSet, value) {
fieldSet(value);
}
@action
toggleMoreFonts() {
this.showMoreFonts = !this.showMoreFonts;
}
<template>
<@field.Custom>
{{#each MAIN_FONTS as |font|}}
<DButton
@action={{fn this.setButtonValue @field.set font.key}}
class={{concatClass
"admin-fonts-form__button-option font btn-flat"
(concat "body-font-" (dasherize font.key))
(if (eq @selectedFont font.key) "active")
}}
>{{font.name}}</DButton>
{{/each}}
{{#if this.showMoreFonts}}
{{#each MORE_FONTS as |font|}}
<DButton
@action={{fn this.setButtonValue @field.set font.key}}
class={{concatClass
"admin-fonts-form__button-option font btn-flat"
(concat "body-font-" (dasherize font.key))
(if (eq @selectedFont font.key) "active")
}}
>{{font.name}}</DButton>
{{/each}}
{{/if}}
<DButton
@action={{this.toggleMoreFonts}}
class="admin-fonts-form__more font"
>
{{#if this.showMoreFonts}}
{{i18n "admin.config.branding.fonts.form.less_fonts"}}
{{else}}
{{i18n "admin.config.branding.fonts.form.more_fonts"}}
{{/if}}
</DButton>
</@field.Custom>
</template>
}

View File

@ -0,0 +1,163 @@
import Component from "@glimmer/component";
import { fn } from "@ember/helper";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { decamelize, underscore } from "@ember/string";
import DButton from "discourse/components/d-button";
import Form from "discourse/components/form";
import UpdateDefaultTextSize from "discourse/components/modal/update-default-text-size";
import concatClass from "discourse/helpers/concat-class";
import { ajax } from "discourse/lib/ajax";
import { bind } from "discourse/lib/decorators";
import { i18n } from "discourse-i18n";
import AdminBrandingFontChooser from "admin/components/admin-branding-font-chooser";
import { DEFAULT_TEXT_SIZES } from "admin/lib/constants";
import eq from "truth-helpers/helpers/eq";
export default class AdminBrandingFontsForm extends Component {
@service siteSettings;
@service siteSettingChangeTracker;
@service toasts;
@service modal;
@service router;
updateExistingUsers = null;
@bind
setUpdateExistingUsers(value) {
this.updateExistingUsers = value;
}
@action
setButtonValue(fieldSet, value) {
fieldSet(decamelize(underscore(value)));
}
@action
async update(data) {
if (this.siteSettings.default_text_size === data.default_text_size) {
await this.#save(data);
return;
}
const result = await ajax(
`/admin/site_settings/default_text_size/user_count.json`,
{
type: "PUT",
data: {
default_text_size: data.default_text_size,
},
}
);
const count = result.user_count;
if (count > 0) {
await this.modal.show(UpdateDefaultTextSize, {
model: {
setUpdateExistingUsers: this.setUpdateExistingUsers,
count,
},
});
await this.#save(data);
} else {
await this.#save(data);
}
}
@action
async #save(data) {
try {
await ajax("/admin/config/branding/fonts.json", {
type: "PUT",
data: {
base_font: data.base_font,
heading_font: data.heading_font,
default_text_size: data.default_text_size,
update_existing_users: this.updateExistingUsers,
},
});
this.toasts.success({
duration: 3000,
data: {
message: i18n("admin.config.branding.fonts.form.saved"),
},
});
this.siteSettingChangeTracker.refreshPage();
} catch (err) {
this.toasts.error({
duration: 3000,
data: {
message: err.jqXHR.responseJSON.errors[0],
},
});
}
}
get formData() {
return {
base_font: this.siteSettings.base_font,
heading_font: this.siteSettings.heading_font,
default_text_size: this.siteSettings.default_text_size,
};
}
<template>
<Form
@onSubmit={{this.update}}
@data={{this.formData}}
class="admin-fonts-form"
as |form transientData|
>
<form.Field
@name="base_font"
@title={{i18n "admin.config.branding.fonts.form.base_font.title"}}
@validation="required"
@format="full"
as |field|
>
<AdminBrandingFontChooser
@field={{field}}
@selectedFont={{transientData.base_font}}
/>
</form.Field>
<form.Field
@name="heading_font"
@title={{i18n "admin.config.branding.fonts.form.heading_font.title"}}
@validation="required"
@format="full"
as |field|
>
<AdminBrandingFontChooser
@field={{field}}
@selectedFont={{transientData.heading_font}}
/>
</form.Field>
<form.Field
@name="default_text_size"
@title={{i18n
"admin.config.branding.fonts.form.default_text_size.title"
}}
@description={{i18n
"admin.config.branding.fonts.form.default_text_size.description"
}}
@validation="required"
@format="full"
as |field|
>
<field.Custom>
{{#each DEFAULT_TEXT_SIZES as |textSize|}}
<DButton
@action={{fn this.setButtonValue field.set textSize}}
class={{concatClass
"admin-fonts-form__button-option text-size btn-flat"
textSize
(if (eq transientData.default_text_size textSize) "active")
}}
>{{textSize}}</DButton>
{{/each}}
</field.Custom>
</form.Field>
<form.Submit />
</Form>
</template>
}

View File

@ -14,6 +14,7 @@ import SimpleList from "admin/components/simple-list";
export default class AdminBrandingLogoForm extends Component {
@service siteSettings;
@service siteSettingChangeTracker;
@service toasts;
@tracked placeholders = {};
@ -81,6 +82,7 @@ export default class AdminBrandingLogoForm extends Component {
message: i18n("admin.config.branding.logo.form.saved"),
},
});
this.siteSettingChangeTracker.refreshPage();
} catch (err) {
this.toasts.error({
duration: 3000,

View File

@ -1,3 +0,0 @@
import AdminAreaSettingsBaseController from "admin/controllers/admin-area-settings-base";
export default class AdminConfigFontsSettingsController extends AdminAreaSettingsBaseController {}

View File

@ -82,3 +82,43 @@ export const DEFAULT_USER_PREFERENCES = [
"default_sidebar_link_to_filtered_list",
"default_sidebar_show_count_of_new_items",
];
export const MAIN_FONTS = [
{ key: "open_sans", name: "Open Sans" },
{ key: "roboto", name: "Roboto" },
{ key: "lato", name: "Lato" },
{ key: "inter", name: "Inter" },
{ key: "montserrat", name: "Montserrat" },
{ key: "poppins", name: "Poppins" },
{ key: "merriweather", name: "Merriweather" },
{ key: "mukta", name: "Mukta" },
{ key: "helvetica", name: "Helvetica" },
];
export const MORE_FONTS = [
{ key: "arial", name: "Arial" },
{ key: "system", name: "System" },
{ key: "oxanium", name: "Oxanium" },
{ key: "noto_sans_jp", name: "NotoSansJP" },
{ key: "roboto_condensed", name: "RobotoCondensed" },
{ key: "source_sans_pro", name: "SourceSansPro" },
{ key: "oswald", name: "Oswald" },
{ key: "raleway", name: "Raleway" },
{ key: "roboto_mono", name: "RobotoMono" },
{ key: "noto_sans", name: "NotoSans" },
{ key: "roboto_slab", name: "RobotoSlab" },
{ key: "ubuntu", name: "Ubuntu" },
{ key: "pt_sans", name: "PTSans" },
{ key: "playfair_display", name: "PlayfairDisplay" },
{ key: "nunito", name: "Nunito" },
{ key: "lora", name: "Lora" },
{ key: "jet_brains_mono", name: "JetBrains Mono" },
];
export const DEFAULT_TEXT_SIZES = [
"smallest",
"smaller",
"normal",
"larger",
"largest",
];

View File

@ -209,8 +209,9 @@ export default Mixin.create({
this.setting.validationMessage = null;
this.buffered.applyChanges();
if (this.setting.requiresReload) {
this.afterSave();
this.siteSettingChangeTracker.refreshPage();
}
} catch (e) {
const json = e.jqXHR?.responseJSON;

View File

@ -11,7 +11,13 @@ import {
} from "admin/lib/constants";
import SettingObjectHelper from "admin/lib/setting-object-helper";
const AUTO_REFRESH_ON_SAVE = ["logo", "logo_small", "large_icon"];
const AUTO_REFRESH_ON_SAVE = [
"logo",
"mobile_logo",
"base_font",
"heading_font",
"default_text_size",
];
export default class SiteSetting extends EmberObject {
static findAll(params = {}) {

View File

@ -0,0 +1,8 @@
import DiscourseRoute from "discourse/routes/discourse";
import { i18n } from "discourse-i18n";
export default class AdminConfigBrandingRoute extends DiscourseRoute {
titleToken() {
return i18n("admin.config.branding.title");
}
}

View File

@ -1,8 +0,0 @@
import { i18n } from "discourse-i18n";
import AdminConfigWithSettingsRoute from "./admin-config-with-settings-route";
export default class AdminConfigFontsRoute extends AdminConfigWithSettingsRoute {
titleToken() {
return i18n("admin.config.font_style.title");
}
}

View File

@ -1,8 +0,0 @@
import { i18n } from "discourse-i18n";
import AdminConfigWithSettingsRoute from "./admin-config-with-settings-route";
export default class AdminConfigLogoRoute extends AdminConfigWithSettingsRoute {
titleToken() {
return i18n("admin.config.logo.title");
}
}

View File

@ -311,12 +311,6 @@ export default function () {
this.route("developer", function () {
this.route("settings", { path: "/" });
});
this.route("fonts", function () {
this.route("settings", { path: "/" });
});
this.route("logo", function () {
this.route("settings", { path: "/" });
});
this.route("branding");
this.route("navigation", function () {
this.route("settings", { path: "/" });

View File

@ -4,12 +4,16 @@ import { TrackedSet } from "@ember-compat/tracked-built-ins";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { i18n } from "discourse-i18n";
import { DEFAULT_TEXT_SIZES } from "admin/lib/constants";
import SiteSetting from "admin/models/site-setting";
import SiteSettingDefaultCategoriesModal from "../components/modal/site-setting-default-categories";
export default class SiteSettingChangeTracker extends Service {
@service dialog;
@service modal;
@service session;
@service site;
@service siteSettings;
@tracked dirtySiteSettings = new TrackedSet();
@ -181,6 +185,53 @@ export default class SiteSettingChangeTracker extends Service {
});
}
refreshPage() {
document.documentElement.style.setProperty(
"--font-family",
this.siteSettings.base_font
);
document.documentElement.style.setProperty(
"--heading-font-family",
this.siteSettings.heading_font
);
DEFAULT_TEXT_SIZES.forEach((size) => {
document.documentElement.classList.remove(`text-size-${size}`);
});
document.documentElement.classList.add(
`text-size-${this.siteSettings.default_text_size}`
);
let logo;
if (this.site.mobileView) {
if (
this.session.defaultColorSchemeIsDark ||
this.session.darkModeAvailable
) {
logo = this.siteSettings.mobile_logo_dark;
} else {
logo = this.siteSettings.mobile_logo;
}
}
if (!logo && this.session.defaultColorSchemeIsDark) {
logo = this.siteSettings.logo_dark;
}
if (!logo) {
logo = this.siteSettings.logo;
}
// Force reload when switching from text logo to image logo and vice versa
if (
(!this.siteSettings.logo && document.getElementById("site-logo")) ||
(this.siteSettings.logo && !document.getElementById("site-logo"))
) {
window.location.reload();
} else {
document.getElementById("site-logo").setAttribute("src", logo);
}
}
get count() {
return this.dirtySiteSettings.size;
}

View File

@ -2,6 +2,7 @@ import RouteTemplate from "ember-route-template";
import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
import DPageHeader from "discourse/components/d-page-header";
import { i18n } from "discourse-i18n";
import AdminBrandingFontsForm from "admin/components/admin-branding-fonts-form";
import AdminBrandingLogoForm from "admin/components/admin-branding-logo-form";
import AdminConfigAreaCard from "admin/components/admin-config-area-card";
@ -33,6 +34,19 @@ export default RouteTemplate(
</AdminConfigAreaCard>
</div>
</div>
<div class="admin-config-area">
<div class="admin-config-area__primary-content">
<AdminConfigAreaCard
@heading="admin.config.branding.fonts.title"
@collapsable={{true}}
class="admin-config-area-branding__fonts"
>
<:content>
<AdminBrandingFontsForm />
</:content>
</AdminConfigAreaCard>
</div>
</div>
</div>
</template>
);

View File

@ -1,33 +0,0 @@
import RouteTemplate from "ember-route-template";
import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
import DPageHeader from "discourse/components/d-page-header";
import { i18n } from "discourse-i18n";
import AdminAreaSettings from "admin/components/admin-area-settings";
export default RouteTemplate(
<template>
<DPageHeader
@hideTabs={{true}}
@titleLabel={{i18n "admin.config.font_style.title"}}
@descriptionLabel={{i18n "admin.config.font_style.header_description"}}
>
<:breadcrumbs>
<DBreadcrumbsItem @path="/admin" @label={{i18n "admin_title"}} />
<DBreadcrumbsItem
@path="/admin/config/fonts"
@label={{i18n "admin.config.font_style.title"}}
/>
</:breadcrumbs>
</DPageHeader>
<div class="admin-config-page__main-area">
<AdminAreaSettings
@showBreadcrumb={{false}}
@area="fonts"
@path="/admin/config/fonts"
@filter={{@controller.filter}}
@adminSettingsFilterChangedCallback={{@controller.adminSettingsFilterChangedCallback}}
/>
</div>
</template>
);

View File

@ -1,34 +0,0 @@
import RouteTemplate from "ember-route-template";
import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
import DPageHeader from "discourse/components/d-page-header";
import { i18n } from "discourse-i18n";
import AdminBrandingLogoForm from "admin/components/admin-branding-logo-form";
import AdminConfigAreaCard from "admin/components/admin-config-area-card";
export default RouteTemplate(
<template>
<DPageHeader
@hideTabs={{true}}
@titleLabel={{i18n "admin.config.logo.title"}}
@descriptionLabel={{i18n "admin.config.logo.header_description"}}
>
<:breadcrumbs>
<DBreadcrumbsItem @path="/admin" @label={{i18n "admin_title"}} />
<DBreadcrumbsItem
@path="/admin/config/logo"
@label={{i18n "admin.config.logo.title"}}
/>
</:breadcrumbs>
</DPageHeader>
<AdminConfigAreaCard
@heading="admin.config.branding.logo.title"
@collapsable={{true}}
class="admin-config-area-branding__logo"
>
<:content>
<AdminBrandingLogoForm />
</:content>
</AdminConfigAreaCard>
</template>
);

View File

@ -1,5 +1,4 @@
import RouteTemplate from "ember-route-template";
import routeAction from "discourse/helpers/route-action";
import { i18n } from "discourse-i18n";
import SiteSetting from "admin/components/site-setting";
@ -8,10 +7,7 @@ export default RouteTemplate(
{{#if @controller.filteredContent}}
<section class="form-horizontal settings">
{{#each @controller.filteredContent as |setting|}}
<SiteSetting
@setting={{setting}}
@afterSave={{routeAction "refreshAll"}}
/>
<SiteSetting @setting={{setting}} />
{{/each}}
{{#if @controller.category.hasMore}}
<p class="warning">{{i18n

View File

@ -0,0 +1,44 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import DButton from "discourse/components/d-button";
import DModal from "discourse/components/d-modal";
import { i18n } from "discourse-i18n";
export default class UpdateDefaultTextSize extends Component {
@action
updateExistingUsers() {
this.args.model.setUpdateExistingUsers(true);
this.args.closeModal();
}
@action
cancel() {
this.args.model.setUpdateExistingUsers(null);
this.args.closeModal();
}
<template>
<DModal
@title={{i18n "admin.config.branding.fonts.backfill_modal.title"}}
@closeModal={{@closeModal}}
>
<:body>
{{i18n
"admin.config.branding.fonts.backfill_modal.description"
count=@model.count
}}
</:body>
<:footer>
<DButton
@action={{this.updateExistingUsers}}
@label="admin.config.branding.fonts.backfill_modal.modal_yes"
class="btn-primary"
/>
<DButton
@action={{this.cancel}}
@label="admin.config.branding.fonts.backfill_modal.modal_no"
/>
</:footer>
</DModal>
</template>
}

View File

@ -90,3 +90,13 @@ export const TOPIC_VISIBILITY_REASONS = {
export const MAX_UNOPTIMIZED_CATEGORIES = 1000;
export const REVIEWABLE_UNKNOWN_TYPE_SOURCE = "unknown";
export const ADMIN_SEARCH_RESULT_TYPES = [
"page",
"setting",
"theme",
"component",
"report",
];
export const API_KEY_SCOPE_MODES = ["global", "read_only", "granular"];

View File

@ -200,18 +200,10 @@ export const ADMIN_NAV_MAP = [
label: "admin.config_sections.appearance.title",
links: [
{
name: "admin_font_style",
route: "adminConfig.fonts.settings",
label: "admin.config.font_style.title",
description: "admin.config.font_style.header_description",
icon: "italic",
settings_area: "fonts",
},
{
name: "admin_site_logo",
route: "adminConfig.logo.settings",
label: "admin.config.logo.title",
description: "admin.config.logo.header_description",
name: "admin_branding",
route: "adminConfig.branding",
label: "admin.config.branding.title",
description: "admin.config.branding.header_description",
icon: "fab-discourse",
settings_category: "branding",
},

View File

@ -175,3 +175,103 @@
width: var(--form-kit-small-input) !important;
}
}
.admin-fonts-form {
&__more {
font-size: var(--font-down-1-rem);
}
.form-kit__field-custom {
width: 100% !important;
.form-kit__control-custom {
display: flex;
flex-wrap: wrap;
flex-direction: row;
justify-content: space-between;
.admin-fonts-form__button-option {
border: 1px solid var(--primary-low);
font-size: var(--font-down-1-rem);
max-height: 4em;
height: 50px;
margin-bottom: 1em;
padding: 0 0.65em;
&:hover {
border-color: var(--primary-low-mid);
}
&.active {
position: relative;
border: 2px solid var(--tertiary);
&::after {
position: absolute;
top: 35%;
left: 0.5rem;
font-family: "Segoe UI", Arial, sans-serif;
content: "\2714"; // ✔
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
background-color: var(--tertiary);
color: var(--secondary);
border-radius: 9999px;
font-size: 0.5rem;
margin-left: 0.25rem;
}
&:hover {
border-color: var(--tertiary);
}
}
&.font {
flex: 0 0 32%;
max-width: 32%;
@media (max-width: $mobile-breakpoint) {
flex: 0 0 100%;
max-width: 100%;
}
}
&.text-size {
flex: 0 0 19%;
max-width: 19%;
@media (max-width: $mobile-breakpoint) {
flex: 0 0 100%;
max-width: 100%;
}
// px muse be used because there is different font size inside each button
height: 45px;
&.smallest {
font-size: var(--base-font-size-smallest);
}
&.smaller {
font-size: var(--base-font-size-smaller);
}
&.normal {
font-size: var(--base-font-size-normal);
}
&.larger {
font-size: var(--base-font-size-larger);
}
&.largest {
font-size: var(--base-font-size-largest);
}
}
}
}
}
}

View File

@ -38,4 +38,36 @@ class Admin::Config::BrandingController < Admin::AdminController
end
end
end
def fonts
previous_default_text_size = SiteSetting.default_text_size
SiteSetting::Update.call(
guardian:,
params: {
settings: [
{ setting_name: "base_font", value: params[:base_font] },
{ setting_name: "heading_font", value: params[:heading_font] },
{
setting_name: "default_text_size",
value: params[:default_text_size],
backfill: params[:update_existing_users] == "true",
},
],
},
) do
on_success { render json: success_json }
on_failed_policy(:settings_are_visible) do |policy|
raise Discourse::InvalidParameters, policy.reason
end
on_failed_policy(:settings_are_unshadowed_globally) do |policy|
raise Discourse::InvalidParameters, policy.reason
end
on_failed_policy(:settings_are_configurable) do |policy|
raise Discourse::InvalidParameters, policy.reason
end
on_failed_policy(:values_are_valid) do |policy|
raise Discourse::InvalidParameters, policy.reason
end
end
end
end

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
require "enum_site_setting"
class DefaultTextSizeSetting < EnumSiteSetting
DEFAULT_TEXT_SIZES = UserOption.text_sizes.keys.map(&:to_s)
def self.valid_value?(val)
values.any? { |v| v[:value].to_s == val.to_s }
end
def self.values
@values ||= DEFAULT_TEXT_SIZES
end
end

View File

@ -48,7 +48,7 @@ class UserOption < ActiveRecord::Base
end
def self.text_sizes
@text_sizes ||= Enum.new(normal: 0, larger: 1, largest: 2, smaller: 3, smallest: 4)
@text_sizes ||= Enum.new(smallest: 4, smaller: 3, normal: 0, larger: 1, largest: 2)
end
def self.title_count_modes

View File

@ -5241,9 +5241,6 @@ en:
title: "Moderation"
header_description: "The flagging system in Discourse helps you and your moderator team manage content and user behavior, keeping your community respectful and healthy. The defaults are suitable for most communities and you don’t have to change them. However, if your site has particular requirements you can disable flags you don’t need and add your own custom flags."
keywords: "flag|review|spam|illegal"
font_style:
title: "Font style"
header_description: "Customize the font styles used by your themes"
files:
title: "Files"
header_description: "Settings that control file size and type limits, avatar sizes and sources, file storage, image quality and compression, and more"
@ -5257,11 +5254,9 @@ en:
login_and_authentication:
title: "Login & authentication"
header_description: "Configure how users log in and authenticate, secrets and keys, OAuth2 providers, and more"
logo:
title: "Site logo"
header_description: "Customize the variations of your site logo"
branding:
title: "Branding"
header_description: "Customize the variations of your site logo and fonts"
logo:
title: "Logo"
form:
@ -5326,6 +5321,27 @@ en:
title: "X summary large image"
description: "X card 'summary large image'. If left blank, regular card metadata is generated using the OpenGraph_image, as long as that is not also a .svg."
help_text: "recommended size is at least 280 x 150 pixels. Don't use an SVG image."
fonts:
title: "Fonts"
form:
more_fonts: "More fonts"
less_fonts: "Less fonts"
saved: "Font settings are saved."
base_font:
title: "Base font"
heading_font:
title: "Heading font"
default_text_size:
title: "Default text size"
description: "This size can be subsequently adjusted by each user according to their preferences."
backfill_modal:
title: "Apply new default text settings?"
description:
one: "You’ve updated the default text size. Would you like to apply this change to %{count} existing user?"
other: "You’ve updated the default text size. Would you like to apply this change to %{count} existing users?"
modal_yes: "Yes"
modal_no: "No, only apply going forward"
navigation:
title: "Navigation"
header_description: "Configure the navigation links and menu items for your site. This includes the location and behavior of the primary navigation menu, the quick links at the top of the homepage, as well as the admin sidebar"

View File

@ -394,12 +394,10 @@ Discourse::Application.routes.draw do
namespace :config, constraints: StaffConstraint.new do
resources :site_settings, only: %i[index]
get "developer" => "site_settings#index"
get "fonts" => "site_settings#index"
get "files" => "site_settings#index"
get "legal" => "site_settings#index"
get "localization" => "site_settings#index"
get "login-and-authentication" => "site_settings#index"
get "logo" => "site_settings#index"
get "navigation" => "site_settings#index"
get "notifications" => "site_settings#index"
get "rate-limits" => "site_settings#index"
@ -414,6 +412,7 @@ Discourse::Application.routes.draw do
get "group-permissions" => "site_settings#index"
get "branding" => "branding#index"
put "branding/logo" => "branding#logo"
put "branding/fonts" => "branding#fonts"
get "colors/:id" => "color_palettes#show"
resources :flags, only: %i[index new create update destroy] do

View File

@ -439,6 +439,7 @@ basic:
list_type: font
area: "fonts"
allow_any: false
client: true
heading_font:
default: "inter"
choices: "BaseFontSetting.values"
@ -447,6 +448,7 @@ basic:
list_type: font
area: "fonts"
allow_any: false
client: true
enable_sitemap:
default: true
sitemap_page_size:
@ -3283,11 +3285,8 @@ user_preferences:
default_text_size:
type: enum
default: normal
choices:
- smaller
- normal
- larger
- largest
client: true
choices: "DefaultTextSizeSetting.values"
area: "fonts"
default_title_count_mode:

View File

@ -137,6 +137,8 @@ task "javascript:update_constants" => :environment do
)
end
MAIN_FONT_KEYS = %w[helvetica inter lato montserrat open_sans poppins roboto merriweather mukta]
write_template("admin/addon/lib/constants.js", task_name, <<~JS)
export const ADMIN_SEARCH_RESULT_TYPES = #{Admin::SearchController::RESULT_TYPES.to_json};
@ -151,6 +153,12 @@ task "javascript:update_constants" => :environment do
export const USER_FIELD_FLAGS = #{UserField::FLAG_ATTRIBUTES};
export const DEFAULT_USER_PREFERENCES = #{SiteSetting::DEFAULT_USER_PREFERENCES.to_json};
export const MAIN_FONTS = #{DiscourseFonts.fonts.filter { |font| MAIN_FONT_KEYS.include?(font[:key]) }.map { |font| { key: font[:key], name: font[:name] } }.to_json}
export const MORE_FONTS = #{DiscourseFonts.fonts.reject { |font| MAIN_FONT_KEYS.include?(font[:key]) }.map { |font| { key: font[:key], name: font[:name] } }.to_json}
export const DEFAULT_TEXT_SIZES = #{DefaultTextSizeSetting::DEFAULT_TEXT_SIZES}
JS
write_template("discourse/app/lib/constants.js", task_name, <<~JS)
@ -183,6 +191,10 @@ task "javascript:update_constants" => :environment do
export const MAX_UNOPTIMIZED_CATEGORIES = #{CategoryList::MAX_UNOPTIMIZED_CATEGORIES};
export const REVIEWABLE_UNKNOWN_TYPE_SOURCE = "#{Reviewable::UNKNOWN_TYPE_SOURCE}";
export const ADMIN_SEARCH_RESULT_TYPES = #{Admin::SearchController::RESULT_TYPES.to_json};
export const API_KEY_SCOPE_MODES = #{ApiKey.scope_modes.keys.to_json}
JS
pretty_notifications = Notification.types.map { |n| " #{n[0]}: #{n[1]}," }.join("\n")

View File

@ -0,0 +1,73 @@
# frozen_string_literal: true
RSpec.describe Admin::Config::BrandingController do
fab!(:admin)
fab!(:moderator)
fab!(:user)
describe "#fonts" do
context "when logged in as an admin" do
before { sign_in(admin) }
it "updates the fonts and text size" do
put "/admin/config/branding/fonts.json",
params: {
base_font: "helvetica",
heading_font: "roboto",
default_text_size: "largest",
}
expect(response.status).to eq(200)
expect(SiteSetting.base_font).to eq("helvetica")
expect(SiteSetting.heading_font).to eq("roboto")
expect(SiteSetting.default_text_size).to eq("largest")
end
it "validates values" do
put "/admin/config/branding/fonts.json",
params: {
base_font: "invalid_font",
heading_font: "invalid_font",
default_text_size: "invalid_size",
}
expect(response.status).to eq(400)
expect(SiteSetting.base_font).to eq("inter")
expect(SiteSetting.heading_font).to eq("inter")
expect(SiteSetting.default_text_size).to eq("normal")
end
end
context "when logged in as a moderator" do
before { sign_in(moderator) }
it "denies access with a 403 response" do
put "/admin/config/branding/fonts.json",
params: {
base_font: "helvetica",
heading_font: "roboto",
default_text_size: "largest",
}
expect(response.status).to eq(403)
expect(SiteSetting.base_font).to eq("inter")
expect(SiteSetting.heading_font).to eq("inter")
expect(SiteSetting.default_text_size).to eq("normal")
end
end
context "when logged in as a non-staff user" do
before { sign_in(user) }
it "denies access with a 404 response" do
put "/admin/config/branding/fonts.json",
params: {
base_font: "helvetica",
heading_font: "roboto",
default_text_size: "largest",
}
expect(response.status).to eq(404)
expect(SiteSetting.base_font).to eq("inter")
expect(SiteSetting.heading_font).to eq("inter")
expect(SiteSetting.default_text_size).to eq("normal")
end
end
end
end

View File

@ -6,206 +6,267 @@ describe "Admin Branding Page", type: :system do
let(:branding_page) { PageObjects::Pages::AdminBranding.new }
let(:image_file) { file_from_fixtures("logo.png", "images") }
let(:modal) { PageObjects::Modals::Base.new }
before { sign_in(admin) }
describe "primary section" do
let(:primary_section_logos) { %i[logo logo_dark large_icon favicon logo_small logo_small_dark] }
it "can upload images and dark versions" do
branding_page.visit
describe "logo" do
describe "primary section" do
let(:primary_section_logos) do
%i[logo logo_dark large_icon favicon logo_small logo_small_dark]
end
it "can upload images and dark versions" do
branding_page.visit
expect(branding_page.logo_form).to have_no_form_field(:logo_dark)
branding_page.logo_form.toggle_dark_mode(:logo_dark_required)
expect(branding_page.logo_form).to have_form_field(:logo_dark)
expect(branding_page.logo_form).to have_no_form_field(:logo_dark)
branding_page.logo_form.toggle_dark_mode(:logo_dark_required)
expect(branding_page.logo_form).to have_form_field(:logo_dark)
expect(branding_page.logo_form).to have_no_form_field(:logo_small_dark)
branding_page.logo_form.toggle_dark_mode(:logo_small_dark_required)
expect(branding_page.logo_form).to have_form_field(:logo_small_dark)
expect(branding_page.logo_form).to have_no_form_field(:logo_small_dark)
branding_page.logo_form.toggle_dark_mode(:logo_small_dark_required)
expect(branding_page.logo_form).to have_form_field(:logo_small_dark)
primary_section_logos.each do |image_type|
branding_page.logo_form.upload_image(image_type, image_file)
primary_section_logos.each do |image_type|
branding_page.logo_form.upload_image(image_type, image_file)
end
primary_section_logos.each do |image_type|
expect(branding_page.logo_form.image_uploader(image_type)).to have_uploaded_image
end
branding_page.logo_form.submit
expect(branding_page.logo_form).to have_saved_successfully
visit("/")
branding_page.visit
expect(branding_page.logo_form).to have_form_field(:logo_dark)
expect(branding_page.logo_form).to have_form_field(:logo_small_dark)
primary_section_logos.each do |image_type|
expect(branding_page.logo_form.image_uploader(image_type)).to have_uploaded_image
end
end
primary_section_logos.each do |image_type|
expect(branding_page.logo_form.image_uploader(image_type)).to have_uploaded_image
end
it "can remove images" do
primary_section_logos.each { |image_type| SiteSetting.send("#{image_type}=", image_upload) }
branding_page.logo_form.submit
expect(branding_page.logo_form).to have_saved_successfully
branding_page.visit
visit("/")
branding_page.visit
primary_section_logos.each do |image_type|
expect(branding_page.logo_form.image_uploader(image_type)).to have_uploaded_image
end
expect(branding_page.logo_form).to have_form_field(:logo_dark)
expect(branding_page.logo_form).to have_form_field(:logo_small_dark)
primary_section_logos.each { |image_type| branding_page.logo_form.remove_image(image_type) }
primary_section_logos.each do |image_type|
expect(branding_page.logo_form.image_uploader(image_type)).to have_uploaded_image
branding_page.logo_form.submit
expect(page).to have_css("#site-text-logo")
primary_section_logos.each { |image_type| expect(SiteSetting.send(image_type)).to eq(nil) }
end
end
it "can remove images" do
primary_section_logos.each { |image_type| SiteSetting.send("#{image_type}=", image_upload) }
describe "mobile section" do
let(:mobile_section_logos) { %i[mobile_logo mobile_logo_dark manifest_icon apple_touch_icon] }
it "can upload images and dark versions" do
branding_page.visit
branding_page.logo_form.expand_mobile_section
branding_page.visit
expect(branding_page.logo_form).to have_no_form_field(:mobile_logo_dark)
branding_page.logo_form.toggle_dark_mode(:mobile_logo_dark_required)
expect(branding_page.logo_form).to have_form_field(:mobile_logo_dark)
primary_section_logos.each do |image_type|
expect(branding_page.logo_form.image_uploader(image_type)).to have_uploaded_image
mobile_section_logos.each do |image_type|
branding_page.logo_form.upload_image(image_type, image_file)
end
mobile_section_logos.each do |image_type|
expect(branding_page.logo_form.image_uploader(image_type)).to have_uploaded_image
end
branding_page.logo_form.submit
expect(branding_page.logo_form).to have_saved_successfully
visit("/")
branding_page.visit
branding_page.logo_form.expand_mobile_section
expect(branding_page.logo_form).to have_form_field(:mobile_logo_dark)
mobile_section_logos.each do |image_type|
expect(branding_page.logo_form.image_uploader(image_type)).to have_uploaded_image
end
end
primary_section_logos.each { |image_type| branding_page.logo_form.remove_image(image_type) }
it "can remove images" do
mobile_section_logos.each { |image_type| SiteSetting.send("#{image_type}=", image_upload) }
branding_page.logo_form.submit
expect(branding_page.logo_form).to have_saved_successfully
branding_page.visit
branding_page.logo_form.expand_mobile_section
primary_section_logos.each { |image_type| expect(SiteSetting.send(image_type)).to eq(nil) }
mobile_section_logos.each do |image_type|
expect(branding_page.logo_form.image_uploader(image_type)).to have_uploaded_image
end
mobile_section_logos.each { |image_type| branding_page.logo_form.remove_image(image_type) }
branding_page.logo_form.submit
expect(branding_page.logo_form).to have_saved_successfully
mobile_section_logos.each { |image_type| expect(SiteSetting.send(image_type)).to eq(nil) }
end
end
describe "email section" do
let(:email_section_logos) { %i[digest_logo] }
it "can upload images" do
branding_page.visit
branding_page.logo_form.expand_email_section
email_section_logos.each do |image_type|
branding_page.logo_form.upload_image(image_type, image_file)
end
email_section_logos.each do |image_type|
expect(branding_page.logo_form.image_uploader(image_type)).to have_uploaded_image
end
branding_page.logo_form.submit
expect(branding_page.logo_form).to have_saved_successfully
visit("/")
branding_page.visit
branding_page.logo_form.expand_email_section
email_section_logos.each do |image_type|
expect(branding_page.logo_form.image_uploader(image_type)).to have_uploaded_image
end
end
it "can remove images" do
email_section_logos.each { |image_type| SiteSetting.send("#{image_type}=", image_upload) }
branding_page.visit
branding_page.logo_form.expand_email_section
email_section_logos.each do |image_type|
expect(branding_page.logo_form.image_uploader(image_type)).to have_uploaded_image
end
email_section_logos.each { |image_type| branding_page.logo_form.remove_image(image_type) }
branding_page.logo_form.submit
expect(branding_page.logo_form).to have_saved_successfully
email_section_logos.each { |image_type| expect(SiteSetting.send(image_type)).to eq(nil) }
end
end
describe "social media section" do
let(:social_media_section_logos) { %i[opengraph_image] }
it "can upload images" do
branding_page.visit
branding_page.logo_form.expand_social_media_section
social_media_section_logos.each do |image_type|
branding_page.logo_form.upload_image(image_type, image_file)
end
social_media_section_logos.each do |image_type|
expect(branding_page.logo_form.image_uploader(image_type)).to have_uploaded_image
end
branding_page.logo_form.submit
expect(branding_page.logo_form).to have_saved_successfully
visit("/")
branding_page.visit
branding_page.logo_form.expand_social_media_section
social_media_section_logos.each do |image_type|
expect(branding_page.logo_form.image_uploader(image_type)).to have_uploaded_image
end
end
it "can remove images" do
social_media_section_logos.each do |image_type|
SiteSetting.send("#{image_type}=", image_upload)
end
branding_page.visit
branding_page.logo_form.expand_social_media_section
social_media_section_logos.each do |image_type|
expect(branding_page.logo_form.image_uploader(image_type)).to have_uploaded_image
end
social_media_section_logos.each do |image_type|
branding_page.logo_form.remove_image(image_type)
end
branding_page.logo_form.submit
expect(branding_page.logo_form).to have_saved_successfully
social_media_section_logos.each do |image_type|
expect(SiteSetting.send(image_type)).to eq(nil)
end
end
end
end
describe "mobile section" do
let(:mobile_section_logos) { %i[mobile_logo mobile_logo_dark manifest_icon apple_touch_icon] }
it "can upload images and dark versions" do
describe "fonts" do
it "allows an admin to change the site's base font and heading font" do
branding_page.visit
branding_page.logo_form.expand_mobile_section
branding_page.fonts_form.select_font("base", "helvetica")
expect(branding_page.logo_form).to have_no_form_field(:mobile_logo_dark)
branding_page.logo_form.toggle_dark_mode(:mobile_logo_dark_required)
expect(branding_page.logo_form).to have_form_field(:mobile_logo_dark)
expect(branding_page.fonts_form).to have_no_font("heading", "Oswald")
branding_page.fonts_form.show_more_fonts("heading")
branding_page.fonts_form.select_font("heading", "oswald")
mobile_section_logos.each do |image_type|
branding_page.logo_form.upload_image(image_type, image_file)
end
branding_page.fonts_form.submit
expect(branding_page.fonts_form).to have_saved_successfully
mobile_section_logos.each do |image_type|
expect(branding_page.logo_form.image_uploader(image_type)).to have_uploaded_image
end
branding_page.logo_form.submit
expect(branding_page.logo_form).to have_saved_successfully
visit("/")
branding_page.visit
branding_page.logo_form.expand_mobile_section
expect(branding_page.logo_form).to have_form_field(:mobile_logo_dark)
mobile_section_logos.each do |image_type|
expect(branding_page.logo_form.image_uploader(image_type)).to have_uploaded_image
end
expect(branding_page.fonts_form.active_font("base")).to eq("Helvetica")
expect(branding_page.fonts_form.active_font("heading")).to eq("Oswald")
end
it "can remove images" do
mobile_section_logos.each { |image_type| SiteSetting.send("#{image_type}=", image_upload) }
it "allows an admin to change default text size and does not update existing users preferences" do
Jobs.run_immediately!
branding_page.visit
branding_page.logo_form.expand_mobile_section
expect(page).to have_css("html.text-size-normal")
branding_page.fonts_form.select_default_text_size("larger")
mobile_section_logos.each do |image_type|
expect(branding_page.logo_form.image_uploader(image_type)).to have_uploaded_image
end
branding_page.fonts_form.submit
expect(modal).to be_open
expect(modal.header).to have_content(
I18n.t("admin_js.admin.config.branding.fonts.backfill_modal.title"),
)
modal.close
expect(modal).to be_closed
expect(branding_page.fonts_form).to have_saved_successfully
mobile_section_logos.each { |image_type| branding_page.logo_form.remove_image(image_type) }
branding_page.logo_form.submit
expect(branding_page.logo_form).to have_saved_successfully
mobile_section_logos.each { |image_type| expect(SiteSetting.send(image_type)).to eq(nil) }
end
end
describe "email section" do
let(:email_section_logos) { %i[digest_logo] }
it "can upload images" do
branding_page.visit
branding_page.logo_form.expand_email_section
email_section_logos.each do |image_type|
branding_page.logo_form.upload_image(image_type, image_file)
end
email_section_logos.each do |image_type|
expect(branding_page.logo_form.image_uploader(image_type)).to have_uploaded_image
end
branding_page.logo_form.submit
expect(branding_page.logo_form).to have_saved_successfully
visit("/")
branding_page.visit
branding_page.logo_form.expand_email_section
email_section_logos.each do |image_type|
expect(branding_page.logo_form.image_uploader(image_type)).to have_uploaded_image
end
visit "/"
expect(page).to have_css("html.text-size-normal")
end
it "can remove images" do
email_section_logos.each { |image_type| SiteSetting.send("#{image_type}=", image_upload) }
it "allows an admin to change default text size and updates existing users preferences" do
Jobs.run_immediately!
branding_page.visit
branding_page.logo_form.expand_email_section
expect(page).to have_css("html.text-size-normal")
branding_page.fonts_form.select_default_text_size("larger")
email_section_logos.each do |image_type|
expect(branding_page.logo_form.image_uploader(image_type)).to have_uploaded_image
end
branding_page.fonts_form.submit
expect(modal).to be_open
expect(modal.header).to have_content(
I18n.t("admin_js.admin.config.branding.fonts.backfill_modal.title"),
)
modal.click_primary_button
expect(modal).to be_closed
expect(branding_page.fonts_form).to have_saved_successfully
email_section_logos.each { |image_type| branding_page.logo_form.remove_image(image_type) }
branding_page.logo_form.submit
expect(branding_page.logo_form).to have_saved_successfully
email_section_logos.each { |image_type| expect(SiteSetting.send(image_type)).to eq(nil) }
end
end
describe "social media section" do
let(:social_media_section_logos) { %i[opengraph_image] }
it "can upload images" do
branding_page.visit
branding_page.logo_form.expand_social_media_section
social_media_section_logos.each do |image_type|
branding_page.logo_form.upload_image(image_type, image_file)
end
social_media_section_logos.each do |image_type|
expect(branding_page.logo_form.image_uploader(image_type)).to have_uploaded_image
end
branding_page.logo_form.submit
expect(branding_page.logo_form).to have_saved_successfully
visit("/")
branding_page.visit
branding_page.logo_form.expand_social_media_section
social_media_section_logos.each do |image_type|
expect(branding_page.logo_form.image_uploader(image_type)).to have_uploaded_image
end
end
it "can remove images" do
social_media_section_logos.each do |image_type|
SiteSetting.send("#{image_type}=", image_upload)
end
branding_page.visit
branding_page.logo_form.expand_social_media_section
social_media_section_logos.each do |image_type|
expect(branding_page.logo_form.image_uploader(image_type)).to have_uploaded_image
end
social_media_section_logos.each do |image_type|
branding_page.logo_form.remove_image(image_type)
end
branding_page.logo_form.submit
expect(branding_page.logo_form).to have_saved_successfully
social_media_section_logos.each do |image_type|
expect(SiteSetting.send(image_type)).to eq(nil)
end
visit "/"
expect(page).to have_css("html.text-size-larger")
end
end
end

View File

@ -0,0 +1,49 @@
# frozen_string_literal: true
module PageObjects
module Components
class AdminBrandingFontsForm < PageObjects::Components::Base
def select_font(section, font)
find(
"[data-name='#{section}_font'] .admin-fonts-form__button-option.body-font-#{font}",
).click
end
def select_default_text_size(size)
find(".admin-fonts-form__button-option.#{size}").click
end
def active_font(section)
find("[data-name='#{section}_font'] .admin-fonts-form__button-option.active").text
end
def has_no_font?(section, font)
page.has_no_css?(
"[data-name='#{section}_font'] .admin-fonts-form__button-option.body-font-#{font}",
)
end
def show_more_fonts(section)
find("[data-name='#{section}_font'] .admin-fonts-form__more").click
end
def has_form_field?(field)
page.has_css?("#control-#{field}")
end
def submit
form.submit
end
def has_saved_successfully?
PageObjects::Components::Toasts.new.has_success?(
I18n.t("admin_js.admin.config.branding.fonts.form.saved"),
)
end
def form
@form ||= PageObjects::Components::FormKit.new(".admin-fonts-form")
end
end
end
end

View File

@ -10,6 +10,10 @@ module PageObjects
def logo_form
@logo_form ||= PageObjects::Components::AdminBrandingLogoForm.new
end
def fonts_form
@fonts_form ||= PageObjects::Components::AdminBrandingFontsForm.new
end
end
end
end