discourse/app/assets/javascripts/admin/addon/components/themes-grid-card.gjs
Osama Sayegh dbc7942111
DEV: Add plugin outlet to components config area and data-theme-id to themes grid (#35006)
These are needed for adding custom elements to the page and targeting
specific themes with CSS in plugins.
2025-09-30 15:05:08 +03:00

320 lines
10 KiB
Text
Vendored

import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { dasherize } from "@ember/string";
import DButton from "discourse/components/d-button";
import DropdownMenu from "discourse/components/dropdown-menu";
import icon from "discourse/helpers/d-icon";
import { popupAjaxError } from "discourse/lib/ajax-error";
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
// over time.
//
// Much of the existing theme logic in /admin/customize/themes has old patterns
// and technical debt, so anything copied from there to here is subject
// to change as we improve this incrementally.
export default class ThemeCard extends Component {
@service toasts;
@service dialog;
@service router;
@tracked isUpdating = false;
get themeCardClasses() {
return [
"theme-card",
this.args.theme.get("default") ? "--default" : "",
this.isUpdating ? "--updating" : "",
dasherize(this.args.theme.name),
].join(" ");
}
get themeRouteModels() {
return ["themes", this.args.theme.id];
}
get themePreviewUrl() {
return `/admin/themes/${this.args.theme.id}/preview`;
}
get destroyDisabled() {
return this.args.theme.default || this.args.theme.system;
}
get editUrl() {
return this.router.urlFor(
"adminCustomizeThemes.show",
"themes",
this.args.theme.id
);
}
@action
onRegisterApi(api) {
this.dMenu = api;
}
// 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.
@action
async setDefault() {
let oldDefaultThemeId;
this.args.theme.set("default", true);
this.dMenu.close();
this.args.allThemes.forEach((theme) => {
if (theme.id !== this.args.theme.id) {
if (theme.get("default")) {
oldDefaultThemeId = theme.id;
}
theme.set("default", !this.args.theme.get("default"));
}
});
const changesSaved = await this.args.theme.saveChanges("default");
if (!changesSaved) {
this.args.allThemes
.find((theme) => theme.id === oldDefaultThemeId)
.set("default", true);
this.args.theme.set("default", false);
return;
}
this.toasts.success({
data: {
message: i18n("admin.customize.theme.set_default_success", {
theme: this.args.theme.name,
}),
},
});
window.location.reload();
}
@action
async toggleUserSelectable() {
let oldUserSelectable = this.args.theme.user_selectable;
this.args.theme.set("user_selectable", !oldUserSelectable);
this.dMenu.close();
const changesSaved = await this.args.theme.saveChanges("user_selectable");
if (!changesSaved) {
this.args.theme.set("user_selectable", oldUserSelectable);
return;
}
this.toasts.success({
data: {
message: i18n("admin.customize.theme.setting_was_saved"),
},
});
}
@action
updateTheme() {
if (this.isUpdating) {
return;
}
this.isUpdating = true;
this.args.theme
.updateToLatest()
.then(() => {
this.toasts.success({
data: {
message: i18n("admin.customize.theme.update_success", {
theme: this.args.theme.name,
}),
},
});
})
.catch(popupAjaxError)
.finally(() => {
this.isUpdating = false;
});
}
@action
destroyTheme() {
return this.dialog.deleteConfirm({
title: i18n("admin.customize.delete_confirm", {
theme_name: this.args.theme.name,
}),
didConfirm: async () => {
try {
await this.args.theme.destroyRecord();
this.args.allThemes.removeObject(this.args.theme);
this.toasts.success({
data: {
message: i18n("admin.customize.theme.delete_success", {
theme: this.args.theme.name,
}),
},
});
} catch (error) {
popupAjaxError(error);
}
},
});
}
<template>
<AdminConfigAreaCard
class={{this.themeCardClasses}}
data-theme-id={{@theme.id}}
>
<:content>
<div class="theme-card__image-wrapper">
{{#if @theme.screenshot_url}}
<img
class="theme-card__image"
src={{@theme.screenshot_url}}
alt={{@theme.name}}
/>
{{else}}
<ThemesGridPlaceholder @theme={{@theme}} />
{{/if}}
</div>
<div class="theme-card__content">
<a class="theme-card__title" href={{this.editUrl}}>{{@theme.name}}</a>
{{#if @theme.description}}
<p class="theme-card__description">{{@theme.description}}</p>
{{/if}}
</div>
<div class="theme-card__footer">
<div class="theme-card__badges">
{{#if @theme.isPendingUpdates}}
<span
title={{i18n "admin.customize.theme.updates_available_tooltip"}}
class="theme-card__badge"
>{{icon "arrows-rotate"}}
{{i18n "admin.customize.theme.update_available"}}</span>
{{/if}}
{{#if @theme.default}}
<span
class="theme-card__badge --default"
title={{i18n "admin.customize.theme.default_theme"}}
>{{icon "paintbrush"}}
{{i18n "admin.customize.theme.default"}}</span>
{{/if}}
{{#if @theme.user_selectable}}
<span
title={{i18n "admin.customize.theme.user_selectable"}}
class="theme-card__badge --selectable"
>{{icon "user-check"}}
{{i18n
"admin.customize.theme.user_selectable_badge_label"
}}</span>
{{/if}}
</div>
<div class="theme-card__controls">
<DButton
@translatedLabel={{i18n "admin.customize.theme.edit"}}
@route="adminCustomizeThemes.show"
@routeModels={{this.themeRouteModels}}
class="btn-secondary theme-card__button edit"
@preventFocus={{true}}
/>
<div class="theme-card__footer-actions">
<DMenu
@identifier="theme-card__footer-menu"
@triggerClass="theme-card__footer-menu btn-flat"
@onRegisterApi={{this.onRegisterApi}}
@modalForMobile={{true}}
@icon="ellipsis"
>
<:content>
<DropdownMenu as |dropdown|>
{{! TODO: Jordan
solutions for broken, disabled states }}
<dropdown.item>
<DButton
@action={{this.setDefault}}
@preventFocus={{true}}
@icon={{if @theme.default "star" "far-star"}}
class="theme-card__button set-default"
@translatedLabel={{i18n
(if
@theme.default
"admin.customize.theme.default_theme"
"admin.customize.theme.set_default_theme"
)
}}
@disabled={{@theme.default}}
/>
</dropdown.item>
{{#if @theme.isPendingUpdates}}
<dropdown.item>
<DButton
@action={{this.updateTheme}}
@icon="cloud-arrow-down"
class="theme-card__button update"
@preventFocus={{true}}
@translatedLabel={{i18n
"admin.customize.theme.update_to_latest"
}}
/>
</dropdown.item>
{{/if}}
<dropdown.item>
<DButton
@action={{this.toggleUserSelectable}}
@preventFocus={{true}}
@icon={{if
@theme.user_selectable
"user-xmark"
"user-check"
}}
class="theme-card__button set-selectable"
@translatedLabel={{i18n
(if
@theme.user_selectable
"admin.customize.theme.user_selectable_unavailable_button_label"
"admin.customize.theme.user_selectable_button_label"
)
}}
/>
</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 preview"
>{{icon "eye"}}
{{i18n "admin.customize.theme.preview"}}</a>
</dropdown.item>
<dropdown.item>
<DButton
@action={{this.destroyTheme}}
@label="admin.customize.delete"
@icon="trash-can"
@disabled={{this.destroyDisabled}}
class="theme-card__button btn-danger btn-transparent delete"
/>
</dropdown.item>
</DropdownMenu>
</:content>
</DMenu>
</div>
</div>
</div>
</:content>
</AdminConfigAreaCard>
</template>
}