Compare commits

..

9 Commits

Author SHA1 Message Date
uptimeZERO_
88f35d8ec2 Merge branch 'dev' into persist-theme-settings-in-backend 2026-01-14 09:46:38 +00:00
Pavilion
3fda44b56d added notification if theme save fails 2026-01-14 09:46:00 +00:00
Pavilion
362744360f returning no-op unsub to handle rejection path 2026-01-14 09:20:08 +00:00
uptimeZERO_
3cdc8d61d8 Merge branch 'dev' into persist-theme-settings-in-backend 2026-01-14 08:39:30 +00:00
Pavilion Sahota
27dce6670e Merge remote-tracking branch 'origin/persist-theme-settings-in-backend' into persist-theme-settings-in-backend 2026-01-14 08:39:05 +00:00
Pavilion
c2632500ec using SubscribeMixin 2026-01-14 08:37:44 +00:00
uptimeZERO_
f92635bb5f Merge branch 'dev' into persist-theme-settings-in-backend 2026-01-13 15:35:59 +00:00
Pavilion
e612bf09d7 fixed type mismatch 2026-01-13 15:29:13 +00:00
Pavilion
435d3ee36f Moved theme functionality and persistence target 2026-01-13 15:02:22 +00:00
11 changed files with 168 additions and 29 deletions

18
src/data/theme.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { HomeAssistant, ThemeSettings } from "../types";
import { saveFrontendUserData, subscribeFrontendUserData } from "./frontend";
declare global {
interface FrontendUserData {
theme: ThemeSettings;
}
}
export const subscribeThemePreferences = (
hass: HomeAssistant,
callback: (data: { value: ThemeSettings | null }) => void
) => subscribeFrontendUserData(hass.connection, "theme", callback);
export const saveThemePreferences = (
hass: HomeAssistant,
data: ThemeSettings
) => saveFrontendUserData(hass.connection, "theme", data);

View File

