discourse/app/assets/javascripts/admin/addon/components/themes-grid-card.gjs
Kris e73dc15a79
UX: make admin list item headings clickable (#34772)
This makes the headings in various admin lists clickable as an
additional affordance, as it's somewhat natural to expect headings in
lists to be clickable!

This includes the following admin areas: 
* Themes
* Theme Components
* Color palettes
* Plugins
* User fields
* Custom flags

---------

Co-authored-by: Gary Pendergast <gary@pento.net>
2025-09-15 09:08:50 -04:00

317 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}}>
<: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>
}