diff --git a/package.json b/package.json index d69688a6..77a0e7ba 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "lint": "yarn run lint:types", "test": "node test/badges.js && node test/redirects.js", "precommit-redirect": "node build-scripts/sort-redirects.js && npx prettier -w redirect.json && git add redirect.json", - "precommit-badges": "node build-scripts/create-badges.js && git add public/badges" + "precommit-badges": "node build-scripts/create-badges.js && git add public/badges", + "start": "./script/develop" }, "pre-commit": [ "precommit-redirect", diff --git a/src-11ty/change-url.html b/src-11ty/change-url.html index 2e0886d9..1dd1b193 100644 --- a/src-11ty/change-url.html +++ b/src-11ty/change-url.html @@ -2,7 +2,7 @@ layout: base_card_with_hero javascript_source: my-change-url.js permalink: "redirect/_change/" -description: Change the URL of your Home Assistant instance. +description: Change the URLs of your Home Assistant instances. --- -

My Home Assistant

- My Home Assistant allows the documentation to link you to specific pages in - your Home Assistant instance. See the FAQ for more - information. + My Home Assistant allows websites to link to specific pages in your Home + Assistant instances. See the FAQ for more information.
-
HOME ASSISTANT INSTANCE
+
HOME ASSISTANT INSTANCES
Loading...
diff --git a/src/components/my-instance-info.ts b/src/components/my-instance-info.ts index b0480eb7..0931ef52 100644 --- a/src/components/my-instance-info.ts +++ b/src/components/my-instance-info.ts @@ -1,31 +1,64 @@ -import { unsafeSVG } from "lit/directives/unsafe-svg.js"; import { html, LitElement, TemplateResult } from "lit"; import { customElement, property } from "lit/decorators.js"; -import { svgPencil } from "./svg-pencil"; +import { Instance } from "../data/instance_info"; @customElement("my-instance-info") class MyInstanceInfo extends LitElement { - @property({ attribute: false }) public instanceUrl!: string | null; + @property({ attribute: false }) public instances!: Instance[] | null; createRenderRoot() { return this; } protected render(): TemplateResult { - if (!this.instanceUrl) { + console.log("this.instances", this.instances); + if (!this.instances?.length) { + console.error("my-instance-info rendered with a empty instance list"); return html``; } return html` -
-
-
HOME ASSISTANT INSTANCE
- - ${this.instanceUrl} +
+
+ ${this.instances.map( + (instance) => + html`` /* + + `,*/, + )} + + + Add Instance
-
`; } diff --git a/src/components/my-url-input.ts b/src/components/my-url-input.ts index 4fa80b16..bdb79810 100644 --- a/src/components/my-url-input.ts +++ b/src/components/my-url-input.ts @@ -1,102 +1,158 @@ import "@material/web/button/filled-button"; +import "@material/web/button/outlined-button"; +import "@material/web/button/text-button"; import "@material/web/textfield/filled-text-field"; +import "@material/web/dialog/dialog"; import type { MdFilledTextField } from "@material/web/textfield/filled-text-field"; -import { css, CSSResult, html, LitElement, TemplateResult } from "lit"; +import { html, LitElement, PropertyValues, TemplateResult } from "lit"; import { customElement, state, query, property } from "lit/decorators.js"; -import { DEFAULT_HASS_URL } from "../const"; +import { DEFAULT_INSTANCE_NAME, DEFAULT_INSTANCE_URL } from "../const"; import { fireEvent } from "../util/fire_event"; - -const HASS_URL = "hassUrl"; +import { Instance } from "../data/instance_info"; +import { unsafeSVG } from "lit/directives/unsafe-svg.js"; +import { svgPencil } from "./svg-pencil"; +import { MdDialog } from "@material/web/dialog/dialog"; @customElement("my-url-input") export class MyUrlInputMain extends LitElement { @property() public value?: string; + @property() public instance!: Instance; + @property() public instanceIndex!: number; + + @state() private _instanceName?: string; + @state() private _instanceUrl?: string; - @state() private _error?: string | TemplateResult; + @query("md-dialog", true) private _dialog!: MdDialog; - @query("md-filled-text-field", true) private _textfield!: MdFilledTextField; + _handleEditButtonClick() { + this._instanceName = this.instance.name; + this._instanceUrl = this.instance.url; + this._dialog.show(); + } - public focus(): void { - this.updateComplete.then(() => this._textfield.focus()); + protected firstUpdated(_changedProperties: PropertyValues): void { + // If no instance url is set that means this instance is being added so go ahead and show the edit dialog. + if (this.instance.url === "") { + this._handleEditButtonClick(); + } } protected render(): TemplateResult { return html` - ${this._error ? html`

${this._error}

` : ""} -
- - ${this.value ? "Update" : "Save"} +
+ ${this.instance.name || DEFAULT_INSTANCE_NAME} +
+
+ + ${this.instance.url || DEFAULT_INSTANCE_URL} + + + +
+ ${this.instance.url ? "Edit Instance" : "New Instance"} +
+
+
+ +
+ +
+
+
+ ${this.instance.url !== "" ? html`Delete` : html``} +
+ Cancel + Save +
+
+
+
`; } - private _handleInputKeyDown(ev: KeyboardEvent) { - // Handle pressing enter. - if (ev.key === "Enter") { - this._handleSave(); + private _handleDialogClose() { + switch (this._dialog.returnValue) { + case "save": + fireEvent(this, "save", { + instanceIndex: this.instanceIndex, + instanceName: this._instanceName, + instanceUrl: this._instanceUrl, + }); + return; + case "delete": + fireEvent(this, "delete", { instanceIndex: this.instanceIndex }); + return; + case "cancel": + if (!this.instance.url) { + // As a special case, if cancelled and url === "" then delete it. + // This happens when adding a new instance. + fireEvent(this, "delete", { instanceIndex: this.instanceIndex }); + } + return; + default: + return; } } private _handleSave() { - const inputEl = this._textfield!; - let value = inputEl.value || ""; - this._error = undefined; + const instanceUrlField = this.renderRoot.querySelector( + "md-filled-text-field#dialog-instance-url", + ) as MdFilledTextField; - if (value === "") { - value = DEFAULT_HASS_URL; - } + let value = instanceUrlField?.value || ""; - if (value.indexOf("://") === -1) { - this._textfield.setCustomValidity( + if (value == "" || value.indexOf("://") === -1) { + instanceUrlField.setCustomValidity( "Please enter your full URL, including the protocol part (https://).", ); - this._textfield.reportValidity(); + instanceUrlField.reportValidity(); return; } let urlObj: URL; try { urlObj = new URL(value); - } catch (err) { - this._textfield.setCustomValidity("Invalid URL"); - this._textfield.reportValidity(); + } catch (err: any) { + instanceUrlField.setCustomValidity("Invalid URL: " + err.toString()); + instanceUrlField.reportValidity(); return; } - const url = `${urlObj.protocol}//${urlObj.host}`; - try { - window.localStorage.setItem(HASS_URL, url); - } catch (err) { - this._error = "Failed to store your URL!"; - return; - } - fireEvent(this, "value-changed", { value: url }); - } + this._instanceName = + ( + this.renderRoot.querySelector( + "md-filled-text-field#dialog-instance-name", + ) as MdFilledTextField + )?.value || DEFAULT_INSTANCE_NAME; + this._instanceUrl = `${urlObj.protocol}//${urlObj.host}`; - static get styles(): CSSResult { - return css` - :host { - display: block; - } - div { - display: flex; - justify-content: space-between; - align-items: center; - } - .error { - color: #db4437; - font-weight: bold; - } - md-filled-text-field { - flex-grow: 1; - margin-right: 8px; - } - `; + this._dialog.close("save"); } } diff --git a/src/const.ts b/src/const.ts index 54e25e99..1b4feea0 100644 --- a/src/const.ts +++ b/src/const.ts @@ -1,5 +1,7 @@ -export const HASS_URL = "hassUrl"; -export const DEFAULT_HASS_URL = "http://homeassistant.local:8123"; +export const HASS_URL = "hassUrl"; // Deprecated. +export const DEFAULT_INSTANCE_NAME = "home assistant instance"; +export const DEFAULT_INSTANCE_URL = "http://homeassistant.local:8123"; +export const DEFAULT_HASS_URL = DEFAULT_INSTANCE_URL; // Deprecated: use DEFAULT_INSTANCE_URL instead; export const MOBILE_URL = "homeassistant://navigate"; export type ParamType = "url" | "string" | "string?" | "url?"; diff --git a/src/data/instance_info.ts b/src/data/instance_info.ts index 29a44afe..22fbb122 100644 --- a/src/data/instance_info.ts +++ b/src/data/instance_info.ts @@ -1,9 +1,44 @@ import { HASS_URL, MOBILE_URL } from "../const"; import { isMobile } from "./is_mobile"; -export const getInstanceUrl = (): string | null => { +const LOCAL_STORAGE_KEY = "instances"; + +export interface Instance { + url: string; + name?: string; +} + +export const getInstances = (): Instance[] | null => { if (isMobile) { - return MOBILE_URL; + return [{ url: MOBILE_URL }]; + } + + // Perform: One-time migration of legacy single URLs. + if (localStorage.getItem(HASS_URL)) { + localStorage.setItem( + LOCAL_STORAGE_KEY, + JSON.stringify([{ url: localStorage.getItem(HASS_URL) }]), + ); + localStorage.removeItem(HASS_URL); + } + + const raw = localStorage.getItem(LOCAL_STORAGE_KEY); + if (!raw) { + return null; + } + + try { + return JSON.parse(raw); + } catch (e) { + console.error("failed to parse stored urls", e); + return []; + } +}; + +export const saveInstances = (instances: Instance[] | null) => { + if (!instances) { + localStorage.removeItem(LOCAL_STORAGE_KEY); + return; } - return localStorage.getItem(HASS_URL); + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(instances)); }; diff --git a/src/entrypoints/my-change-url.ts b/src/entrypoints/my-change-url.ts index 4fd3e0ff..e205f4a2 100644 --- a/src/entrypoints/my-change-url.ts +++ b/src/entrypoints/my-change-url.ts @@ -1,7 +1,7 @@ import { html, LitElement, TemplateResult, PropertyValues } from "lit"; import { state, query, customElement } from "lit/decorators.js"; import "../components/my-url-input"; -import { getInstanceUrl } from "../data/instance_info"; +import { getInstances } from "../data/instance_info"; import { extractSearchParamsObject } from "../util/search-params"; import { MyUrlInputMain } from "../components/my-url-input"; import { isMobile } from "../data/is_mobile"; @@ -10,7 +10,7 @@ const changeRequestedFromRedirect = extractSearchParamsObject().redirect; @customElement("my-change-url") class MyChangeUrl extends LitElement { - @state() private _instanceUrl = getInstanceUrl(); + @state() private _instances = getInstances(); @state() private _error?: string; @@ -44,7 +44,7 @@ class MyChangeUrl extends LitElement { } protected shouldUpdate() { - return this._instanceUrl !== undefined; + return this._instances !== undefined; } protected render(): TemplateResult { @@ -55,7 +55,7 @@ class MyChangeUrl extends LitElement { } return html` - ${changeRequestedFromRedirect && !this._instanceUrl + ${changeRequestedFromRedirect && !this._instances ? html`
You are seeing this page because you have been linked to a page in @@ -66,7 +66,7 @@ class MyChangeUrl extends LitElement { ` : ""}
- ${this._instanceUrl + ${this._instances ? html`

Configure My Home Assistant by entering the URL of your Home Assistant instance. @@ -74,13 +74,13 @@ class MyChangeUrl extends LitElement { : ""} ${this._error ? html`

${this._error}
` : ""} -

Note: This URL is only stored in your browser.

+

Note: The URLs are only stored in your browser.

`; } diff --git a/src/entrypoints/my-index.ts b/src/entrypoints/my-index.ts index aecc99d3..3deaeeed 100644 --- a/src/entrypoints/my-index.ts +++ b/src/entrypoints/my-index.ts @@ -1,92 +1,133 @@ -import { html, LitElement, TemplateResult, PropertyValues } from "lit"; +import "@material/web/button/outlined-button"; + +import { html, LitElement, TemplateResult } from "lit"; import { customElement, state, query } from "lit/decorators.js"; import "../components/my-url-input"; import "../components/my-instance-info"; -import { getInstanceUrl } from "../data/instance_info"; -import { MyUrlInputMain } from "../components/my-url-input"; +import { getInstances, Instance, saveInstances } from "../data/instance_info"; + @customElement("my-index") class MyIndex extends LitElement { - @state() private _updatingUrl = false; - - @state() private _instanceUrl!: string | null; - - @state() private _error?: string; - - @query("my-url-input") private _urlInput?: MyUrlInputMain; - - createRenderRoot() { - while (this.lastChild) { - this.removeChild(this.lastChild); - } - return this; - } + @state() private _instances!: Instance[] | null; public connectedCallback() { super.connectedCallback(); - this._instanceUrl = getInstanceUrl(); - if (!this._updatingUrl && !this._instanceUrl) { - this._updatingUrl = true; - } - } - - protected updated(changedProps: PropertyValues) { - if (changedProps.has("_updatingUrl") && this._updatingUrl) { - this.updateComplete.then(() => this._urlInput?.focus()); - } + this._instances = getInstances(); } protected shouldUpdate() { - return this._instanceUrl !== undefined; + return this._instances !== undefined; } - protected render(): TemplateResult { - if (this._updatingUrl) { - return html` -
- ${!this._instanceUrl - ? html` -

- Configure My Home Assistant by entering the URL of your Home - Assistant instance. -

- ` - : ""} - - - - ${this._error ? html`
${this._error}
` : ""} - -

Note: This URL is only stored in your browser.

-
- `; - } - + /*protected render(): TemplateResult { return html` `; + }*/ + + protected render(): TemplateResult { + console.log("this.instances", this._instances); + return html` +
+
+ ${this._instances?.map( + (instance, index) => + html`` /* +
+
+ ${instance.name || "HOME ASSISTANT INSTANCE"} +
+
+ + ${instance.url} + + +
+
+ `,*/, + )} + + Add Instance +
+
+ `; } - private _handleEdit() { - this._updatingUrl = true; + _handleNewInstanceClick() { + if (!this._instances) { + this._instances = [ + { + url: "", + }, + ]; + } else { + this._instances = [...this._instances, { url: "" }]; + } } - private _handleUrlChanged(ev: CustomEvent) { - const instanceUrl = ev.detail.value; + _handleSave(ev: CustomEvent) { + const instanceIndex = ev.detail.instanceIndex; + if (instanceIndex === undefined) { + return; + } - if (!instanceUrl) { - this._error = "You need to configure a URL to use My Home Assistant."; + const instance: Instance = { + name: ev.detail.instanceName, + url: ev.detail.instanceUrl, + }; + // In-place operations (such as .pop) don't trigger re-renders so use a temporary array, mutate it, and then set this._instances. + let tmp = [...(this._instances || [])]; + if (!this._instances) { + tmp = [instance]; + return; + } else if (this._instances.length >= instanceIndex) { + tmp[instanceIndex] = instance; + } else { + tmp.push(instance); + } + this._instances = tmp; + saveInstances(this._instances); + } + _handleDelete(ev: CustomEvent) { + const instanceIndex = ev.detail.instanceIndex; + if (instanceIndex === undefined) { return; } - this._error = undefined; - this._updatingUrl = false; - this._instanceUrl = instanceUrl; + // .splice changes the array in place which does not trigger a re-render. + // By using a temporary copy of the array, splicing it, and then setting this._instances to that spliced value + // a re-render is triggered. + const tmp = [...(this._instances || [])]; + tmp.splice(instanceIndex, 1) || null; + this._instances = tmp; + saveInstances(this._instances); } } diff --git a/src/entrypoints/my-redirect.ts b/src/entrypoints/my-redirect.ts index 824c4783..79dea281 100644 --- a/src/entrypoints/my-redirect.ts +++ b/src/entrypoints/my-redirect.ts @@ -4,7 +4,7 @@ import { createSearchParam, extractSearchParamsObject, } from "../util/search-params"; -import { getInstanceUrl } from "../data/instance_info"; +import { getInstances } from "../data/instance_info"; import { Redirect } from "../const"; import { svgPencil } from "../components/svg-pencil"; import { isMobile } from "../data/is_mobile"; @@ -40,7 +40,7 @@ const createRedirectParams = (): string => { let changingInstance = false; const render = (showTroubleshooting: boolean) => { - const instanceUrl = getInstanceUrl(); + const instances = getInstances(); let params; try { @@ -59,7 +59,7 @@ const render = (showTroubleshooting: boolean) => { window.redirect.redirect + "/" + params, )}`; - if (instanceUrl === null) { + if (instances === null) { changingInstance = true; document.location.assign(changeUrl); return; @@ -67,8 +67,8 @@ const render = (showTroubleshooting: boolean) => { const redirectUrl = window.redirect.redirect === "oauth" - ? `${instanceUrl}/auth/external/callback${params}` - : `${instanceUrl}/_my_redirect/${window.redirect.redirect}${params}`; + ? `${instances[0]}/auth/external/callback${params}` + : `${instances[0]}/_my_redirect/${window.redirect.redirect}${params}`; const openLink = document.querySelector(".open-link") as HTMLElement; openLink.outerHTML = `