Add more info alarm control panel (#15893)

* Add more info alarm control panel

* Improve buttons sizes

* Add triggered, arming and pending state

* Add keypad

* Improve alarm code dialog

* Fix code condition

* Clean code

* Fix mode selection with code

* Use nothing
This commit is contained in:
Paul Bottein 2023-03-28 16:31:25 +02:00 committed by GitHub
parent 3a700aebcc
commit 48c74c8660
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 671 additions and 184 deletions

View File

@ -85,6 +85,7 @@ export class HaControlButton extends LitElement {
--control-button-background-opacity: 0.2;
--control-button-border-radius: 10px;
--mdc-icon-size: 20px;
color: var(--primary-text-color);
width: 40px;
height: 40px;
-webkit-tap-highlight-color: transparent;
@ -110,6 +111,8 @@ export class HaControlButton extends LitElement {
--mdc-ripple-color: var(--control-button-background-color);
/* For safari border-radius overflow */
z-index: 0;
font-size: inherit;
color: inherit;
}
.button::before {
content: "";

View File

@ -100,10 +100,11 @@ export class HaControlSelect extends LitElement {
private _handleKeydown(ev: KeyboardEvent) {
if (!this.options || this._activeIndex == null || this.disabled) return;
const value = this.options[this._activeIndex].value;
switch (ev.key) {
case " ":
this.value = this.options[this._activeIndex].value;
fireEvent(this, "value-changed", { value: this.value });
this.value = value;
fireEvent(this, "value-changed", { value });
break;
case "ArrowUp":
case "ArrowLeft":
@ -132,7 +133,7 @@ export class HaControlSelect extends LitElement {
if (this.disabled) return;
const value = (ev.target as any).value;
this.value = value;
fireEvent(this, "value-changed", { value: this.value });
fireEvent(this, "value-changed", { value });
}
private _handleOptionMouseDown(ev: MouseEvent) {

View File

@ -1,3 +1,7 @@
import {
HassEntityAttributeBase,
HassEntityBase,
} from "home-assistant-js-websocket";
import { HomeAssistant } from "../types";
export const FORMAT_TEXT = "text";
@ -12,6 +16,16 @@ export const enum AlarmControlPanelEntityFeature {
ARM_VACATION = 32,
}
interface AlarmControlPanelEntityAttributes extends HassEntityAttributeBase {
code_format?: "text" | "number";
changed_by?: string | null;
code_arm_required?: boolean;
}
export interface AlarmControlPanelEntity extends HassEntityBase {
attributes: AlarmControlPanelEntityAttributes;
}
export const callAlarmAction = (
hass: HomeAssistant,
entity: string,

View File

@ -0,0 +1,248 @@
import "@material/web/button/filled-button";
import "@material/web/iconbutton/filled-icon-button";
import { mdiCheck, mdiClose } from "@mdi/js";
import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-button";
import "../../../../components/ha-control-button";
import { createCloseHeading } from "../../../../components/ha-dialog";
import "../../../../components/ha-textfield";
import type { HaTextField } from "../../../../components/ha-textfield";
import { HomeAssistant } from "../../../../types";
import { HassDialog } from "../../../make-dialog-manager";
import { EnterCodeDialogParams } from "./show-enter-code-dialog";
const BUTTONS = [
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"0",
"clear",
"submit",
];
@customElement("dialog-enter-code")
export class DialogEnterCode
extends LitElement
implements HassDialog<EnterCodeDialogParams>
{
@property({ attribute: false }) public hass?: HomeAssistant;
@state() private _dialogParams?: EnterCodeDialogParams;
@query("#code") private _input?: HaTextField;
@state() private _showClearButton = false;
public async showDialog(dialogParams: EnterCodeDialogParams): Promise<void> {
this._dialogParams = dialogParams;
await this.updateComplete;
}
public closeDialog(): void {
if (this._dialogParams?.cancel) {
this._dialogParams.cancel();
}
this._dialogParams = undefined;
this._showClearButton = false;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
private _submit(): void {
this._dialogParams?.submit?.(this._input?.value ?? "");
this._dialogParams = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
private _numberClick(e: MouseEvent): void {
const val = (e.currentTarget! as any).value;
this._input!.value = this._input!.value + val;
this._showClearButton = true;
}
private _clear(): void {
this._input!.value = "";
this._showClearButton = false;
}
private _inputValueChange(e) {
const val = (e.currentTarget! as any).value;
this._showClearButton = !!val;
}
protected render() {
if (!this._dialogParams || !this.hass) {
return nothing;
}
const isText = this._dialogParams.codeFormat === "text";
if (isText) {
return html`
<ha-dialog
open
@closed=${this.closeDialog}
defaultAction="ignore"
.heading=${this._dialogParams.title ??
this.hass.localize("ui.dialogs.enter_code.title")}
>
<ha-textfield
class="input"
dialogInitialFocus
id="code"
.label=${this.hass.localize("ui.dialogs.enter_code.input_label")}
type="password"
input-mode="text"
></ha-textfield>
<ha-button @click=${this.closeDialog} slot="secondaryAction">
${this._dialogParams.cancelText ??
this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button @click=${this._submit} slot="primaryAction">
${this._dialogParams.submitText ??
this.hass.localize("ui.common.submit")}
</ha-button>
</ha-dialog>
`;
}
return html`
<ha-dialog
open
.heading=${createCloseHeading(
this.hass,
this._dialogParams.title ?? "Enter code"
)}
@closed=${this.closeDialog}
hideActions
>
<div class="container">
<ha-textfield
@input=${this._inputValueChange}
id="code"
.label=${this.hass.localize("ui.dialogs.enter_code.input_label")}
type="password"
input-mode="numeric"
></ha-textfield>
<div class="keypad">
${BUTTONS.map((value) =>
value === ""
? html`<span></span>`
: value === "clear"
? html`
<ha-control-button
@click=${this._clear}
class="clear"
.disabled=${!this._showClearButton}
.label=${this.hass!.localize("ui.common.clear")}
>
<ha-svg-icon path=${mdiClose}></ha-svg-icon>
</ha-control-button>
`
: value === "submit"
? html`
<ha-control-button
@click=${this._submit}
class="submit"
.label=${this._dialogParams!.submitText ??
this.hass!.localize("ui.common.submit")}
>
<ha-svg-icon path=${mdiCheck}></ha-svg-icon>
</ha-control-button>
`
: html`
<ha-control-button
.value=${value}
@click=${this._numberClick}
.label=${value}
>
${value}
</ha-control-button>
`
)}
</div>
</div>
</ha-dialog>
`;
}
static get styles(): CSSResultGroup {
return css`
ha-dialog {
--mdc-dialog-heading-ink-color: var(--primary-text-color);
--mdc-dialog-content-ink-color: var(--primary-text-color);
/* Place above other dialogs */
--dialog-z-index: 104;
}
ha-textfield {
width: 100%;
max-width: 300px;
margin: auto;
}
.container {
display: flex;
align-items: center;
flex-direction: column;
}
.keypad {
--keypad-columns: 3;
margin-top: 12px;
padding: 12px;
display: grid;
grid-template-columns: repeat(var(--keypad-columns), auto);
grid-auto-rows: auto;
grid-gap: 24px;
justify-items: center;
align-items: center;
}
.clear {
grid-row-start: 4;
grid-column-start: 0;
}
@media all and (max-height: 450px) {
.keypad {
--keypad-columns: 6;
}
.clear {
grid-row-start: 1;
grid-column-start: 6;
}
}
ha-control-button {
width: 56px;
height: 56px;
--control-button-border-radius: 28px;
--mdc-icon-size: 24px;
font-size: 24px;
}
.submit {
--control-button-background-color: var(--green-color);
--control-button-icon-color: var(--green-color);
}
.clear {
--control-button-background-color: var(--red-color);
--control-button-icon-color: var(--red-color);
}
.hidden {
display: none;
}
.buttons {
margin-top: 12px;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-enter-code": DialogEnterCode;
}
}

View File

@ -0,0 +1,216 @@
import {
mdiAirplane,
mdiHome,
mdiLock,
mdiMoonWaningCrescent,
mdiShield,
mdiShieldOff,
} from "@mdi/js";
import { HassEntity } from "home-assistant-js-websocket";
import { css, CSSResultGroup, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { computeAttributeNameDisplay } from "../../../../common/entity/compute_attribute_display";
import { stateColorCss } from "../../../../common/entity/state_color";
import { supportsFeature } from "../../../../common/entity/supports-feature";
import "../../../../components/ha-control-select";
import type { ControlSelectOption } from "../../../../components/ha-control-select";
import "../../../../components/ha-control-slider";
import {
AlarmControlPanelEntity,
AlarmControlPanelEntityFeature,
} from "../../../../data/alarm_control_panel";
import { HomeAssistant } from "../../../../types";
import { showEnterCodeDialogDialog } from "./show-enter-code-dialog";
type AlarmMode =
| "away"
| "home"
| "night"
| "vacation"
| "custom_bypass"
| "disarmed";
type AlarmConfig = {
service: string;
feature?: AlarmControlPanelEntityFeature;
state: string;
path: string;
};
const ALARM_MODES: Record<AlarmMode, AlarmConfig> = {
away: {
feature: AlarmControlPanelEntityFeature.ARM_AWAY,
service: "alarm_arm_away",
state: "armed_away",
path: mdiLock,
},
home: {
feature: AlarmControlPanelEntityFeature.ARM_HOME,
service: "alarm_arm_home",
state: "armed_home",
path: mdiHome,
},
custom_bypass: {
feature: AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS,
service: "alarm_arm_custom_bypass",
state: "armed_custom_bypass",
path: mdiShield,
},
night: {
feature: AlarmControlPanelEntityFeature.ARM_NIGHT,
service: "alarm_arm_night",
state: "armed_night",
path: mdiMoonWaningCrescent,
},
vacation: {
feature: AlarmControlPanelEntityFeature.ARM_VACATION,
service: "alarm_arm_vacation",
state: "armed_vacation",
path: mdiAirplane,
},
disarmed: {
service: "alarm_disarm",
state: "disarmed",
path: mdiShieldOff,
},
};
@customElement("ha-more-info-alarm_control_panel-modes")
export class HaMoreInfoAlarmControlPanelModes extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj!: AlarmControlPanelEntity;
@state() _currentMode?: AlarmMode;
private _modes = memoizeOne((stateObj: AlarmControlPanelEntity) => {
const modes = Object.keys(ALARM_MODES) as AlarmMode[];
return modes.filter((mode) => {
const feature = ALARM_MODES[mode as AlarmMode].feature;
return !feature || supportsFeature(stateObj, feature);
});
});
protected updated(changedProp: Map<string | number | symbol, unknown>): void {
super.updated(changedProp);
if (changedProp.has("stateObj") && this.stateObj) {
const oldStateObj = changedProp.get("stateObj") as HassEntity | undefined;
if (!oldStateObj || this.stateObj.state !== oldStateObj.state) {
this._currentMode = this._getCurrentMode(this.stateObj);
}
}
}
private _getCurrentMode(stateObj: AlarmControlPanelEntity) {
return this._modes(stateObj).find(
(mode) => ALARM_MODES[mode].state === stateObj.state
);
}
private async _valueChanged(ev: CustomEvent) {
const mode = (ev.detail as any).value as AlarmMode;
this._currentMode = mode;
const { state: modeState, service } = ALARM_MODES[mode];
if (modeState === this.stateObj.state) return;
let code: string | undefined;
if (
(mode !== "disarmed" &&
this.stateObj.attributes.code_arm_required &&
this.stateObj.attributes.code_format) ||
(mode === "disarmed" && this.stateObj.attributes.code_format)
) {
const disarm = mode === "disarmed";
const response = await showEnterCodeDialogDialog(this, {
codeFormat: this.stateObj.attributes.code_format,
title: this.hass.localize(
`ui.dialogs.more_info_control.alarm_control_panel.${
disarm ? "disarm_title" : "arm_title"
}`
),
submitText: this.hass.localize(
`ui.dialogs.more_info_control.alarm_control_panel.${
disarm ? "disarm_action" : "arm_action"
}`
),
});
if (!response) {
this._currentMode = this._getCurrentMode(this.stateObj);
return;
}
code = response;
}
try {
await this.hass.callService("alarm_control_panel", service, {
entity_id: this.stateObj!.entity_id,
code,
});
} catch (_err) {
this._currentMode = this._getCurrentMode(this.stateObj);
}
}
protected render() {
const color = stateColorCss(this.stateObj);
const modes = this._modes(this.stateObj);
const options = modes.map<ControlSelectOption>((mode) => ({
value: mode,
label: this.hass.localize(
`ui.dialogs.more_info_control.alarm_control_panel.modes.${mode}`
),
path: ALARM_MODES[mode].path,
}));
return html`
<ha-control-select
vertical
.options=${options}
.value=${this._currentMode}
@value-changed=${this._valueChanged}
no-optimistic-update
.ariaLabel=${computeAttributeNameDisplay(
this.hass.localize,
this.stateObj,
this.hass.entities,
"percentage"
)}
style=${styleMap({
"--control-select-color": color,
"--modes-count": modes.length.toString(),
})}
>
</ha-control-select>
`;
}
static get styles(): CSSResultGroup {
return css`
ha-control-select {
height: 45vh;
max-height: max(320px, var(--modes-count, 1) * 80px);
min-height: max(200px, var(--modes-count, 1) * 80px);
--control-select-thickness: 100px;
--control-select-border-radius: 24px;
--control-select-color: var(--primary-color);
--control-select-background: var(--disabled-color);
--control-select-background-opacity: 0.2;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-more-info-alarm_control_panel-modes": HaMoreInfoAlarmControlPanelModes;
}
}

View File

@ -0,0 +1,39 @@
import { fireEvent } from "../../../../common/dom/fire_event";
export interface EnterCodeDialogParams {
codeFormat: "text" | "number";
submitText?: string;
cancelText?: string;
title?: string;
submit?: (code?: string) => void;
cancel?: () => void;
}
export const showEnterCodeDialogDialog = (
element: HTMLElement,
dialogParams: EnterCodeDialogParams
) =>
new Promise<string | null>((resolve) => {
const origCancel = dialogParams.cancel;
const origSubmit = dialogParams.submit;
fireEvent(element, "show-dialog", {
dialogTag: "dialog-enter-code",
dialogImport: () => import("./dialog-enter-code"),
dialogParams: {
...dialogParams,
cancel: () => {
resolve(null);
if (origCancel) {
origCancel();
}
},
submit: (code: string) => {
resolve(code);
if (origSubmit) {
origSubmit(code);
}
},
},
});
});

View File

@ -17,6 +17,7 @@ export const EDITABLE_DOMAINS_WITH_ID = ["scene", "automation"];
export const EDITABLE_DOMAINS_WITH_UNIQUE_ID = ["script"];
/** Domains with with new more info design. */
export const DOMAINS_WITH_NEW_MORE_INFO = [
"alarm_control_panel",
"cover",
"fan",
"input_boolean",

View File

@ -1,70 +1,46 @@
import "@material/mwc-button";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, PropertyValues, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { supportsFeature } from "../../../common/entity/supports-feature";
import "../../../components/ha-textfield";
import type { HaTextField } from "../../../components/ha-textfield";
import {
AlarmControlPanelEntityFeature,
callAlarmAction,
FORMAT_NUMBER,
} from "../../../data/alarm_control_panel";
import "@material/web/button/outlined-button";
import { mdiShieldOff } from "@mdi/js";
import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { domainIcon } from "../../../common/entity/domain_icon";
import { stateColorCss } from "../../../common/entity/state_color";
import { AlarmControlPanelEntity } from "../../../data/alarm_control_panel";
import type { HomeAssistant } from "../../../types";
const BUTTONS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "", "0", "clear"];
const DISARM_ACTIONS = ["disarm"];
import "../components/alarm_control_panel/ha-more-info-alarm_control_panel-modes";
import { showEnterCodeDialogDialog } from "../components/alarm_control_panel/show-enter-code-dialog";
import { moreInfoControlStyle } from "../components/ha-more-info-control-style";
import "../components/ha-more-info-state-header";
@customElement("more-info-alarm_control_panel")
export class MoreInfoAlarmControlPanel extends LitElement {
class MoreInfoAlarmControlPanel extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj?: HassEntity;
@property({ attribute: false }) public stateObj?: AlarmControlPanelEntity;
@state() private _armActions: string[] = [];
private async _disarm() {
let code: string | undefined;
@query("#alarmCode") private _input?: HaTextField;
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
if (!this.stateObj || !changedProps.has("stateObj")) {
return;
if (this.stateObj!.attributes.code_format) {
const response = await showEnterCodeDialogDialog(this, {
codeFormat: this.stateObj!.attributes.code_format,
title: this.hass.localize(
"ui.dialogs.more_info_control.alarm_control_panel.disarm_title"
),
submitText: this.hass.localize(
"ui.dialogs.more_info_control.alarm_control_panel.disarm_action"
),
});
if (!response) {
return;
}
code = response;
}
this._armActions = [];
if (
supportsFeature(this.stateObj, AlarmControlPanelEntityFeature.ARM_HOME)
) {
this._armActions.push("arm_home");
}
if (
supportsFeature(this.stateObj, AlarmControlPanelEntityFeature.ARM_AWAY)
) {
this._armActions.push("arm_away");
}
if (
supportsFeature(this.stateObj, AlarmControlPanelEntityFeature.ARM_NIGHT)
) {
this._armActions.push("arm_night");
}
if (
supportsFeature(
this.stateObj,
AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS
)
) {
this._armActions.push("arm_custom_bypass");
}
if (
supportsFeature(
this.stateObj,
AlarmControlPanelEntityFeature.ARM_VACATION
)
) {
this._armActions.push("arm_vacation");
}
this.hass.callService("alarm_control_panel", "alarm_disarm", {
entity_id: this.stateObj!.entity_id,
code,
});
}
protected render() {
@ -72,134 +48,104 @@ export class MoreInfoAlarmControlPanel extends LitElement {
return nothing;
}
const color = stateColorCss(this.stateObj);
const style = {
"--icon-color": color,
};
return html`
${!this.stateObj.attributes.code_format
? ""
: html`
<div class="center">
<ha-textfield
id="alarmCode"
.label=${this.hass.localize("ui.card.alarm_control_panel.code")}
type="password"
.inputMode=${this.stateObj.attributes.code_format ===
FORMAT_NUMBER
? "numeric"
: "text"}
></ha-textfield>
</div>
`}
${this.stateObj.attributes.code_format !== FORMAT_NUMBER
? ""
: html`
<div id="keypad">
${BUTTONS.map((value) =>
value === ""
? html`<mwc-button disabled></mwc-button>`
: html`
<mwc-button
.value=${value}
@click=${this._handlePadClick}
outlined
class=${classMap({
numberkey: value !== "clear",
})}
>
${value === "clear"
? this.hass!.localize(
`ui.card.alarm_control_panel.clear_code`
)
: value}
</mwc-button>
`
)}
</div>
`}
<div class="actions">
${(this.stateObj.state === "disarmed"
? this._armActions
: DISARM_ACTIONS
).map(
(stateAction) => html`
<mwc-button
.action=${stateAction}
@click=${this._handleActionClick}
outlined
>
${this.hass!.localize(
`ui.card.alarm_control_panel.${stateAction}`
)}
</mwc-button>
`
)}
<ha-more-info-state-header
.hass=${this.hass}
.stateObj=${this.stateObj}
></ha-more-info-state-header>
<div class="controls" style=${styleMap(style)}>
${["triggered", "arming", "pending"].includes(this.stateObj.state)
? html`
<div class="status">
<span></span>
<div class="icon">
<ha-svg-icon
.path=${domainIcon("alarm_control_panel", this.stateObj)}
></ha-svg-icon>
</div>
<md-outlined-button
.label=${this.hass.localize(
"ui.dialogs.more_info_control.alarm_control_panel.disarm_action"
)}
@click=${this._disarm}
>
<ha-svg-icon slot="icon" .path=${mdiShieldOff}></ha-svg-icon>
</md-outlined-button>
</div>
`
: html`
<ha-more-info-alarm_control_panel-modes
.stateObj=${this.stateObj}
.hass=${this.hass}
>
</ha-more-info-alarm_control_panel-modes>
`}
</div>
<span></span>
`;
}
private _handlePadClick(e: MouseEvent): void {
const val = (e.currentTarget! as any).value;
this._input!.value = val === "clear" ? "" : this._input!.value + val;
static get styles(): CSSResultGroup {
return [
moreInfoControlStyle,
css`
:host {
--icon-color: var(--primary-color);
}
md-outlined-button {
--ha-icon-display: block;
--md-sys-color-primary: var(--primary-text-color);
}
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.status {
display: flex;
align-items: center;
flex-direction: column;
}
.status .icon {
position: relative;
--mdc-icon-size: 80px;
animation: pulse 1s infinite;
color: var(--icon-color);
border-radius: 50%;
width: 144px;
height: 144px;
display: flex;
align-items: center;
justify-content: center;
}
.status .icon::before {
content: "";
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
border-radius: 50%;
background-color: var(--icon-color);
transition: background-color 180ms ease-in-out;
opacity: 0.2;
}
.status md-outlined-button {
margin-top: 32px;
}
`,
];
}
private _handleActionClick(e: MouseEvent): void {
const input = this._input;
callAlarmAction(
this.hass!,
this.stateObj!.entity_id,
(e.currentTarget! as any).action,
input?.value || undefined
);
if (input) {
input.value = "";
}
}
static styles = css`
ha-textfield {
display: block;
margin: 8px;
max-width: 150px;
text-align: center;
}
#keypad {
display: flex;
justify-content: center;
flex-wrap: wrap;
margin: auto;
width: 100%;
max-width: 300px;
}
#keypad mwc-button {
padding: 8px;
width: 30%;
box-sizing: border-box;
}
.actions {
margin: 0;
display: flex;
flex-wrap: wrap;
justify-content: center;
}
.actions mwc-button {
margin: 0 4px 4px;
}
mwc-button#disarm {
color: var(--error-color);
}
mwc-button.numberkey {
--mdc-typography-button-font-size: var(--keypad-font-size, 0.875rem);
}
.center {
display: flex;
justify-content: center;
}
`;
}
declare global {

View File

@ -342,7 +342,8 @@ export const haStyleDialog = css`
--ha-dialog-border-radius: 0px;
}
}
mwc-button.warning {
mwc-button.warning,
ha-button.warning {
--mdc-theme-primary: var(--error-color);
}
.error {

View File

@ -924,6 +924,20 @@
"medium": "Medium",
"high": "High"
}
},
"alarm_control_panel": {
"modes": {
"away": "Away",
"home": "Home",
"night": "Night",
"vacation": "Vacation",
"custom_bypass": "Custom",
"disarmed": "Disarmed"
},
"disarm_title": "Disarm",
"disarm_action": "Disarm",
"arm_title": "Arm",
"arm_action": "Arm"
}
},
"entity_registry": {
@ -1283,6 +1297,10 @@
"release_items": "This includes beta releases for:",
"view_documentation": "View documentation",
"join": "Join"
},
"enter_code": {
"title": "Enter code",
"input_label": "Code"
}
},
"duration": {