UX: Look and feel changes (#29245)

This PR:

- Removes components from being displayed in the card
- Adds a DMenu to house previous footer actions
- Allows themes to be updated from this grid, with an animation and different border to show the update is happening
- Stops position of cards changing when default changes
- Fixes outline colour not changing when default changes
- Show a global notice on the page when previewing a theme
- Allows updating a theme from the grid, and showing an indicator of what theme needs to be updated
- Moves "Set as default" to the dropdown for the theme
- Show screenshot for theme if it is available
- Prevent page reloading when updating the theme
- Fixes theme install modal on grid page
- Temporarily remove sorting of default theme to the top
This commit is contained in:
Jordan Vidrine 2024-10-28 21:25:17 -05:00 committed by GitHub
parent 4d7f70b923
commit f902e0fdd7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 296 additions and 195 deletions

View File

@ -0,0 +1,69 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { service } from "@ember/service";
import I18n from "discourse-i18n";
import AdminPageSubheader from "admin/components/admin-page-subheader";
import InstallThemeModal from "admin/components/modal/install-theme";
import ThemesGrid from "admin/components/themes-grid";
export default class AdminConfigAreasLookAndFeelThemes extends Component {
@service modal;
@service router;
@service toasts;
@action
installModal() {
this.modal.show(InstallThemeModal, {
model: { ...this.installThemeOptions() },
});
}
// TODO (martin) These install methods may not belong here and they
// are incomplete or have stubbed or omitted properties. We may want
// to move this to the new config route or a dedicated component
// that sits in the route.
installThemeOptions() {
return {
selectedType: "theme",
userId: null,
content: [],
installedThemes: this.args.themes,
addTheme: this.addTheme,
updateSelectedType: () => {},
};
}
@action
addTheme(theme) {
this.toasts.success({
data: {
message: I18n.t("admin.customize.theme.install_success", {
theme: theme.name,
}),
},
duration: 2000,
});
this.router.refresh();
}
<template>
<AdminPageSubheader
@titleLabel="admin.config_areas.look_and_feel.themes.title"
@descriptionLabel="admin.customize.theme.themes_intro_new"
@learnMoreUrl="https://meta.discourse.org/t/93648"
>
<:actions as |actions|>
<actions.Primary
@action={{this.installModal}}
@label="admin.customize.install"
@icon="upload"
class="admin-look-and-feel__install-theme"
/>
</:actions>
</AdminPageSubheader>
<div class="admin-detail">
<ThemesGrid @themes={{@themes}} />
</div>
</template>
}

View File

@ -10,7 +10,7 @@ import { COMPONENTS, THEMES } from "admin/models/theme";
const MIN_NAME_LENGTH = 4;
export default class InstallTheme extends Component {
export default class InstallThemeModal extends Component {
@service store;
@tracked selection = this.args.model.selection || "popular";
@ -184,9 +184,11 @@ export default class InstallTheme extends Component {
}
if (this.remote || this.popular || this.directRepoInstall) {
const duplicate = this.args.model.content.find((theme) =>
this.themeHasSameUrl(theme, this.uploadUrl)
);
const duplicate =
this.args.model.content &&
this.args.model.content.find((theme) =>
this.themeHasSameUrl(theme, this.uploadUrl)
);
if (duplicate && !this.duplicateRemoteThemeWarning) {
const warning = I18n.t("admin.customize.theme.duplicate_remote_theme", {
name: duplicate.name,

View File

@ -1,13 +1,16 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { array } from "@ember/helper";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import DButton from "discourse/components/d-button";
import concatClass from "discourse/helpers/concat-class";
import DropdownMenu from "discourse/components/dropdown-menu";
import { popupAjaxError } from "discourse/lib/ajax-error";
import icon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
import I18n from "discourse-i18n";
import AdminConfigAreaCard from "admin/components/admin-config-area-card";
import DMenu from "float-kit/components/d-menu";
import ThemesGridPlaceholder from "./themes-grid-placeholder";
// NOTE (martin): We will need to revisit and improve this component
@ -20,24 +23,28 @@ export default class ThemeCard extends Component {
@service siteSettings;
@service toasts;
@tracked isUpdating = false;
get themeCardClasses() {
return [
"theme-card",
this.args.theme.get("default") ? "-active" : "",
this.isUpdating ? "--updating" : "",
].join(" ");
}
get themeRouteModels() {
return ["themes", this.args.theme.id];
}
get childrenString() {
return this.args.theme.childThemes.reduce((acc, theme, idx) => {
if (idx === this.args.theme.childThemes.length - 1) {
return acc + theme.name;
} else {
return acc + theme.name + ", ";
}
}, "");
}
get themePreviewUrl() {
return `/admin/themes/${this.args.theme.id}/preview`;
}
get footerActionIcon() {
return this.args.theme.isPendingUpdates ? "sync" : "ellipsis-h";
}
// NOTE: inspired by -> https://github.com/discourse/discourse/blob/24caa36eef826bcdaed88aebfa7df154413fb349/app/assets/javascripts/admin/addon/controllers/admin-customize-themes-show.js#L366
//
// Will also need some cleanup when refactoring other theme code.
@ -75,12 +82,43 @@ export default class ThemeCard extends Component {
});
}
@action
updateTheme() {
if (this.isUpdating) {
return;
}
this.isUpdating = true;
this.args.theme
.updateToLatest()
.then(() => {
this.toasts.success({
data: {
message: I18n.t("admin.customize.theme.update_success", {
theme: this.args.theme.name,
}),
},
duration: 2000,
});
})
.catch(popupAjaxError)
.finally(() => {
this.isUpdating = false;
});
}
<template>
<AdminConfigAreaCard
class={{concatClass "theme-card" (if @theme.default "-active")}}
class={{this.themeCardClasses}}
@translatedHeading={{@theme.name}}
>
<:content>
{{#if @theme.isPendingUpdates}}
<span
title={{i18n "admin.customize.theme.updates_available_tooltip"}}
class="theme-card__update-available"
>{{icon "info-circle"}}</span>
{{/if}}
<div class="theme-card__image-wrapper">
{{#if @theme.screenshot_url}}
<img
@ -96,77 +134,78 @@ export default class ThemeCard extends Component {
{{#if @theme.description}}
<p class="theme-card__description">{{@theme.description}}</p>
{{/if}}
{{#if @theme.childThemes}}
<span class="theme-card__components">{{i18n
"admin.customize.theme.components"
}}:
{{htmlSafe this.childrenString}}</span>
{{/if}}
</div>
<div class="theme-card__footer">
<DButton
@action={{this.setDefault}}
@translatedLabel={{i18n "admin.customize.theme.edit"}}
@route="adminCustomizeThemes.show"
@routeModels={{this.themeRouteModels}}
@class="btn-primary theme-card__button"
@preventFocus={{true}}
@icon={{if @theme.default "far-check-square" "far-square"}}
@class={{concatClass
"theme-card__button"
(if @theme.default "btn-primary" "btn-default")
}}
@translatedLabel={{i18n
(if
@theme.default
"admin.customize.theme.default_theme"
"admin.customize.theme.set_default_theme"
)
}}
@disabled={{@theme.default}}
/>
{{#if @theme.isPendingUpdates}}
<DButton
@route="adminCustomizeThemes.show"
@routeModels={{this.themeRouteModels}}
@icon="sync"
@class="btn btn-flat theme-card__button"
@title="admin.customize.theme.updates_available_tooltip"
@preventFocus={{true}}
/>
{{else}}
{{#if @theme.isBroken}}
<DButton
@route="adminCustomizeThemes.show"
@routeModels={{this.themeRouteModels}}
@icon="exclamation-circle"
@class="btn btn-flat theme-card__button broken-indicator"
@title="admin.customize.theme.broken_theme_tooltip"
@preventFocus={{true}}
/>
{{/if}}
{{#unless @theme.enabled}}
<DButton
@route="adminCustomizeThemes.show"
@routeModels={{this.themeRouteModels}}
@icon="ban"
@class="btn btn-flat theme-card__button broken-indicator light-grey-icon"
@title="admin.customize.theme.disabled_component_tooltip"
@preventFocus={{true}}
/>
{{/unless}}
{{/if}}
<div class="theme-card__footer-actions">
<a
href={{this.themePreviewUrl}}
title={{i18n "admin.customize.explain_preview"}}
rel="noopener noreferrer"
target="_blank"
class="btn btn-flat theme-card__button"
>{{icon "eye"}}</a>
<DButton
@route="adminCustomizeThemes.show"
@routeModels={{this.themeRouteModels}}
@icon="cog"
@class="btn-flat theme-card__button"
@preventFocus={{true}}
/>
<DMenu
@identifier="theme-card__footer-menu"
@triggerClass="theme-card__footer-menu btn-flat"
@modalForMobile={{true}}
@icon={{this.footerActionIcon}}
@label={{if
this.isUpdating
(i18n "admin.customize.theme.updating")
""
}}
@triggers={{array "click"}}
>
<:content>
<DropdownMenu as |dropdown|>
{{#if @theme.isPendingUpdates}}
<dropdown.item>
<DButton
@action={{this.updateTheme}}
@icon="download"
@class="theme-card__button -update"
@preventFocus={{true}}
@translatedLabel={{i18n
"admin.customize.theme.update_to_latest"
}}
/>
</dropdown.item>
{{/if}}
{{! TODO: Jordan
solutions for broken, disabled states }}
<dropdown.item>
<DButton
@action={{this.setDefault}}
@preventFocus={{true}}
@icon={{if
@theme.default
"far-check-square"
"far-square"
}}
@class="theme-card__button"
@translatedLabel={{i18n
(if
@theme.default
"admin.customize.theme.default_theme"
"admin.customize.theme.set_default_theme"
)
}}
@disabled={{@theme.default}}
/>
</dropdown.item>
<dropdown.item>
<a
href={{this.themePreviewUrl}}
title={{i18n "admin.customize.explain_preview"}}
rel="noopener noreferrer"
target="_blank"
class="btn btn-transparent theme-card__button"
>{{icon "eye"}} {{i18n "admin.customize.theme.preview"}}</a>
</dropdown.item>
</DropdownMenu>
</:content>
</DMenu>
</div>
</div>
</:content>

View File

@ -1,11 +1,5 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { service } from "@ember/service";
import DButton from "discourse/components/d-button";
import icon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
import AdminConfigAreaCard from "admin/components/admin-config-area-card";
import InstallThemeModal from "../components/modal/install-theme";
import ThemesGridCard from "./themes-grid-card";
// NOTE (martin): Much of the JS code in this component is placeholder code. Much
@ -16,6 +10,8 @@ export default class ThemesGrid extends Component {
@service modal;
@service router;
sortedThemes;
externalResources = [
{
key: "admin.customize.theme.beginners_guide_title",
@ -31,9 +27,17 @@ export default class ThemesGrid extends Component {
},
];
// Always show the default theme first in the list
get sortedThemes() {
return this.args.themes.sort((a, b) => {
constructor() {
super(...arguments);
// Show default theme at the top of the list on page load,
// but don't move it around dynamically if the admin changes the default.
//
// TODO (martin) Figure out how to make it so we can sort default to the
// top but also allow the list of themes to change if an additional theme is
// installed. Basically don't want .get("default") to affect the sort after
// the first time, but if the whole array changes this needs to be recalculated.
this.sortedThemes = this.args.themes.sort((a, b) => {
if (a.get("default")) {
return -1;
} else if (b.get("default")) {
@ -42,78 +46,11 @@ export default class ThemesGrid extends Component {
});
}
// TODO (martin) These install methods may not belong here and they
// are incomplete or have stubbed or omitted properties. We may want
// to move this to the new config route or a dedicated component
// that sits in the route.
installThemeOptions() {
return {
selectedType: "theme",
userId: null,
content: null,
installedThemes: this.args.themes,
addTheme: this.addTheme,
updateSelectedType: () => {},
};
}
@action
addTheme(theme) {
this.refresh();
theme.setProperties({ recentlyInstalled: true });
this.router.transitionTo("adminCustomizeThemes.show", theme.get("id"), {
queryParams: {
repoName: null,
repoUrl: null,
},
});
}
@action
installModal() {
this.modal.show(InstallThemeModal, {
model: { ...this.installThemeOptions() },
});
}
<template>
<div class="themes-cards-container">
<div class="themes-cards-container__main">
{{#each this.sortedThemes as |theme|}}
<ThemesGridCard @theme={{theme}} @allThemes={{@themes}} />
{{/each}}
</div>
<div class="themes-cards-container__helper">
<AdminConfigAreaCard
class="theme-card"
@heading="admin.config_areas.look_and_feel.themes.new_theme"
>
<:content>
<p class="theme-card__description">{{i18n
"admin.customize.theme.themes_intro_new"
}}</p>
<div class="external-resources">
{{#each this.externalResources as |resource|}}
<a
href={{resource.link}}
class="external-link"
rel="noopener noreferrer"
target="_blank"
>
{{i18n resource.key}}
{{icon "external-link-alt"}}
</a>
{{/each}}
</div>
<DButton
@action={{this.installModal}}
@icon="upload"
@label="admin.customize.install"
class="btn-primary theme-card__install-button"
/>
</:content>
</AdminConfigAreaCard>
</div>
{{#each @themes as |theme|}}
<ThemesGridCard @theme={{theme}} @allThemes={{@themes}} />
{{/each}}
</div>
</template>
}

View File

@ -0,0 +1,10 @@
import { service } from "@ember/service";
import DiscourseRoute from "discourse/routes/discourse";
export default class AdminConfigLookAndFeelIndexRoute extends DiscourseRoute {
@service router;
beforeModel() {
this.router.replaceWith("adminConfig.lookAndFeel.themes");
}
}

View File

@ -5,10 +5,6 @@ import I18n from "discourse-i18n";
export default class AdminConfigLookAndFeelRoute extends DiscourseRoute {
@service router;
beforeModel() {
this.router.replaceWith("adminConfig.lookAndFeel.themes");
}
titleToken() {
return I18n.t("admin.config_areas.look_and_feel.title");
}

View File

@ -3,6 +3,4 @@
@label={{i18n "admin.config_areas.look_and_feel.themes.title"}}
/>
<div class="admin-detail">
<ThemesGrid @themes={{this.model}} />
</div>
<AdminConfigAreas::LookAndFeelThemes @themes={{this.model}} />

View File

@ -1,6 +1,7 @@
<AdminPageHeader
@titleLabel="admin.config_areas.look_and_feel.title"
@descriptionLabel="admin.config_areas.look_and_feel.description"
@learnMoreUrl="https://meta.discourse.org/t/beginners-guide-to-using-discourse-themes/91966"
>
<:breadcrumbs>
<DBreadcrumbsItem

View File

@ -116,6 +116,15 @@ export default class GlobalNotice extends Component {
);
}
if (this.router.currentRoute?.queryParams?.preview_theme_id) {
notices.push(
Notice.create({
text: I18n.t("theme_preview_notice"),
id: "theme-preview",
})
);
}
if (this.siteSettings.disable_emails === "yes") {
notices.push(
Notice.create({

View File

@ -51,8 +51,11 @@ class LiveDevelopmentInit {
// Refresh if necessary
document.location.reload(true);
} else if (me === "development-mode-theme-changed") {
if (window.location.pathname.startsWith("/admin/customize/themes")) {
// don't refresh users on routes which make theme changes - would be very inconvenient.
if (
window.location.pathname.startsWith("/admin/customize/themes") ||
window.location.pathname.startsWith("/admin/config/look-and-feel")
) {
// Don't refresh users on routes which make theme changes - would be very inconvenient.
// Instead, refresh on their next route navigation.
this.session.requiresRefresh = true;
} else {

View File

@ -5,25 +5,60 @@
.themes-cards-container {
display: grid;
grid-template-columns: calc(66.66% - 0.5em) calc(33.33% - 0.5em);
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1em;
@media screen and (max-width: 1300px) {
display: flex;
flex-direction: column-reverse;
}
&__main {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(calc(325px - 1em), 1fr));
gap: 1em;
}
}
.theme-card {
display: flex;
flex-direction: column;
position: relative;
box-shadow: 0px 0px 0px 3px transparent;
transition: box-shadow 0.3s ease-in-out;
&.--updating {
animation: updating 3s ease-in-out 1,
updatingInfinite 3s ease-in-out 3s infinite;
@keyframes updating {
0% {
box-shadow: 0px 0px 0px 3px transparent;
border: 1px solid var(--success);
}
50% {
box-shadow: 0px 0px 0px 6px var(--success-low);
border: 1px solid var(--success);
}
100% {
box-shadow: 0px 0px 0px 3px var(--success-low);
border: 1px solid var(--success);
}
}
@keyframes updatingInfinite {
0% {
box-shadow: 0px 0px 0px 3px var(--success-low);
border: 1px solid var(--success);
}
50% {
box-shadow: 0px 0px 0px 6px var(--success-low);
border: 1px solid var(--success);
}
100% {
box-shadow: 0px 0px 0px 3px var(--success-low);
border: 1px solid var(--success);
}
}
}
&.--updating .d-icon-sync {
animation: rotate 3s linear infinite;
margin-right: 0.45em;
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
}
.admin-config-area-card__content {
display: flex;
flex-direction: column;
@ -34,16 +69,14 @@
@include theme-card-border(tertiary);
}
&.-disabled {
@include theme-card-border(primary);
}
&.-broken {
@include theme-card-border(danger);
}
.broken-indicator {
color: var(--danger);
&__update-available {
position: absolute;
right: -9px;
top: -9px;
color: var(--success);
font-size: var(--font-up-1);
background: var(--secondary);
display: flex;
}
&__icons {
@ -139,6 +172,5 @@
&__title {
display: flex;
align-items: center;
gap: 0.5em;
}
}

View File

@ -4740,6 +4740,8 @@ en:
safe_mode:
enabled: "Safe mode is enabled, to exit safe mode close this browser window"
theme_preview_notice: "You are currently previewing a theme, close this browser tab or window to return to your normal site configuration."
image_removed: "(image removed)"
pause_notifications:
@ -5694,8 +5696,8 @@ en:
move_up: "Move up"
move_down: "Move down"
look_and_feel:
title: "Look & feel"
description: "Themes, components, and color schemes can be used to customise and brand your Discourse site, giving it a distinctive style."
title: "Look and feel"
description: "Customize and brand your Discourse site, giving it a distinctive style."
themes:
title: "Themes"
themes_intro: "Install a new theme to get started, or create your own from scratch using these resources."
@ -5929,12 +5931,14 @@ en:
set_default_theme: "Set default"
default_theme: "Default theme"
set_default_success: "Default theme set to %{theme}"
install_success: "%{theme} installed successfully!"
inactive_components: "Unused components:"
selected:
one: "%{count} selected"
other: "%{count} selected"
cancel: "Cancel"
broken_theme_tooltip: "This theme has errors in its CSS, HTML or YAML"
broken_theme: "Disable broken theme"
disabled_component_tooltip: "This component has been disabled"
default_theme_tooltip: "This theme is the site's default theme"
updates_available_tooltip: "Updates are available for this theme"
@ -6001,6 +6005,7 @@ en:
update_to_latest: "Update to Latest"
check_for_updates: "Check for Updates"
updating: "Updating…"
update_success: "%{theme} Update complete"
up_to_date: "Theme is up-to-date, last checked:"
has_overwritten_history: "Current theme version no longer exists because the Git history has been overwritten by a force push."
add: "Add"