@@ -381,12 +381,12 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
},
voice_assistants: {
title: localize(
"ui.panel.config.voice_assistants.expose.headers.assistants"
"ui.panel.config.automation.picker.headers.voice_assistants"
),
type: "flex",
type: "icon",
defaultHidden: true,
minWidth: "160px",
maxWidth: "160px",
minWidth: "100px",
maxWidth: "100px",
template: (automation) => {
const exposedToVoiceAssistantIds = getEntityVoiceAssistantsIds(
this._entityReg,

View File

@@ -498,12 +498,12 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
},
voice_assistants: {
title: localize(
"ui.panel.config.voice_assistants.expose.headers.assistants"
"ui.panel.config.entities.picker.headers.voice_assistants"
),
type: "flex",
type: "icon",
defaultHidden: true,
minWidth: "160px",
maxWidth: "160px",
minWidth: "100px",
maxWidth: "100px",
template: (entry) => {
const exposedToVoiceAssistantIds = getEntityVoiceAssistantsIds(
this._entities,

View File

@@ -487,12 +487,12 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
},
voice_assistants: {
title: localize(
"ui.panel.config.voice_assistants.expose.headers.assistants"
"ui.panel.config.helpers.picker.headers.voice_assistants"
),
type: "flex",
type: "icon",
defaultHidden: true,
minWidth: "160px",
maxWidth: "160px",
minWidth: "100px",
maxWidth: "100px",
template: (helper) => {
const exposedToVoiceAssistantIds = getEntityVoiceAssistantsIds(
this._entityReg,

View File

@@ -415,12 +415,12 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
},
voice_assistants: {
title: localize(
"ui.panel.config.voice_assistants.expose.headers.assistants"
"ui.panel.config.scene.picker.headers.voice_assistants"
),
type: "flex",
type: "icon",
defaultHidden: true,
minWidth: "160px",
maxWidth: "160px",
minWidth: "100px",
maxWidth: "100px",
template: (scene) => {
const exposedToVoiceAssistantIds = getEntityVoiceAssistantsIds(
this._entityReg,

View File

@@ -403,12 +403,12 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
},
voice_assistants: {
title: localize(
"ui.panel.config.voice_assistants.expose.headers.assistants"
"ui.panel.config.script.picker.headers.voice_assistants"
),
type: "flex",
type: "icon",
defaultHidden: true,
minWidth: "160px",
maxWidth: "160px",
minWidth: "100px",
maxWidth: "100px",
template: (script) => {
const exposedToVoiceAssistantIds = getEntityVoiceAssistantsIds(
this._entityReg,

View File

@@ -15,20 +15,41 @@ import {
DefaultAccentColor,
DefaultPrimaryColor,
} from "../../resources/theme/color/color.globals";
import type { HomeAssistant } from "../../types";
import {
saveThemePreferences,
subscribeThemePreferences,
} from "../../data/theme";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import type { HomeAssistant, ThemeSettings } from "../../types";
import { documentationUrl } from "../../util/documentation-url";
import { clearSelectedThemeState, getState } from "../../util/ha-pref-storage";
const USE_DEFAULT_THEME = "__USE_DEFAULT_THEME__";
const HOME_ASSISTANT_THEME = "default";
@customElement("ha-pick-theme-row")
export class HaPickThemeRow extends LitElement {
export class HaPickThemeRow extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@state() _themeNames: string[] = [];
@state() private _backendTheme?: ThemeSettings | null;
@state() private _migrating = false;
protected hassSubscribe() {
return [
subscribeThemePreferences(this.hass, ({ value }) => {
this._backendTheme = value;
}).catch(() => {
this._backendTheme = undefined;
return () => undefined;
}),
];
}
protected render(): TemplateResult {
const hasThemes =
this.hass.themes.themes && Object.keys(this.hass.themes.themes).length;
@@ -41,6 +62,11 @@ export class HaPickThemeRow extends LitElement {
: this.hass.themes.default_theme;
const themeSettings = this.hass.selectedTheme;
const localTheme = this._getLocalTheme();
const showMigration =
this._backendTheme !== undefined &&
this._backendTheme === null &&
localTheme !== null;
return html`
<ha-settings-row .narrow=${this.narrow}>
@@ -159,6 +185,28 @@ export class HaPickThemeRow extends LitElement {
: ""}
</div>`
: ""}
${showMigration
? html`
<ha-settings-row .narrow=${this.narrow}>
<span slot="heading">
${this.hass.localize("ui.panel.profile.themes.migrate_header")}
</span>
<span slot="description">
${this.hass.localize(
"ui.panel.profile.themes.migrate_description"
)}
</span>
<ha-button
appearance="plain"
size="small"
.disabled=${this._migrating}
@click=${this._migrateThemePreferences}
>
${this.hass.localize("ui.panel.profile.themes.migrate_button")}
</ha-button>
</ha-settings-row>
`
: ""}
`;
}
@@ -236,6 +284,31 @@ export class HaPickThemeRow extends LitElement {
});
}
private _getLocalTheme(): ThemeSettings | null {
return getState().selectedTheme ?? null;
}
private async _migrateThemePreferences() {
const localTheme = this._getLocalTheme();
if (!localTheme) {
return;
}
this._migrating = true;
try {
await saveThemePreferences(this.hass, localTheme);
clearSelectedThemeState();
fireEvent(this, "hass-notification", {
message: this.hass.localize("ui.panel.profile.themes.migrate_success"),
});
} catch (_err: any) {
fireEvent(this, "hass-notification", {
message: this.hass.localize("ui.panel.profile.themes.migrate_failed"),
});
} finally {
this._migrating = false;
}
}
static styles = css`
a {
color: var(--primary-color);

View File

@@ -154,6 +154,10 @@ class HaProfileSectionGeneral extends LitElement {
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-first-weekday-row>
<ha-pick-theme-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-theme-row>
<ha-pick-dashboard-row
.narrow=${this.narrow}
.hass=${this.hass}
@@ -208,10 +212,6 @@ class HaProfileSectionGeneral extends LitElement {
<div class="card-content">
${this.hass.localize("ui.panel.profile.client_settings_detail")}
</div>
<ha-pick-theme-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-theme-row>
${this.hass.dockedSidebar !== "auto" || !this.narrow
? html`
<ha-force-narrow-row

View File

@@ -2,7 +2,9 @@ import {
applyThemesOnElement,
invalidateThemeCache,
} from "../common/dom/apply_themes_on_element";
import { fireEvent } from "../common/dom/fire_event";
import type { HASSDomEvent } from "../common/dom/fire_event";
import { subscribeThemePreferences, saveThemePreferences } from "../data/theme";
import { subscribeThemes } from "../data/ws-themes";
import type { Constructor, HomeAssistant } from "../types";
import { storeState } from "../util/ha-pref-storage";
@@ -24,6 +26,10 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
class extends superClass {
private _themeApplied = false;
private _themePrefsAvailable = false;
private _themeSaveFailedNotified = false;
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
this.addEventListener("settheme", (ev) => {
@@ -34,7 +40,23 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
},
});
this._applyTheme(mql.matches);
storeState(this.hass!);
if (this._themePrefsAvailable) {
saveThemePreferences(this.hass!, this.hass!.selectedTheme!).catch(
() => {
storeState(this.hass!);
if (!this._themeSaveFailedNotified) {
this._themeSaveFailedNotified = true;
fireEvent(this, "hass-notification", {
message: this.hass!.localize(
"ui.notification_toast.theme_save_failed"
),
});
}
}
);
} else {
storeState(this.hass!);
}
});
mql.addListener((ev) => this._applyTheme(ev.matches));
if (!this._themeApplied && mql.matches) {
@@ -63,6 +85,17 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
invalidateThemeCache();
this._applyTheme(mql.matches);
});
subscribeThemePreferences(this.hass!, ({ value }) => {
this._themePrefsAvailable = true;
if (!value) {
return;
}
this._updateHass({ selectedTheme: value });
this._applyTheme(mql.matches);
}).catch(() => {
this._themePrefsAvailable = false;
});
}
private _applyTheme(darkPreferred: boolean) {

View File

@@ -2234,7 +2234,8 @@
"dismiss": "Dismiss",
"no_matching_link_found": "No matching My link found for {path}",
"new_version_available": "A new version of the frontend is available.",
"reload": "Reload"
"reload": "Reload",
"theme_save_failed": "Unable to save theme settings to your user profile. Your changes are saved locally to the browser for now."
},
"sidebar": {
"external_app_configuration": "App settings",
@@ -3341,7 +3342,8 @@
"type": "Type",
"editable": "Editable",
"category": "Category",
"area": "Area"
"area": "Area",
"voice_assistants": "Voice assistants"
},
"create_helper": "Create helper",
"no_helpers": "Looks like you don't have any helpers yet!",
@@ -8945,6 +8947,11 @@
"error_no_theme": "No themes available.",
"link_promo": "Learn about themes",
"dropdown_label": "Theme",
"migrate_header": "Migrate theme settings",
"migrate_description": "Save your theme selection to your Home Assistant user profile.",
"migrate_button": "Migrate",
"migrate_success": "Theme settings migrated.",
"migrate_failed": "Failed to migrate theme settings.",
"dark_mode": {
"auto": "Auto",
"light": "Light",

View File

@@ -56,3 +56,11 @@ export function getState(): Partial<StoredHomeAssistant> {
export function clearState() {
window.localStorage.clear();
}
export function clearSelectedThemeState() {
try {
window.localStorage.removeItem("selectedTheme");
} catch (_err: any) {
// Ignore storage errors (private mode, full storage).
}
}