feat: Add a simple mode to the visual card editor (#2588)
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
upgradeConfig,
|
||||
} from '../../config/management';
|
||||
import { setProfiles } from '../../config/profiles/set-profiles';
|
||||
import type { EditorMode } from '../../config/schema/editor';
|
||||
import { profilesSchema, type ProfileType } from '../../config/schema/profiles';
|
||||
import { configDefaults } from '../../config/schema/types';
|
||||
import type {
|
||||
@@ -27,6 +28,7 @@ import { getFolderID } from '../../utils/folder';
|
||||
import { applyConfigChanges } from './form-data';
|
||||
import type { FormsInput } from './forms-controller';
|
||||
import type { EditorIntent } from './intents';
|
||||
import { deriveEditorMode, getEditorMode } from './mode';
|
||||
import type { FormRequestOptions } from './schema/registry';
|
||||
import { getEditorCameraTitle, getEditorFolderTitle } from './titles';
|
||||
|
||||
@@ -69,6 +71,9 @@ export class EditorController implements ReactiveController {
|
||||
// The parsed configuration profiles (empty when unset or invalid).
|
||||
private _profiles: ProfileType[] = [];
|
||||
|
||||
// Which editor the configuration asks for, or suits.
|
||||
private _editorMode: EditorMode = 'simple';
|
||||
|
||||
constructor(host: EditorControllerHost) {
|
||||
this._host = host;
|
||||
host.addController(this);
|
||||
@@ -103,6 +108,7 @@ export class EditorController implements ReactiveController {
|
||||
private _applyConfig(config: RawAdvancedCameraCardConfig): void {
|
||||
this._config = config;
|
||||
this._configUpgradeable = isConfigUpgradeable(config);
|
||||
this._editorMode = getEditorMode(config);
|
||||
|
||||
// The defaults are rebuilt from scratch so that removing (or breaking) a
|
||||
// profile also removes its adjusted defaults.
|
||||
@@ -129,6 +135,16 @@ export class EditorController implements ReactiveController {
|
||||
if (Array.isArray(overrides) && overrides.length) {
|
||||
notices.push({ type: 'info', message: localize('config.overrides.info') });
|
||||
}
|
||||
|
||||
// The simple editor can be asked for by a configuration that sets more than
|
||||
// it shows, so tell the user those settings are still in effect.
|
||||
if (
|
||||
this._config &&
|
||||
this._editorMode === 'simple' &&
|
||||
deriveEditorMode(this._config) === 'full'
|
||||
) {
|
||||
notices.push({ type: 'info', message: localize('editor.simple_coverage') });
|
||||
}
|
||||
return notices;
|
||||
}
|
||||
|
||||
@@ -136,6 +152,10 @@ export class EditorController implements ReactiveController {
|
||||
return this._config;
|
||||
}
|
||||
|
||||
public getEditorMode(): EditorMode {
|
||||
return this._editorMode;
|
||||
}
|
||||
|
||||
public getDefaults(): RawAdvancedCameraCardConfig {
|
||||
return this._defaults;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DOCS_URL } from '../../const';
|
||||
import type { HAFormSchema } from '../../ha/types';
|
||||
import { isFormFieldSchema, type HAFormSchema } from '../../ha/types';
|
||||
import { getFormContainerPath, stripArrayIndices } from './paths';
|
||||
import type { ConfigPath } from './types';
|
||||
|
||||
// Documentation links keyed by configuration path (without array indices).
|
||||
// Sections and expandable groups with an entry here render a documentation
|
||||
@@ -51,7 +52,6 @@ const DOC_LINKS: Record<string, string> = {
|
||||
'media_viewer.display': 'configuration/media-viewer?id=display',
|
||||
menu: 'configuration/menu',
|
||||
'menu.buttons': 'configuration/menu?id=buttons',
|
||||
options: 'configuration/README',
|
||||
performance: 'configuration/performance',
|
||||
'performance.features': 'configuration/performance?id=features',
|
||||
'performance.style': 'configuration/performance?id=style',
|
||||
@@ -77,7 +77,7 @@ const DOC_LINKS: Record<string, string> = {
|
||||
* @returns A full documentation URL, or null if the path has no documentation
|
||||
* link.
|
||||
*/
|
||||
export const getDocURL = (path: (string | number)[]): string | null => {
|
||||
export const getDocURL = (path: ConfigPath): string | null => {
|
||||
const docPath = DOC_LINKS[stripArrayIndices(path).join('.')];
|
||||
return docPath ? `${DOCS_URL}/#/${docPath}` : null;
|
||||
};
|
||||
@@ -90,22 +90,22 @@ export const getDocURL = (path: (string | number)[]): string | null => {
|
||||
* @returns The configuration path to link to, or null for no link.
|
||||
*/
|
||||
export const getDocLinkPath = (
|
||||
basePath: (string | number)[],
|
||||
basePath: ConfigPath,
|
||||
schema: HAFormSchema,
|
||||
options?: { path?: string[] },
|
||||
): (string | number)[] | null => {
|
||||
): ConfigPath | null => {
|
||||
// Documentation links attach to containers (sections/groups), never to
|
||||
// individual fields.
|
||||
if ('selector' in schema) {
|
||||
if (isFormFieldSchema(schema)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// A nameless group (an editor-only grouping such as "Engines") carries an
|
||||
// explicit `docPath`; a named group derives its path from the configuration
|
||||
// path.
|
||||
return schema.docPath
|
||||
? schema.docPath.split('.')
|
||||
: schema.name
|
||||
? [...basePath, ...getFormContainerPath(options), schema.name]
|
||||
: null;
|
||||
const docPath = schema.type === 'expandable' ? schema.docPath : null;
|
||||
return (
|
||||
docPath ??
|
||||
(schema.name ? [...basePath, ...getFormContainerPath(options), schema.name] : null)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,9 +7,14 @@ import {
|
||||
setConfigValue,
|
||||
} from '../../config/management';
|
||||
import type { RawAdvancedCameraCardConfig } from '../../config/types';
|
||||
import type { HAFormSchema, HAFormSelectorSchema } from '../../ha/types';
|
||||
import {
|
||||
isFormFieldSchema,
|
||||
type HAFormSchema,
|
||||
type HAFormSelectorSchema,
|
||||
} from '../../ha/types';
|
||||
import { isRecord } from '../../utils/basic';
|
||||
import {
|
||||
findBinding,
|
||||
isComputedFieldBinding,
|
||||
type ConfigChange,
|
||||
type ConfigPath,
|
||||
@@ -30,7 +35,7 @@ export const forEachFieldRecursively = (
|
||||
): void => {
|
||||
const recurse = (items: readonly HAFormSchema[], prefix: string[]): void => {
|
||||
for (const item of items) {
|
||||
if ('selector' in item) {
|
||||
if (isFormFieldSchema(item)) {
|
||||
callback([...prefix, item.name], item);
|
||||
} else {
|
||||
// A nameless expandable is a visual-only grouping, so it is not added
|
||||
@@ -42,8 +47,27 @@ export const forEachFieldRecursively = (
|
||||
recurse(schema, []);
|
||||
};
|
||||
|
||||
const findBinding = (form: EditorForm, formPath: string[]): FieldBinding | undefined =>
|
||||
form.bindings?.find((binding) => isEqual(binding.formPath, formPath));
|
||||
/**
|
||||
* Get the configuration paths a form's fields address: those a binding names,
|
||||
* or, for an unbound field, its position within the form beneath the form's
|
||||
* base path.
|
||||
* @param form The form.
|
||||
* @returns The absolute configuration paths, in field order.
|
||||
*/
|
||||
export const getFormConfigPaths = (form: EditorForm): ConfigPath[] => {
|
||||
const paths: ConfigPath[] = [];
|
||||
forEachFieldRecursively(form.schema, (path) => {
|
||||
const binding = findBinding(form, path);
|
||||
if (!binding) {
|
||||
paths.push([...form.basePath, ...path]);
|
||||
} else if (isComputedFieldBinding(binding)) {
|
||||
paths.push(...binding.configPaths);
|
||||
} else {
|
||||
paths.push(binding.configPath);
|
||||
}
|
||||
});
|
||||
return paths;
|
||||
};
|
||||
|
||||
// The value to show for a bound field. A computed binding works its own value
|
||||
// out; otherwise the configured value is shown, and only a missing value falls
|
||||
|
||||
@@ -1,42 +1,57 @@
|
||||
import type { HAFormSchema } from '../../ha/types';
|
||||
import { isFormFieldSchema, type HAFormSchema } from '../../ha/types';
|
||||
import { localize } from '../../localize/localize';
|
||||
import { getFormContainerPath, stripArrayIndices } from './paths';
|
||||
import {
|
||||
findBinding,
|
||||
isComputedFieldBinding,
|
||||
type ConfigPath,
|
||||
type EditorForm,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Get the localization key for a configuration path.
|
||||
* @param path The configuration path segments.
|
||||
* @returns The localization key.
|
||||
*/
|
||||
export const getLocalizationKeyForPath = (path: (string | number)[]): string =>
|
||||
export const getLocalizationKeyForPath = (path: ConfigPath): string =>
|
||||
['config', ...stripArrayIndices(path)].join('.');
|
||||
|
||||
/**
|
||||
* Compute the label for a form field. Intended for use as an `ha-form`
|
||||
* `computeLabel` callback, bound to the path of the form's data within the
|
||||
* configuration. Container nodes are labelled by their explicit title, never
|
||||
* `computeLabel` callback. A field is named for the setting it edits, which for
|
||||
* a bound field is wherever that setting is stored rather than where the field
|
||||
* sits in the form. Container nodes are labelled by their explicit title, never
|
||||
* by configuration path (their headers already render the title).
|
||||
* @param basePath The path of the form's data within the configuration.
|
||||
* @param form The form the field belongs to.
|
||||
* @param schema The field's schema.
|
||||
* @param options Path context provided by `ha-form` for fields nested inside
|
||||
* containers.
|
||||
* @returns A localized label.
|
||||
*/
|
||||
export const computeFormLabel = (
|
||||
basePath: (string | number)[],
|
||||
form: EditorForm,
|
||||
schema: HAFormSchema,
|
||||
options?: { path?: string[] },
|
||||
): string => {
|
||||
if (schema.label !== undefined) {
|
||||
return schema.label;
|
||||
}
|
||||
if (!('selector' in schema)) {
|
||||
return schema.title ?? schema.name ?? '';
|
||||
if (!isFormFieldSchema(schema)) {
|
||||
// Only an expandable has a title: a grid lays out fields that are labelled
|
||||
// individually, and shows nothing of its own.
|
||||
return (schema.type === 'expandable' ? schema.title : null) ?? schema.name ?? '';
|
||||
}
|
||||
|
||||
const formPath = [...getFormContainerPath(options), schema.name];
|
||||
const binding = findBinding(form, formPath);
|
||||
|
||||
// A field standing for more than one setting has no single name to take, and
|
||||
// so carries its own label.
|
||||
return localize(
|
||||
getLocalizationKeyForPath([
|
||||
...basePath,
|
||||
...getFormContainerPath(options),
|
||||
schema.name,
|
||||
]),
|
||||
getLocalizationKeyForPath(
|
||||
binding && !isComputedFieldBinding(binding)
|
||||
? binding.configPath
|
||||
: [...form.basePath, ...formPath],
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -173,7 +173,7 @@ export class FormsController {
|
||||
schema: HAFormSchema,
|
||||
_data?: unknown,
|
||||
options?: { path?: string[] },
|
||||
) => computeFormLabel(form.basePath, schema, options),
|
||||
) => computeFormLabel(form, schema, options),
|
||||
|
||||
// Caution: `ha-form` types the helper as a string, but interpolates it
|
||||
// into a Lit template, so a template renders as-is; should a future Home
|
||||
|
||||
@@ -10,7 +10,10 @@ import type { ConfigChange, ConfigPath } from './types';
|
||||
|
||||
type KeyboardShortcutsControllerHost = ReactiveControllerHost & EventTarget;
|
||||
|
||||
const FORM_REQUEST: FormRequest = { kind: 'section', name: 'view.keyboard_shortcuts' };
|
||||
const FORM_REQUEST: FormRequest = {
|
||||
kind: 'full-section',
|
||||
name: 'view.keyboard_shortcuts',
|
||||
};
|
||||
|
||||
/**
|
||||
* The keyboard shortcuts panel: the settings that apply to all the shortcuts,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { editorConfigSchema, type EditorMode } from '../../config/schema/editor';
|
||||
import type { RawAdvancedCameraCardConfig } from '../../config/types';
|
||||
import { isRecord } from '../../utils/basic';
|
||||
import { getFormConfigPaths } from './form-data';
|
||||
import { stripArrayIndices } from './paths';
|
||||
import { getFullEditorForms } from './schema/full';
|
||||
import {
|
||||
getSimpleCameraForms,
|
||||
getSimpleMenuForms,
|
||||
getSimpleTopLevelForms,
|
||||
} from './schema/simple';
|
||||
import type { EditorForm } from './types';
|
||||
|
||||
// The configuration paths a set of forms shows, without array indices so that
|
||||
// the third camera's title is the camera title field.
|
||||
const getShownPaths = (forms: EditorForm[]): Set<string> =>
|
||||
new Set(
|
||||
forms.flatMap(getFormConfigPaths).map((path) => stripArrayIndices(path).join('.')),
|
||||
);
|
||||
|
||||
// What the full editor shows and the simple editor does not. Configuration
|
||||
// outside this is either something the simple editor shows too, or something
|
||||
// neither shows (`elements`, `debug`, anything the card does not recognise at
|
||||
// all), which the full editor would be no better at showing.
|
||||
const getFullOnlyPaths = (): Set<string> => {
|
||||
const simplePaths = getShownPaths([
|
||||
...getSimpleCameraForms(0),
|
||||
...getSimpleMenuForms(),
|
||||
...getSimpleTopLevelForms(),
|
||||
]);
|
||||
return new Set(
|
||||
[...getShownPaths(getFullEditorForms())].filter((path) => !simplePaths.has(path)),
|
||||
);
|
||||
};
|
||||
|
||||
// The paths of every value the configuration sets. Lists contribute the paths
|
||||
// of their items' contents, without the position of the item.
|
||||
const getSetPaths = (value: unknown, prefix: string[] = []): string[] => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((item) => getSetPaths(item, prefix));
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
return Object.entries(value).flatMap(([key, item]) =>
|
||||
getSetPaths(item, [...prefix, key]),
|
||||
);
|
||||
}
|
||||
return [prefix.join('.')];
|
||||
};
|
||||
|
||||
/**
|
||||
* Work out which editor to show for a configuration that does not say. The full
|
||||
* editor is shown when the configuration sets something it shows and the simple
|
||||
* editor does not, so that opening the simple editor never hides a setting the
|
||||
* user could otherwise have seen.
|
||||
* @param config The raw (possibly invalid) configuration.
|
||||
* @returns The editor mode.
|
||||
*/
|
||||
export const deriveEditorMode = (config: RawAdvancedCameraCardConfig): EditorMode => {
|
||||
const fullOnlyPaths = getFullOnlyPaths();
|
||||
return getSetPaths(config).some((path) => fullOnlyPaths.has(path)) ? 'full' : 'simple';
|
||||
};
|
||||
|
||||
/**
|
||||
* Get which editor to show for a configuration: what it asks for, or, if it
|
||||
* does not ask (or asks for something that is not an editor), what suits it.
|
||||
* @param config The raw (possibly invalid) configuration.
|
||||
* @returns The editor mode.
|
||||
*/
|
||||
export const getEditorMode = (config: RawAdvancedCameraCardConfig): EditorMode => {
|
||||
const editor = editorConfigSchema.safeParse(config['editor']);
|
||||
return editor.success && editor.data.mode
|
||||
? editor.data.mode
|
||||
: deriveEditorMode(config);
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ConfigPath } from './types';
|
||||
|
||||
/**
|
||||
* Convert the container path `ha-form` passes to `computeLabel`/`computeHelper`
|
||||
* into configuration order. `ha-form` builds the path by appending each
|
||||
@@ -16,7 +18,7 @@ export const getFormContainerPath = (options?: { path?: string[] }): string[] =>
|
||||
* @param path The configuration path segments.
|
||||
* @returns The path segments without array indices.
|
||||
*/
|
||||
export const stripArrayIndices = (path: (string | number)[]): string[] =>
|
||||
export const stripArrayIndices = (path: ConfigPath): string[] =>
|
||||
path.filter(
|
||||
(segment): segment is string =>
|
||||
typeof segment === 'string' && !/^\d+$/.test(segment),
|
||||
|
||||
@@ -70,6 +70,35 @@ const getMediaLayoutSchema = (): HAFormExpandableSchema => ({
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the camera fields the simple editor shows: the entity, how it is
|
||||
* streamed, and how it is presented.
|
||||
* @returns The camera fields.
|
||||
*/
|
||||
export const getCameraSimpleFields = (): HAFormSchema[] => [
|
||||
{ name: 'camera_entity', selector: { entity: { domain: 'camera' } } },
|
||||
{
|
||||
name: 'live_provider',
|
||||
selector: createSelectSelector([
|
||||
{ value: 'auto', label: localize('config.cameras.live_providers.auto') },
|
||||
{ value: 'ha', label: localize('config.cameras.live_providers.ha') },
|
||||
{ value: 'image', label: localize('config.cameras.live_providers.image') },
|
||||
{ value: 'jsmpeg', label: localize('config.cameras.live_providers.jsmpeg') },
|
||||
{ value: 'go2rtc', label: localize('config.cameras.live_providers.go2rtc') },
|
||||
{
|
||||
value: 'go2rtc-experimental',
|
||||
label: localize('config.cameras.live_providers.go2rtc-experimental'),
|
||||
},
|
||||
{
|
||||
value: 'webrtc-card',
|
||||
label: localize('config.cameras.live_providers.webrtc-card'),
|
||||
},
|
||||
]),
|
||||
},
|
||||
{ name: 'title', selector: { text: {} } },
|
||||
{ name: 'icon', selector: { icon: {} } },
|
||||
];
|
||||
|
||||
interface CameraSchemaOptions {
|
||||
otherCameras: HASelectSelectorOption[];
|
||||
folders: HASelectSelectorOption[];
|
||||
@@ -89,27 +118,7 @@ export const getCameraSchema = (options: CameraSchemaOptions): HAFormSchema[] =>
|
||||
}));
|
||||
|
||||
return [
|
||||
{ name: 'camera_entity', selector: { entity: { domain: 'camera' } } },
|
||||
{
|
||||
name: 'live_provider',
|
||||
selector: createSelectSelector([
|
||||
{ value: 'auto', label: localize('config.cameras.live_providers.auto') },
|
||||
{ value: 'ha', label: localize('config.cameras.live_providers.ha') },
|
||||
{ value: 'image', label: localize('config.cameras.live_providers.image') },
|
||||
{ value: 'jsmpeg', label: localize('config.cameras.live_providers.jsmpeg') },
|
||||
{ value: 'go2rtc', label: localize('config.cameras.live_providers.go2rtc') },
|
||||
{
|
||||
value: 'go2rtc-experimental',
|
||||
label: localize('config.cameras.live_providers.go2rtc-experimental'),
|
||||
},
|
||||
{
|
||||
value: 'webrtc-card',
|
||||
label: localize('config.cameras.live_providers.webrtc-card'),
|
||||
},
|
||||
]),
|
||||
},
|
||||
{ name: 'title', selector: { text: {} } },
|
||||
{ name: 'icon', selector: { icon: {} } },
|
||||
...getCameraSimpleFields(),
|
||||
{ name: 'id', selector: { text: {} } },
|
||||
{ name: 'always_error_if_entity_unavailable', selector: { boolean: {} } },
|
||||
|
||||
@@ -210,7 +219,7 @@ export const getCameraSchema = (options: CameraSchemaOptions): HAFormSchema[] =>
|
||||
type: 'expandable',
|
||||
title: localize('config.cameras.engines.editor_label'),
|
||||
icon: 'mdi:engine',
|
||||
docPath: 'cameras.engine',
|
||||
docPath: ['cameras', 'engine'],
|
||||
schema: [
|
||||
{
|
||||
name: 'engine',
|
||||
@@ -295,7 +304,7 @@ export const getCameraSchema = (options: CameraSchemaOptions): HAFormSchema[] =>
|
||||
type: 'expandable',
|
||||
title: localize('config.cameras.live_provider_options.editor_label'),
|
||||
icon: 'mdi:cctv',
|
||||
docPath: 'cameras.live_provider',
|
||||
docPath: ['cameras', 'live_provider'],
|
||||
schema: [
|
||||
{
|
||||
name: 'go2rtc',
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
import type { HASelectSelectorOption } from '../../../ha/types';
|
||||
import { localize } from '../../../localize/localize';
|
||||
import type { EditorForm } from '../types';
|
||||
import { createSelectSelector } from './common/selectors';
|
||||
|
||||
/**
|
||||
* Get the options for how the card's aspect ratio is decided.
|
||||
* @returns The aspect ratio mode options.
|
||||
*/
|
||||
export const getAspectRatioModeOptions = (): HASelectSelectorOption[] => [
|
||||
{
|
||||
value: 'dynamic',
|
||||
label: localize('config.dimensions.aspect_ratio_modes.dynamic'),
|
||||
},
|
||||
{
|
||||
value: 'static',
|
||||
label: localize('config.dimensions.aspect_ratio_modes.static'),
|
||||
},
|
||||
{
|
||||
value: 'unconstrained',
|
||||
label: localize('config.dimensions.aspect_ratio_modes.unconstrained'),
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the form for the dimensions section.
|
||||
* @returns The section forms.
|
||||
@@ -12,20 +32,7 @@ export const getDimensionsSectionForms = (): EditorForm[] => [
|
||||
schema: [
|
||||
{
|
||||
name: 'aspect_ratio_mode',
|
||||
selector: createSelectSelector([
|
||||
{
|
||||
value: 'dynamic',
|
||||
label: localize('config.dimensions.aspect_ratio_modes.dynamic'),
|
||||
},
|
||||
{
|
||||
value: 'static',
|
||||
label: localize('config.dimensions.aspect_ratio_modes.static'),
|
||||
},
|
||||
{
|
||||
value: 'unconstrained',
|
||||
label: localize('config.dimensions.aspect_ratio_modes.unconstrained'),
|
||||
},
|
||||
]),
|
||||
selector: createSelectSelector(getAspectRatioModeOptions()),
|
||||
},
|
||||
{
|
||||
name: 'aspect_ratio',
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { getEditorMode } from '../mode';
|
||||
import type { ConfigPath, EditorForm, FieldBinding } from '../types';
|
||||
|
||||
const EDITOR_MODE_PATH: ConfigPath = ['editor', 'mode'];
|
||||
|
||||
// The switch between the two editors. It shows whether the full editor is in
|
||||
// use, while the configuration names the editor itself, so the two
|
||||
// representations are converted here. The mode is always written out, including
|
||||
// when it matches what would have been chosen anyway, so that the choice sticks.
|
||||
const getEditorModeBinding = (): FieldBinding => ({
|
||||
formPath: ['mode'],
|
||||
configPaths: [EDITOR_MODE_PATH],
|
||||
read: (config) => getEditorMode(config) === 'full',
|
||||
write: (value) => [
|
||||
{ path: EDITOR_MODE_PATH, type: 'set', value: value === true ? 'full' : 'simple' },
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the form choosing which editor to show. It is shown above both editors,
|
||||
* so that the way back is in the same place whichever is in use.
|
||||
* @returns The editor mode forms.
|
||||
*/
|
||||
export const getEditorModeForms = (): EditorForm[] => [
|
||||
{
|
||||
basePath: ['editor'],
|
||||
schema: [{ name: 'mode', selector: { boolean: {} } }],
|
||||
bindings: [getEditorModeBinding()],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,114 @@
|
||||
import { CONF_CAMERAS, CONF_FOLDERS } from '../../../config/const';
|
||||
import type { EditorForm } from '../types';
|
||||
import {
|
||||
getCameraSchema,
|
||||
getCameraTriggersSchema,
|
||||
getTriggerEventSchema,
|
||||
} from './cameras';
|
||||
import { getDimensionsSectionForms } from './dimensions';
|
||||
import { getFolderSchema } from './folders';
|
||||
import { getImageSectionForms } from './image';
|
||||
import { getLiveSectionForms } from './live';
|
||||
import { getMediaGallerySectionForms } from './media-gallery';
|
||||
import { getMediaViewerSectionForms } from './media-viewer';
|
||||
import { getMenuSectionForms } from './menu';
|
||||
import { getPerformanceSectionForms } from './performance';
|
||||
import { getProfilesSectionForms } from './profiles';
|
||||
import { getRemoteControlSectionForms } from './remote-control';
|
||||
import { getStatusBarSectionForms } from './status-bar';
|
||||
import { getTimelineSectionForms } from './timeline';
|
||||
import { getViewKeyboardShortcutsSectionForms, getViewSectionForms } from './view';
|
||||
|
||||
// The forms of each section of the full editor, by section name. Kept as a
|
||||
// lookup rather than a switch so that the full editor's fields can also be
|
||||
// asked for as a whole, without a second list of the sections to keep in step.
|
||||
const SECTION_FORM_BUILDERS: Record<string, () => EditorForm[]> = {
|
||||
dimensions: getDimensionsSectionForms,
|
||||
image: getImageSectionForms,
|
||||
live: getLiveSectionForms,
|
||||
media_gallery: getMediaGallerySectionForms,
|
||||
media_viewer: getMediaViewerSectionForms,
|
||||
menu: getMenuSectionForms,
|
||||
performance: getPerformanceSectionForms,
|
||||
profiles: getProfilesSectionForms,
|
||||
remote_control: getRemoteControlSectionForms,
|
||||
status_bar: getStatusBarSectionForms,
|
||||
timeline: getTimelineSectionForms,
|
||||
view: getViewSectionForms,
|
||||
'view.keyboard_shortcuts': getViewKeyboardShortcutsSectionForms,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the forms of one section of the full editor.
|
||||
* @param name The section name.
|
||||
* @returns The section forms, or none for a section that has no forms of its
|
||||
* own.
|
||||
*/
|
||||
export const getFullSectionForms = (name: string): EditorForm[] =>
|
||||
SECTION_FORM_BUILDERS[name]?.() ?? [];
|
||||
|
||||
/**
|
||||
* Get the form for one camera in the full editor, without its triggers, which
|
||||
* are rendered by a hand-built panel so it can host the events list.
|
||||
* @param index The camera's position in the configuration.
|
||||
* @param options The camera and folder lists the dropdowns choose from.
|
||||
* @returns The camera's forms.
|
||||
*/
|
||||
export const getFullCameraForms = (
|
||||
index: number,
|
||||
options: Parameters<typeof getCameraSchema>[0],
|
||||
): EditorForm[] => [
|
||||
{ basePath: [CONF_CAMERAS, index], schema: getCameraSchema(options) },
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the form for a camera's triggers.
|
||||
* @param index The camera's position in the configuration.
|
||||
* @returns The triggers forms.
|
||||
*/
|
||||
export const getFullCameraTriggersForms = (index: number): EditorForm[] => [
|
||||
{
|
||||
basePath: [CONF_CAMERAS, index, 'triggers'],
|
||||
schema: getCameraTriggersSchema(),
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the form for one of a camera's trigger events.
|
||||
* @param cameraIndex The camera's position in the configuration.
|
||||
* @param eventIndex The event's position within the camera's events.
|
||||
* @returns The event's forms.
|
||||
*/
|
||||
export const getFullCameraEventForms = (
|
||||
cameraIndex: number,
|
||||
eventIndex: number,
|
||||
): EditorForm[] => [
|
||||
{
|
||||
basePath: [CONF_CAMERAS, cameraIndex, 'triggers', 'events', eventIndex],
|
||||
schema: getTriggerEventSchema(),
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the form for one folder.
|
||||
* @param index The folder's position in the configuration.
|
||||
* @returns The folder's forms.
|
||||
*/
|
||||
export const getFullFolderForms = (index: number): EditorForm[] => [
|
||||
{ basePath: [CONF_FOLDERS, index], schema: getFolderSchema() },
|
||||
];
|
||||
|
||||
/**
|
||||
* Get every form the full editor shows, wherever it shows it: its sections, and
|
||||
* the pages it opens for an item of a list. The items are those at index 0,
|
||||
* since every item of a list has the same fields, and the dropdowns are given
|
||||
* nothing to choose from since nothing is being chosen.
|
||||
* @returns The full editor's forms.
|
||||
*/
|
||||
export const getFullEditorForms = (): EditorForm[] => [
|
||||
...Object.values(SECTION_FORM_BUILDERS).flatMap((build) => build()),
|
||||
...getFullCameraForms(0, { otherCameras: [], folders: [] }),
|
||||
...getFullCameraTriggersForms(0),
|
||||
...getFullCameraEventForms(0, 0),
|
||||
...getFullFolderForms(0),
|
||||
];
|
||||
@@ -1,11 +1,15 @@
|
||||
import { BUTTON_SIZE_MIN, MENU_PRIORITY_MAX } from '../../../config/schema/common/const';
|
||||
import type { HAFormExpandableSchema, HAFormSchema } from '../../../ha/types';
|
||||
import type {
|
||||
HAFormExpandableSchema,
|
||||
HAFormSchema,
|
||||
HASelectSelectorOption,
|
||||
} from '../../../ha/types';
|
||||
import { localize } from '../../../localize/localize';
|
||||
import type { EditorForm } from '../types';
|
||||
import { createNumberSelector, createSelectSelector } from './common/selectors';
|
||||
|
||||
// The menu buttons in their display order.
|
||||
const MENU_BUTTONS = [
|
||||
export const MENU_BUTTONS = [
|
||||
// Iris menu button is first.
|
||||
'iris',
|
||||
|
||||
@@ -39,6 +43,40 @@ const MENU_BUTTONS = [
|
||||
'timeline',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the options for choosing a menu button.
|
||||
* @returns The menu buttons, in display order.
|
||||
*/
|
||||
export const getMenuButtonOptions = (): HASelectSelectorOption[] =>
|
||||
MENU_BUTTONS.map((button) => ({
|
||||
value: button,
|
||||
label: localize(`config.menu.buttons.${button}`),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Get the options for the menu style.
|
||||
* @returns The menu style options.
|
||||
*/
|
||||
export const getMenuStyleOptions = (): HASelectSelectorOption[] => [
|
||||
{ value: 'none', label: localize('config.menu.styles.none') },
|
||||
{ value: 'hidden', label: localize('config.menu.styles.hidden') },
|
||||
{ value: 'overlay', label: localize('config.menu.styles.overlay') },
|
||||
{ value: 'hover', label: localize('config.menu.styles.hover') },
|
||||
{ value: 'hover-card', label: localize('config.menu.styles.hover-card') },
|
||||
{ value: 'outside', label: localize('config.menu.styles.outside') },
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the options for the menu position.
|
||||
* @returns The menu position options.
|
||||
*/
|
||||
export const getMenuPositionOptions = (): HASelectSelectorOption[] => [
|
||||
{ value: 'left', label: localize('config.menu.positions.left') },
|
||||
{ value: 'right', label: localize('config.menu.positions.right') },
|
||||
{ value: 'top', label: localize('config.menu.positions.top') },
|
||||
{ value: 'bottom', label: localize('config.menu.positions.bottom') },
|
||||
];
|
||||
|
||||
// The microphone button alone carries a `type` (momentary vs toggle).
|
||||
const getMicrophoneTypeSchema = (): HAFormSchema => ({
|
||||
name: 'type',
|
||||
@@ -116,23 +154,11 @@ export const getMenuSectionForms = (): EditorForm[] => [
|
||||
schema: [
|
||||
{
|
||||
name: 'style',
|
||||
selector: createSelectSelector([
|
||||
{ value: 'none', label: localize('config.menu.styles.none') },
|
||||
{ value: 'hidden', label: localize('config.menu.styles.hidden') },
|
||||
{ value: 'overlay', label: localize('config.menu.styles.overlay') },
|
||||
{ value: 'hover', label: localize('config.menu.styles.hover') },
|
||||
{ value: 'hover-card', label: localize('config.menu.styles.hover-card') },
|
||||
{ value: 'outside', label: localize('config.menu.styles.outside') },
|
||||
]),
|
||||
selector: createSelectSelector(getMenuStyleOptions()),
|
||||
},
|
||||
{
|
||||
name: 'position',
|
||||
selector: createSelectSelector([
|
||||
{ value: 'left', label: localize('config.menu.positions.left') },
|
||||
{ value: 'right', label: localize('config.menu.positions.right') },
|
||||
{ value: 'top', label: localize('config.menu.positions.top') },
|
||||
{ value: 'bottom', label: localize('config.menu.positions.bottom') },
|
||||
]),
|
||||
selector: createSelectSelector(getMenuPositionOptions()),
|
||||
},
|
||||
{
|
||||
name: 'alignment',
|
||||
|
||||
@@ -1,7 +1,29 @@
|
||||
import type { HAFormSchema } from '../../../ha/types';
|
||||
import { localize } from '../../../localize/localize';
|
||||
import type { EditorForm } from '../types';
|
||||
import { createSelectSelector } from './common/selectors';
|
||||
|
||||
/**
|
||||
* Get the field choosing which pre-configured sets of defaults apply.
|
||||
* @returns The profiles field.
|
||||
*/
|
||||
export const getProfilesField = (): HAFormSchema => ({
|
||||
name: 'profiles',
|
||||
label: localize('config.profiles.editor_label'),
|
||||
selector: createSelectSelector(
|
||||
[
|
||||
{ value: 'casting', label: localize('config.profiles.casting') },
|
||||
{ value: 'doorbell', label: localize('config.profiles.doorbell') },
|
||||
{
|
||||
value: 'low-performance',
|
||||
label: localize('config.profiles.low-performance'),
|
||||
},
|
||||
{ value: 'scrubbing', label: localize('config.profiles.scrubbing') },
|
||||
],
|
||||
{ multiple: true },
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the form for the profiles section.
|
||||
* @returns The section forms.
|
||||
@@ -9,23 +31,6 @@ import { createSelectSelector } from './common/selectors';
|
||||
export const getProfilesSectionForms = (): EditorForm[] => [
|
||||
{
|
||||
basePath: [],
|
||||
schema: [
|
||||
{
|
||||
name: 'profiles',
|
||||
label: localize('config.profiles.editor_label'),
|
||||
selector: createSelectSelector(
|
||||
[
|
||||
{ value: 'casting', label: localize('config.profiles.casting') },
|
||||
{ value: 'doorbell', label: localize('config.profiles.doorbell') },
|
||||
{
|
||||
value: 'low-performance',
|
||||
label: localize('config.profiles.low-performance'),
|
||||
},
|
||||
{ value: 'scrubbing', label: localize('config.profiles.scrubbing') },
|
||||
],
|
||||
{ multiple: true },
|
||||
),
|
||||
},
|
||||
],
|
||||
schema: [getProfilesField()],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,34 +1,37 @@
|
||||
import { CONF_CAMERAS, CONF_FOLDERS } from '../../../config/const';
|
||||
import type { HASelectSelectorOption } from '../../../ha/types';
|
||||
import type { EditorForm } from '../types';
|
||||
import { getEditorModeForms } from './editor-mode';
|
||||
import {
|
||||
getCameraSchema,
|
||||
getCameraTriggersSchema,
|
||||
getTriggerEventSchema,
|
||||
} from './cameras';
|
||||
import { getDimensionsSectionForms } from './dimensions';
|
||||
import { getFolderSchema } from './folders';
|
||||
import { getImageSectionForms } from './image';
|
||||
import { getLiveSectionForms } from './live';
|
||||
import { getMediaGallerySectionForms } from './media-gallery';
|
||||
import { getMediaViewerSectionForms } from './media-viewer';
|
||||
import { getMenuSectionForms } from './menu';
|
||||
import { getPerformanceSectionForms } from './performance';
|
||||
import { getProfilesSectionForms } from './profiles';
|
||||
import { getRemoteControlSectionForms } from './remote-control';
|
||||
import { getStatusBarSectionForms } from './status-bar';
|
||||
import { getTimelineSectionForms } from './timeline';
|
||||
import { getViewKeyboardShortcutsSectionForms, getViewSectionForms } from './view';
|
||||
getFullCameraEventForms,
|
||||
getFullCameraForms,
|
||||
getFullCameraTriggersForms,
|
||||
getFullFolderForms,
|
||||
getFullSectionForms,
|
||||
} from './full';
|
||||
import {
|
||||
getSimpleCameraForms,
|
||||
getSimpleMenuForms,
|
||||
getSimpleTopLevelForms,
|
||||
} from './simple';
|
||||
|
||||
// The forms an editor component asks for. A request carries its indices as
|
||||
// numbers rather than being named by a path string, so no caller has to build
|
||||
// a string key and no builder has to pull one apart again.
|
||||
export type FormRequest =
|
||||
| { kind: 'section'; name: string }
|
||||
| { kind: 'folder'; index: number }
|
||||
| { kind: 'camera'; index: number }
|
||||
| { kind: 'camera-triggers'; cameraIndex: number }
|
||||
| { kind: 'camera-event'; cameraIndex: number; eventIndex: number };
|
||||
// The editor mode (simple or full)
|
||||
| { kind: 'editor-mode' }
|
||||
|
||||
// Simple mode forms.
|
||||
| { kind: 'simple-camera'; index: number }
|
||||
| { kind: 'simple-menu' }
|
||||
| { kind: 'simple-top-level' }
|
||||
|
||||
// Full mode forms.
|
||||
| { kind: 'full-section'; name: string }
|
||||
| { kind: 'full-folder'; index: number }
|
||||
| { kind: 'full-camera'; index: number }
|
||||
| { kind: 'full-camera-triggers'; cameraIndex: number }
|
||||
| { kind: 'full-camera-event'; cameraIndex: number; eventIndex: number };
|
||||
|
||||
// The lists a form's dropdowns choose from, gathered from the rest of the
|
||||
// configuration: the cameras and the folders. Entity fields are not among them:
|
||||
@@ -40,38 +43,6 @@ export interface FormRequestOptions {
|
||||
folders: HASelectSelectorOption[];
|
||||
}
|
||||
|
||||
const getSectionFormsByName = (name: string): EditorForm[] => {
|
||||
switch (name) {
|
||||
case 'dimensions':
|
||||
return getDimensionsSectionForms();
|
||||
case 'image':
|
||||
return getImageSectionForms();
|
||||
case 'live':
|
||||
return getLiveSectionForms();
|
||||
case 'media_gallery':
|
||||
return getMediaGallerySectionForms();
|
||||
case 'media_viewer':
|
||||
return getMediaViewerSectionForms();
|
||||
case 'menu':
|
||||
return getMenuSectionForms();
|
||||
case 'performance':
|
||||
return getPerformanceSectionForms();
|
||||
case 'profiles':
|
||||
return getProfilesSectionForms();
|
||||
case 'remote_control':
|
||||
return getRemoteControlSectionForms();
|
||||
case 'status_bar':
|
||||
return getStatusBarSectionForms();
|
||||
case 'timeline':
|
||||
return getTimelineSectionForms();
|
||||
case 'view':
|
||||
return getViewSectionForms();
|
||||
case 'view.keyboard_shortcuts':
|
||||
return getViewKeyboardShortcutsSectionForms();
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the forms for a request.
|
||||
* @param request What forms are wanted.
|
||||
@@ -83,42 +54,29 @@ export const getForms = (
|
||||
options: FormRequestOptions,
|
||||
): EditorForm[] => {
|
||||
switch (request.kind) {
|
||||
case 'section':
|
||||
return getSectionFormsByName(request.name);
|
||||
case 'folder':
|
||||
return [{ basePath: [CONF_FOLDERS, request.index], schema: getFolderSchema() }];
|
||||
case 'camera':
|
||||
return [
|
||||
{
|
||||
basePath: [CONF_CAMERAS, request.index],
|
||||
schema: getCameraSchema({
|
||||
// A camera cannot depend on itself.
|
||||
otherCameras: options.cameras.filter(
|
||||
(_camera, index) => index !== request.index,
|
||||
),
|
||||
folders: options.folders,
|
||||
}),
|
||||
},
|
||||
];
|
||||
case 'camera-triggers':
|
||||
return [
|
||||
{
|
||||
basePath: [CONF_CAMERAS, request.cameraIndex, 'triggers'],
|
||||
schema: getCameraTriggersSchema(),
|
||||
},
|
||||
];
|
||||
case 'camera-event':
|
||||
return [
|
||||
{
|
||||
basePath: [
|
||||
CONF_CAMERAS,
|
||||
request.cameraIndex,
|
||||
'triggers',
|
||||
'events',
|
||||
request.eventIndex,
|
||||
],
|
||||
schema: getTriggerEventSchema(),
|
||||
},
|
||||
];
|
||||
case 'editor-mode':
|
||||
return getEditorModeForms();
|
||||
case 'full-section':
|
||||
return getFullSectionForms(request.name);
|
||||
case 'simple-camera':
|
||||
return getSimpleCameraForms(request.index);
|
||||
case 'simple-menu':
|
||||
return getSimpleMenuForms();
|
||||
case 'simple-top-level':
|
||||
return getSimpleTopLevelForms();
|
||||
case 'full-folder':
|
||||
return getFullFolderForms(request.index);
|
||||
case 'full-camera':
|
||||
return getFullCameraForms(request.index, {
|
||||
// A camera cannot depend on itself.
|
||||
otherCameras: options.cameras.filter(
|
||||
(_camera, index) => index !== request.index,
|
||||
),
|
||||
folders: options.folders,
|
||||
});
|
||||
case 'full-camera-triggers':
|
||||
return getFullCameraTriggersForms(request.cameraIndex);
|
||||
case 'full-camera-event':
|
||||
return getFullCameraEventForms(request.cameraIndex, request.eventIndex);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { CONF_CAMERAS } from '../../../config/const';
|
||||
import { getConfigValue } from '../../../config/management';
|
||||
import type { RawAdvancedCameraCardConfig } from '../../../config/types';
|
||||
import { localize } from '../../../localize/localize';
|
||||
import type { ConfigChange, ConfigPath, EditorForm, FieldBinding } from '../types';
|
||||
import { getCameraSimpleFields } from './cameras';
|
||||
import { createSelectSelector } from './common/selectors';
|
||||
import { getAspectRatioModeOptions } from './dimensions';
|
||||
import {
|
||||
getMenuButtonOptions,
|
||||
getMenuPositionOptions,
|
||||
getMenuStyleOptions,
|
||||
MENU_BUTTONS,
|
||||
} from './menu';
|
||||
import { getProfilesField } from './profiles';
|
||||
import { getViewModeOptions } from './view';
|
||||
|
||||
const getMenuButtonEnabledPath = (button: string): ConfigPath => [
|
||||
'menu',
|
||||
'buttons',
|
||||
button,
|
||||
'enabled',
|
||||
];
|
||||
|
||||
// Whether a button is shown with the configuration as it stands: what the
|
||||
// configuration says, or what the (profile-adjusted) defaults say for a button
|
||||
// the configuration does not mention.
|
||||
const isMenuButtonEnabled = (
|
||||
config: RawAdvancedCameraCardConfig,
|
||||
defaults: RawAdvancedCameraCardConfig,
|
||||
button: string,
|
||||
): boolean => {
|
||||
const path = getMenuButtonEnabledPath(button);
|
||||
const configured = getConfigValue(config, path);
|
||||
return typeof configured === 'boolean'
|
||||
? configured
|
||||
: getConfigValue(defaults, path) === true;
|
||||
};
|
||||
|
||||
// The single control for which buttons the menu shows, standing for one
|
||||
// `enabled` key per button. Roughly half the buttons are shown by default, so a
|
||||
// button the user turns off has to be written out as `false` rather than having
|
||||
// its key removed.
|
||||
const getMenuButtonsBinding = (): FieldBinding => ({
|
||||
formPath: ['menu_buttons'],
|
||||
configPaths: MENU_BUTTONS.map(getMenuButtonEnabledPath),
|
||||
read: (config, defaults) =>
|
||||
MENU_BUTTONS.filter((button) => isMenuButtonEnabled(config, defaults, button)),
|
||||
write: (value, config, defaults) => {
|
||||
const selected = Array.isArray(value) ? value : [];
|
||||
const changes: ConfigChange[] = [];
|
||||
|
||||
for (const button of MENU_BUTTONS) {
|
||||
const wanted = selected.includes(button);
|
||||
if (wanted === isMenuButtonEnabled(config, defaults, button)) {
|
||||
continue;
|
||||
}
|
||||
const path = getMenuButtonEnabledPath(button);
|
||||
changes.push(
|
||||
wanted === (getConfigValue(defaults, path) === true)
|
||||
? { path, type: 'delete' }
|
||||
: { path, type: 'set', value: wanted },
|
||||
);
|
||||
}
|
||||
return changes;
|
||||
},
|
||||
});
|
||||
|
||||
// The simple editor's fields are gathered from across the configuration, so
|
||||
// each is named for what it means where it is shown and bound to where the
|
||||
// setting actually lives.
|
||||
|
||||
export const getSimpleMenuForms = (): EditorForm[] => [
|
||||
{
|
||||
basePath: [],
|
||||
schema: [
|
||||
{
|
||||
name: 'menu_style',
|
||||
selector: createSelectSelector(getMenuStyleOptions()),
|
||||
},
|
||||
{
|
||||
name: 'menu_position',
|
||||
selector: createSelectSelector(getMenuPositionOptions()),
|
||||
},
|
||||
{
|
||||
name: 'menu_buttons',
|
||||
label: localize('config.menu.buttons.editor_label'),
|
||||
selector: createSelectSelector(getMenuButtonOptions(), { multiple: true }),
|
||||
},
|
||||
],
|
||||
bindings: [
|
||||
{ formPath: ['menu_style'], configPath: ['menu', 'style'] },
|
||||
{ formPath: ['menu_position'], configPath: ['menu', 'position'] },
|
||||
getMenuButtonsBinding(),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the settings the simple editor shows above everything else. They are
|
||||
* single controls with nothing behind them, so they are shown as they are
|
||||
* rather than in a section that has to be opened.
|
||||
* @returns The top level forms.
|
||||
*/
|
||||
export const getSimpleTopLevelForms = (): EditorForm[] => [
|
||||
{
|
||||
basePath: [],
|
||||
schema: [
|
||||
{
|
||||
name: 'default_view',
|
||||
selector: createSelectSelector(getViewModeOptions()),
|
||||
},
|
||||
|
||||
// The ratio only means anything alongside the mode that uses it, so the
|
||||
// two are shown together.
|
||||
{
|
||||
type: 'grid',
|
||||
schema: [
|
||||
{
|
||||
name: 'aspect_ratio_mode',
|
||||
selector: createSelectSelector(getAspectRatioModeOptions()),
|
||||
},
|
||||
{
|
||||
name: 'aspect_ratio',
|
||||
selector: { text: {} },
|
||||
},
|
||||
],
|
||||
},
|
||||
getProfilesField(),
|
||||
],
|
||||
bindings: [
|
||||
{ formPath: ['default_view'], configPath: ['view', 'default'] },
|
||||
{
|
||||
formPath: ['aspect_ratio_mode'],
|
||||
configPath: ['dimensions', 'aspect_ratio_mode'],
|
||||
},
|
||||
{ formPath: ['aspect_ratio'], configPath: ['dimensions', 'aspect_ratio'] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the form for one camera in the simple editor: what the camera is and what
|
||||
* it is called, without the settings a working camera does not need.
|
||||
* @param index The camera's position in the configuration.
|
||||
* @returns The camera's forms.
|
||||
*/
|
||||
export const getSimpleCameraForms = (index: number): EditorForm[] => [
|
||||
{
|
||||
basePath: [CONF_CAMERAS, index],
|
||||
schema: getCameraSimpleFields(),
|
||||
},
|
||||
];
|
||||
@@ -4,7 +4,11 @@ import type { EditorForm } from '../types';
|
||||
import { getInteractionModeOptions } from './common/interaction-mode';
|
||||
import { createNumberSelector, createSelectSelector } from './common/selectors';
|
||||
|
||||
const getViewModeOptions = (): HASelectSelectorOption[] => [
|
||||
/**
|
||||
* Get the options for choosing a view.
|
||||
* @returns The view options.
|
||||
*/
|
||||
export const getViewModeOptions = (): HASelectSelectorOption[] => [
|
||||
{ value: 'auto', label: localize('config.view.views.auto') },
|
||||
{ value: 'clip', label: localize('config.view.views.clip') },
|
||||
{ value: 'clips', label: localize('config.view.views.clips') },
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isEqual } from 'lodash-es';
|
||||
|
||||
import type { RawAdvancedCameraCardConfig } from '../../config/types';
|
||||
import type { HAFormSchema } from '../../ha/types';
|
||||
|
||||
@@ -38,6 +40,11 @@ interface PathFieldBinding {
|
||||
interface ComputedFieldBinding {
|
||||
formPath: string[];
|
||||
|
||||
// Every configuration path the field stands for. Stated rather than worked
|
||||
// out from the functions below, which cannot be inspected, so that what the
|
||||
// form as a whole addresses is still answerable.
|
||||
configPaths: ConfigPath[];
|
||||
|
||||
// The value to show for the field, given the whole configuration and the
|
||||
// configuration defaults.
|
||||
read: (
|
||||
@@ -61,6 +68,18 @@ export const isComputedFieldBinding = (
|
||||
binding: FieldBinding,
|
||||
): binding is ComputedFieldBinding => 'read' in binding;
|
||||
|
||||
/**
|
||||
* Get the binding for a field of a form, if it has one.
|
||||
* @param form The form.
|
||||
* @param formPath The field's path within the form's own data.
|
||||
* @returns The binding, or undefined for a field stored where it sits.
|
||||
*/
|
||||
export const findBinding = (
|
||||
form: EditorForm,
|
||||
formPath: string[],
|
||||
): FieldBinding | undefined =>
|
||||
form.bindings?.find((binding) => isEqual(binding.formPath, formPath));
|
||||
|
||||
// One `ha-form` for part of a section. A section splits into more than one of
|
||||
// these when its fields live at different configuration paths, since each form
|
||||
// binds to a single base path. For example, the timeline section uses one form
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '../../components-lib/editor/titles';
|
||||
import type { ConfigPath } from '../../components-lib/editor/types';
|
||||
import { CONF_CAMERAS } from '../../config/const';
|
||||
import type { EditorMode } from '../../config/schema/editor';
|
||||
import type { HomeAssistant } from '../../ha/types';
|
||||
import { localize } from '../../localize/localize';
|
||||
import editorExpanderBodyStyle from '../../scss/editor-expander-body.scss';
|
||||
@@ -45,6 +46,11 @@ export class AdvancedCameraCardEditorCameras extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public input?: FormsInput;
|
||||
|
||||
// Which editor this list is part of. The simple editor shows fewer of a
|
||||
// camera's settings, and none of its triggers.
|
||||
@property()
|
||||
public mode?: EditorMode;
|
||||
|
||||
private _pagesController = new ListPagesController(this);
|
||||
private _formsController = new ListFormsController(this, (path) =>
|
||||
renderDocumentation(path),
|
||||
@@ -103,8 +109,7 @@ export class AdvancedCameraCardEditorCameras extends LitElement {
|
||||
|
||||
private _renderCamera(index: number, hidden: boolean): TemplateResult {
|
||||
const camera = this._formsController.getList(CAMERAS_PATH)[index];
|
||||
const eventsPath: ConfigPath = [CONF_CAMERAS, index, 'triggers', 'events'];
|
||||
const events = this._formsController.getList(eventsPath);
|
||||
const fullMode = this.mode !== 'simple';
|
||||
|
||||
return html`
|
||||
<advanced-camera-card-editor-page
|
||||
@@ -114,32 +119,43 @@ export class AdvancedCameraCardEditorCameras extends LitElement {
|
||||
>
|
||||
${renderForms(
|
||||
this.hass,
|
||||
this._formsController.getFormContexts({ kind: 'camera', index }),
|
||||
this._formsController.getFormContexts(
|
||||
fullMode ? { kind: 'full-camera', index } : { kind: 'simple-camera', index },
|
||||
),
|
||||
)}
|
||||
<ha-expansion-panel
|
||||
outlined
|
||||
.header=${localize('config.cameras.triggers.editor_label')}
|
||||
>
|
||||
<advanced-camera-card-icon
|
||||
slot="leading-icon"
|
||||
.icon=${{ icon: 'mdi:magnify-scan' }}
|
||||
></advanced-camera-card-icon>
|
||||
${this._renderContained(html`
|
||||
${renderDocumentation([CONF_CAMERAS, 'triggers'])}
|
||||
${renderForms(
|
||||
this.hass,
|
||||
this._formsController.getFormContexts({
|
||||
kind: 'camera-triggers',
|
||||
cameraIndex: index,
|
||||
}),
|
||||
)}
|
||||
${this._renderEvents(eventsPath, events)}
|
||||
`)}
|
||||
</ha-expansion-panel>
|
||||
${fullMode ? this._renderTriggers(index) : nothing}
|
||||
</advanced-camera-card-editor-page>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderTriggers(index: number): TemplateResult {
|
||||
const eventsPath: ConfigPath = [CONF_CAMERAS, index, 'triggers', 'events'];
|
||||
const events = this._formsController.getList(eventsPath);
|
||||
|
||||
return html`
|
||||
<ha-expansion-panel
|
||||
outlined
|
||||
.header=${localize('config.cameras.triggers.editor_label')}
|
||||
>
|
||||
<advanced-camera-card-icon
|
||||
slot="leading-icon"
|
||||
.icon=${{ icon: 'mdi:magnify-scan' }}
|
||||
></advanced-camera-card-icon>
|
||||
${this._renderContained(html`
|
||||
${renderDocumentation([CONF_CAMERAS, 'triggers'])}
|
||||
${renderForms(
|
||||
this.hass,
|
||||
this._formsController.getFormContexts({
|
||||
kind: 'full-camera-triggers',
|
||||
cameraIndex: index,
|
||||
}),
|
||||
)}
|
||||
${this._renderEvents(eventsPath, events)}
|
||||
`)}
|
||||
</ha-expansion-panel>
|
||||
`;
|
||||
}
|
||||
|
||||
// The Home Assistant events the triggers watch for, a group of their own
|
||||
// within the triggers panel: they are one kind of trigger among the others,
|
||||
// not a sibling of the whole trigger set.
|
||||
@@ -216,7 +232,7 @@ export class AdvancedCameraCardEditorCameras extends LitElement {
|
||||
${renderForms(
|
||||
this.hass,
|
||||
this._formsController.getFormContexts({
|
||||
kind: 'camera-event',
|
||||
kind: 'full-camera-event',
|
||||
cameraIndex,
|
||||
eventIndex,
|
||||
}),
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
|
||||
import { getDocURL } from '../../components-lib/editor/doc-links';
|
||||
import type { ConfigPath } from '../../components-lib/editor/types';
|
||||
import { localize } from '../../localize/localize';
|
||||
import editorDocLinkStyle from '../../scss/editor-doc-link.scss';
|
||||
|
||||
@@ -48,9 +49,7 @@ export class AdvancedCameraCardEditorDocLink extends LitElement {
|
||||
* @param path The configuration path.
|
||||
* @returns A rendered template, or null when the path has no documentation.
|
||||
*/
|
||||
export const renderDocumentation = (
|
||||
path: (string | number)[],
|
||||
): TemplateResult | null => {
|
||||
export const renderDocumentation = (path: ConfigPath): TemplateResult | null => {
|
||||
const url = getDocURL(path);
|
||||
return url
|
||||
? html`<advanced-camera-card-editor-doc-link
|
||||
|
||||
@@ -64,7 +64,7 @@ export class AdvancedCameraCardEditorFolders extends LitElement {
|
||||
>
|
||||
${renderForms(
|
||||
this.hass,
|
||||
this._controller.getFormContexts({ kind: 'folder', index }),
|
||||
this._controller.getFormContexts({ kind: 'full-folder', index }),
|
||||
)}
|
||||
<ha-alert alert-type="info">
|
||||
${localize('config.folders.ha.path_info')}
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
} from '../../components-lib/editor/forms-controller';
|
||||
import type { FormRequest } from '../../components-lib/editor/schema/registry';
|
||||
import { SectionController } from '../../components-lib/editor/section-controller';
|
||||
import type { ConfigPath } from '../../components-lib/editor/types';
|
||||
import type { HomeAssistant } from '../../ha/types';
|
||||
import editorSectionStyle from '../../scss/editor-section.scss';
|
||||
import { renderDocumentation } from './doc-link';
|
||||
@@ -47,7 +48,7 @@ export class AdvancedCameraCardEditorSection extends LitElement {
|
||||
// The path whose documentation the section links to; the section's own forms
|
||||
// supply the links for everything within them.
|
||||
@property({ attribute: false })
|
||||
public documentationPath?: (string | number)[];
|
||||
public docPath?: ConfigPath;
|
||||
|
||||
// Content shown after the section's schema forms, of which there may be
|
||||
// none. Called only once the section has been opened, and in the render that
|
||||
@@ -96,7 +97,7 @@ export class AdvancedCameraCardEditorSection extends LitElement {
|
||||
@expanded-will-change=${this._stopPropagation}
|
||||
@expanded-changed=${this._stopPropagation}
|
||||
>
|
||||
${this.documentationPath ? renderDocumentation(this.documentationPath) : nothing}
|
||||
${this.docPath ? renderDocumentation(this.docPath) : nothing}
|
||||
${renderForms(this.hass, this._controller.getContexts())}
|
||||
${this.renderCustomContent?.() ?? nothing}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const EDITOR_MODES = ['simple', 'full'] as const;
|
||||
const editorModeSchema = z.enum(EDITOR_MODES);
|
||||
export type EditorMode = z.infer<typeof editorModeSchema>;
|
||||
|
||||
// Options for the visual editor. The card itself renders identically whatever
|
||||
// these are set to.
|
||||
export const editorConfigSchema = z.object({
|
||||
// Which editor experience to show. When unset, the editor chooses for itself
|
||||
// based on whether the configuration only uses settings the simple editor can
|
||||
// express.
|
||||
mode: editorModeSchema.optional(),
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { cameraConfigDefault, cameraConfigSchema, camerasConfigSchema } from './
|
||||
import { cardIDRegex } from './common/const';
|
||||
import { debugConfigDefault, debugConfigSchema, type DebugConfig } from './debug';
|
||||
import { dimensionsConfigSchema } from './dimensions';
|
||||
import { editorConfigSchema } from './editor';
|
||||
import { pictureElementsSchema } from './elements/types';
|
||||
import { foldersConfigSchema } from './folders';
|
||||
import { imageConfigDefault, imageConfigSchema } from './image';
|
||||
@@ -51,6 +52,7 @@ export const advancedCameraCardConfigSchema = z.object({
|
||||
timeline: timelineConfigSchema,
|
||||
performance: performanceConfigSchema,
|
||||
debug: debugConfigSchema,
|
||||
editor: editorConfigSchema.optional(),
|
||||
automations: automationsSchema.optional(),
|
||||
|
||||
profiles: profilesSchema,
|
||||
|
||||
+104
-8
@@ -16,9 +16,16 @@ import './components/editor/section.js';
|
||||
import './components/icon.js';
|
||||
|
||||
import { EditorController } from './components-lib/editor/controller.js';
|
||||
import type { FormsInput } from './components-lib/editor/forms-controller.js';
|
||||
import {
|
||||
FormsController,
|
||||
type FormsInput,
|
||||
} from './components-lib/editor/forms-controller.js';
|
||||
import type { EditorIntent } from './components-lib/editor/intents.js';
|
||||
import type { FormRequest } from './components-lib/editor/schema/registry.js';
|
||||
import type { ConfigChange, ConfigPath } from './components-lib/editor/types.js';
|
||||
import { renderDocumentation } from './components/editor/doc-link.js';
|
||||
import { renderForms } from './components/editor/form.js';
|
||||
import { CONF_CAMERAS } from './config/const.js';
|
||||
import type { RawAdvancedCameraCardConfig } from './config/types.js';
|
||||
import type { HAFormSchema, HomeAssistant, LovelaceCardEditor } from './ha/types.js';
|
||||
import { localize } from './localize/localize.js';
|
||||
@@ -30,6 +37,12 @@ interface EditorSection {
|
||||
name: string;
|
||||
description: string;
|
||||
|
||||
// The forms the section shows, absent for a section that has none of its own.
|
||||
request?: FormRequest;
|
||||
|
||||
// The path whose documentation the section links to.
|
||||
docPath?: ConfigPath;
|
||||
|
||||
// The part of the section a schema cannot express: a list the user adds to
|
||||
// and reorders, or a key assigned by pressing it. A section whose whole
|
||||
// configuration is such a list has no schema forms, and shows only this
|
||||
@@ -133,6 +146,33 @@ const SECTIONS: Record<string, EditorSection> = {
|
||||
},
|
||||
};
|
||||
|
||||
// The two parts of the simple editor with more behind them than a handful of
|
||||
// controls: a list of cameras to add to and drill into, and a menu whose
|
||||
// buttons are a longer list than everything else in the editor put together.
|
||||
// The rest of its settings are shown as they are, below.
|
||||
const SIMPLE_SECTIONS: EditorSection[] = [
|
||||
{
|
||||
icon: 'video',
|
||||
name: localize('editor.cameras'),
|
||||
description: localize('editor.cameras_secondary'),
|
||||
docPath: [CONF_CAMERAS],
|
||||
renderCustomContent: (hass, input) => html`
|
||||
<advanced-camera-card-editor-cameras
|
||||
mode="simple"
|
||||
.hass=${hass}
|
||||
.input=${input}
|
||||
></advanced-camera-card-editor-cameras>
|
||||
`,
|
||||
},
|
||||
{
|
||||
icon: 'menu',
|
||||
name: localize('editor.menu'),
|
||||
description: localize('editor.menu_secondary'),
|
||||
request: { kind: 'simple-menu' },
|
||||
docPath: ['menu'],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Used to side-load lazily-loaded selectors the editor will use.
|
||||
* See {@link AdvancedCameraCardEditor._renderSelectorSideload}.
|
||||
@@ -146,9 +186,13 @@ const SELECTOR_SIDELOAD_SCHEMA: HAFormSchema[] = [
|
||||
{ name: 'entity', selector: { entity: {} } },
|
||||
{ name: 'object', selector: { object: {} } },
|
||||
{ name: 'sideload', type: 'expandable', title: '', schema: [] },
|
||||
{ name: 'sideload-grid', type: 'grid', schema: [] },
|
||||
];
|
||||
const SELECTOR_SIDELOAD_DATA = {};
|
||||
|
||||
const MODE_REQUEST: FormRequest = { kind: 'editor-mode' };
|
||||
const SIMPLE_TOP_LEVEL_REQUEST: FormRequest = { kind: 'simple-top-level' };
|
||||
|
||||
@customElement('advanced-camera-card-editor')
|
||||
export class AdvancedCameraCardEditor extends LitElement implements LovelaceCardEditor {
|
||||
@property({ attribute: false })
|
||||
@@ -156,6 +200,20 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
|
||||
private _controller = new EditorController(this);
|
||||
|
||||
// The forms the editor renders itself rather than within a full editor
|
||||
// section: the switch between the editors, which belongs to neither of them,
|
||||
// and the simple editor's remaining settings, which are shown as they are.
|
||||
private _modeFormsController = this._createFormsController();
|
||||
private _simpleFormsController = this._createFormsController();
|
||||
|
||||
private _createFormsController(): FormsController {
|
||||
return new FormsController(
|
||||
(changes: ConfigChange[]) =>
|
||||
this._controller.applyIntent({ type: 'changes', changes }),
|
||||
(path) => renderDocumentation(path),
|
||||
);
|
||||
}
|
||||
|
||||
public setConfig(config: RawAdvancedCameraCardConfig): void {
|
||||
this._controller.setConfig(config);
|
||||
}
|
||||
@@ -186,32 +244,70 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
@advanced-camera-card:editor:intent=${(ev: CustomEvent<EditorIntent>) =>
|
||||
this._controller.applyIntent(ev.detail)}
|
||||
>
|
||||
${this._renderNotices()}
|
||||
${Object.keys(SECTIONS).map((name) => this._renderSection(name, hass, input))}
|
||||
${[this._renderNotices(), this._renderModeSwitch(hass, input)]}
|
||||
${this._controller.getEditorMode() === 'simple'
|
||||
? this._renderSimple(hass, input)
|
||||
: this._renderFull(hass, input)}
|
||||
${this._renderActionButtons()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderModeSwitch(hass: HomeAssistant, input: FormsInput): TemplateResult {
|
||||
this._modeFormsController.setInput(MODE_REQUEST, input);
|
||||
return html`
|
||||
<div class="mode">
|
||||
<advanced-camera-card-icon
|
||||
.icon=${{ icon: 'mdi:tune' }}
|
||||
></advanced-camera-card-icon>
|
||||
${renderForms(hass, this._modeFormsController.getContexts())}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderSimple(hass: HomeAssistant, input: FormsInput): TemplateResult {
|
||||
this._simpleFormsController.setInput(SIMPLE_TOP_LEVEL_REQUEST, input);
|
||||
return html`
|
||||
${SIMPLE_SECTIONS.map((section) => this._renderSection(section, hass, input))}
|
||||
<div class="settings">
|
||||
${renderForms(hass, this._simpleFormsController.getContexts())}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderFull(hass: HomeAssistant, input: FormsInput): TemplateResult {
|
||||
return html`
|
||||
${Object.entries(SECTIONS).map(([name, section]) =>
|
||||
this._renderSection(
|
||||
{
|
||||
...section,
|
||||
request: { kind: 'full-section', name },
|
||||
docPath: [name],
|
||||
},
|
||||
hass,
|
||||
input,
|
||||
),
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderSection(
|
||||
name: string,
|
||||
section: EditorSection,
|
||||
hass: HomeAssistant,
|
||||
input: FormsInput,
|
||||
): TemplateResult {
|
||||
const section = SECTIONS[name];
|
||||
const request: FormRequest = { kind: 'section', name };
|
||||
const renderCustomContent = section.renderCustomContent;
|
||||
|
||||
return html`
|
||||
<advanced-camera-card-editor-section
|
||||
class="section"
|
||||
.hass=${hass}
|
||||
.request=${request}
|
||||
.request=${section.request}
|
||||
.input=${input}
|
||||
.icon=${`mdi:${section.icon}`}
|
||||
.heading=${section.name}
|
||||
.description=${section.description}
|
||||
.documentationPath=${[name]}
|
||||
.docPath=${section.docPath}
|
||||
.renderCustomContent=${renderCustomContent
|
||||
? () => renderCustomContent(hass, input)
|
||||
: undefined}
|
||||
|
||||
+35
-3
@@ -336,6 +336,15 @@ interface StockHAFormExpandableSchema extends StockHAFormBaseSchema {
|
||||
schema: readonly HAFormSchema[];
|
||||
}
|
||||
|
||||
interface StockHAFormGridSchema extends StockHAFormBaseSchema {
|
||||
type: 'grid';
|
||||
|
||||
// The width below which the columns fall back to one per row.
|
||||
column_min_width?: string;
|
||||
|
||||
schema: readonly HAFormSchema[];
|
||||
}
|
||||
|
||||
// Extensions layered onto the stock shapes; not part of `ha-form` itself.
|
||||
interface CardHAFormSchemaExtensions {
|
||||
// Override the standard path-derived label, e.g. to point shared fields at
|
||||
@@ -356,9 +365,32 @@ export interface HAFormExpandableSchema
|
||||
// the top-level `frigate`/`motioneye` camera keys.
|
||||
name?: string;
|
||||
|
||||
// Explicit documentation-link key, for a nameless group whose link cannot be
|
||||
// Explicit documentation-link path, for a nameless group whose link cannot be
|
||||
// derived from a configuration path.
|
||||
docPath?: string;
|
||||
docPath?: string[];
|
||||
}
|
||||
|
||||
export type HAFormSchema = HAFormSelectorSchema | HAFormExpandableSchema;
|
||||
// Fields laid out in columns rather than one per row. A grid is nameless like
|
||||
// any other visual-only grouping, so the fields within it are stored where they
|
||||
// would be without it.
|
||||
interface HAFormGridSchema
|
||||
extends Omit<StockHAFormGridSchema, 'name'>,
|
||||
CardHAFormSchemaExtensions {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export type HAFormSchema =
|
||||
| HAFormSelectorSchema
|
||||
| HAFormExpandableSchema
|
||||
| HAFormGridSchema;
|
||||
|
||||
/**
|
||||
* Whether a form node is a field the user fills in, rather than a container of
|
||||
* further nodes. Recognized by the selector it carries, since `ha-form` gives a
|
||||
* field nothing else to tell it apart by.
|
||||
* @param schema The node's schema.
|
||||
* @returns `true` for a field.
|
||||
*/
|
||||
export const isFormFieldSchema = (
|
||||
schema: HAFormSchema,
|
||||
): schema is HAFormSelectorSchema => 'selector' in schema;
|
||||
|
||||
@@ -420,8 +420,6 @@
|
||||
"media_viewer_secondary": "Visor de suports estàtics (clips, instantànies o enregistraments)",
|
||||
"menu": "Menú",
|
||||
"menu_secondary": "Opcions d'aspecte del menú",
|
||||
"move_down": "Moure cap avall",
|
||||
"move_up": "Moure cap amunt",
|
||||
"overrides": "Les substitucions estan actives",
|
||||
"overrides_secondary": "S'han detectat substitucions de configuració dinàmica",
|
||||
"performance": "Rendiment",
|
||||
@@ -468,7 +466,6 @@
|
||||
"no_camera_id": "No s'ha pogut determinar l'identificador de la càmera per a la següent càmera, és possible que hagis d'establir el paràmetre 'id' manualment",
|
||||
"no_dashboard_or_view": "Tant els paràmetres 'dashboard_path' com 'view_path' són necessaris per al mètode d'emissió 'dashboard'",
|
||||
"no_live_camera": "El paràmetre 'camera_entity' s'ha de configurar i validar per a aquest proveïdor en directe",
|
||||
"reconnecting": "Reconnectant",
|
||||
"too_many_automations": "Hi ha massa trucades d'automatització imbricades, comproveu la vostra configuració per veure si hi ha bucles",
|
||||
"troubleshooting": "Comproveu la resolució de problemes",
|
||||
"upgrade_available": "Hi ha disponible una actualització automàtica de la configuració de la targeta; visiteu l'editor de targetes visuals",
|
||||
|
||||
@@ -429,6 +429,9 @@
|
||||
},
|
||||
"height": "Card height in CSS units (e.g. '500px')"
|
||||
},
|
||||
"editor": {
|
||||
"mode": "Full editor"
|
||||
},
|
||||
"folders": {
|
||||
"ha": {
|
||||
"editor_label": "Home Assistant Media Folder Options",
|
||||
@@ -534,6 +537,7 @@
|
||||
"clips": "Clips",
|
||||
"display_mode": "Display mode",
|
||||
"download": "Download",
|
||||
"editor_label": "Menu buttons",
|
||||
"enabled": "Button enabled",
|
||||
"expand": "Expand",
|
||||
"folders": "Folders",
|
||||
@@ -768,14 +772,13 @@
|
||||
"media_viewer_secondary": "Viewer for static media",
|
||||
"menu": "Menu",
|
||||
"menu_secondary": "Menu look & feel options",
|
||||
"move_down": "Move down",
|
||||
"move_up": "Move up",
|
||||
"performance": "Performance",
|
||||
"performance_secondary": "Card performance options",
|
||||
"profiles": "Profiles",
|
||||
"profiles_secondary": "Choose pre-configured sets of defaults",
|
||||
"remote_control": "Remote Control",
|
||||
"remote_control_secondary": "Options for remote controlling the card",
|
||||
"simple_coverage": "This card has settings the simple editor does not show. Turn on the full editor to see them",
|
||||
"status_bar": "Status bar",
|
||||
"status_bar_secondary": "Status bar look & feel options",
|
||||
"timeline": "Timeline",
|
||||
@@ -841,7 +844,6 @@
|
||||
"no_camera_id": "Could not determine camera id for the following camera, may need to set 'id' parameter manually",
|
||||
"no_camera_or_media_for_timeline": "No camera or media available for timeline",
|
||||
"no_live_camera": "The camera_entity parameter must be set and valid for this live provider",
|
||||
"reconnecting": "Reconnecting",
|
||||
"too_many_automations": "Too many nested automation calls, please check your configuration for loops",
|
||||
"troubleshooting": "Check troubleshooting",
|
||||
"upgrade_available": "An automated card configuration upgrade is available, please visit the visual card editor",
|
||||
@@ -850,8 +852,7 @@
|
||||
},
|
||||
"issues": {
|
||||
"config_error": {
|
||||
"heading": "Configuration error",
|
||||
"text": "An error occurred evaluating the card configuration"
|
||||
"heading": "Configuration error"
|
||||
},
|
||||
"config_upgrade": {
|
||||
"heading": "Configuration upgrade available",
|
||||
@@ -885,8 +886,7 @@
|
||||
"text_only_legacy": "The legacy 'frigate-hass-card.js' resource must be replaced with 'advanced-camera-card.js'. It will be removed in a future release"
|
||||
},
|
||||
"media_query": {
|
||||
"heading": "Media query failed",
|
||||
"text": "An error occurred fetching media (e.g. thumbnails) for this view."
|
||||
"heading": "Media query failed"
|
||||
},
|
||||
"media_unavailable": {
|
||||
"heading": "Media unavailable",
|
||||
|
||||
@@ -563,8 +563,6 @@
|
||||
"media_viewer_secondary": "Visionneuse de médias statiques (clips, instantanés ou enregistrements)",
|
||||
"menu": "Menu",
|
||||
"menu_secondary": "Options d'apparence et de convivialité du menu",
|
||||
"move_down": "Descendre",
|
||||
"move_up": "Déplacer vers le haut",
|
||||
"overrides": "Remplacements activés",
|
||||
"overrides_secondary": "Remplacements de configuration dynamique détectés",
|
||||
"performance": "Performance",
|
||||
@@ -620,7 +618,6 @@
|
||||
"no_camera_id": "Impossible de déterminer l'identifiant de la caméra suivante. Il faudra peut-être définir le paramètre « ID » manuellement",
|
||||
"no_dashboard_or_view": "Les paramètres 'dashboard_path' et 'view_path' sont requis pour la méthode de conversion 'dashboard'",
|
||||
"no_live_camera": "Le paramètre camera_entity doit être défini et valide pour ce fournisseur en direct",
|
||||
"reconnecting": "Reconnexion",
|
||||
"too_many_automations": "Trop d'appels d'automatisation imbriqués, veuillez vérifier votre configuration pour les boucles",
|
||||
"troubleshooting": "Vérifier le dépannage",
|
||||
"upgrade_available": "Une mise à niveau automatisée de la configuration de la carte est disponible, veuillez visiter l'éditeur visuel de la carte",
|
||||
|
||||
@@ -307,8 +307,6 @@
|
||||
"media_viewer_secondary": "Visualizzatore per supporti statici (clip, istantanee o registrazioni)",
|
||||
"menu": "Menu",
|
||||
"menu_secondary": "Opzioni di aspetto e funzionalità del menu",
|
||||
"move_down": "Sposta verso il basso",
|
||||
"move_up": "Sposta verso l'alto",
|
||||
"overrides": "La sovrascrittura è attiva",
|
||||
"overrides_secondary": "Rilevate sovrascritture della configurazione dinamica",
|
||||
"timeline": "Timeline",
|
||||
@@ -351,7 +349,6 @@
|
||||
"no_camera_entity": "Impossibile trovare l'entità fotocamera",
|
||||
"no_camera_id": "Impossibile determinare l'ID della telecamera , potrebbe essere necessario impostare manualmente il parametro 'ID'",
|
||||
"no_live_camera": "Il parametro fotocamera_enty deve essere impostato e valido per questo provider live",
|
||||
"reconnecting": "Riconnessione",
|
||||
"troubleshooting": "Controllare la risoluzione dei problemi",
|
||||
"upgrade_available": "È disponibile un aggiornamento di configurazione della scheda automatizzato, visitare l'editor di schede visive",
|
||||
"webrtc_card_reported_error": "La scheda WebRTC ha riportato un errore",
|
||||
|
||||
@@ -618,8 +618,6 @@
|
||||
"media_viewer_secondary": "Przeglądarka dla mediów statycznych (klipy, zrzuty, nagrania)",
|
||||
"menu": "Menu",
|
||||
"menu_secondary": "Opcje wyglądu i działania menu",
|
||||
"move_down": "Przesuń w dół",
|
||||
"move_up": "Przesuń w górę",
|
||||
"overrides": "Nadpisania są aktywne",
|
||||
"overrides_secondary": "Wykryto dynamiczne nadpisania konfiguracji",
|
||||
"performance": "Wydajność",
|
||||
@@ -682,7 +680,6 @@
|
||||
"no_camera_or_media_for_timeline": "Brak dostępnej kamery lub mediów dla osi czasu",
|
||||
"no_dashboard_or_view": "Zarówno 'dashboard_path' jak i 'view_path' są wymagane dla metody cast 'dashboard'",
|
||||
"no_live_camera": "Parametr camera_entity musi być ustawiony i prawidłowy dla tego dostawcy",
|
||||
"reconnecting": "Ponowne łączenie",
|
||||
"too_many_automations": "Zbyt wiele zagnieżdżonych wywołań automatyzacji, sprawdź konfigurację pod kątem pętli",
|
||||
"troubleshooting": "Sprawdź rozwiązywanie problemów",
|
||||
"upgrade_available": "Dostępna jest automatyczna aktualizacja konfiguracji karty, odwiedź wizualny edytor karty",
|
||||
|
||||
@@ -312,8 +312,6 @@
|
||||
"media_viewer_secondary": "Opções do visualizador de Snapshots e clipes",
|
||||
"menu": "Menu",
|
||||
"menu_secondary": "Opções de aparência do menu",
|
||||
"move_down": "Descer",
|
||||
"move_up": "Subir",
|
||||
"overrides": "As substituições estão ativas",
|
||||
"overrides_secondary": "Substituições de configuração dinâmica detectadas",
|
||||
"performance": "Desempenho",
|
||||
@@ -358,7 +356,6 @@
|
||||
"no_camera_entity": "Não foi possível encontrar a entidade da câmera",
|
||||
"no_camera_id": "Não foi possível determinar o ID da câmera para a câmera a seguir, pode ser necessário definir o parâmetro 'id' manualmente",
|
||||
"no_live_camera": "O parâmetro camera_entity deve ser definido e válido para este provedor ativo",
|
||||
"reconnecting": "Reconectando",
|
||||
"troubleshooting": "Verifique a solução de problemas",
|
||||
"upgrade_available": "Uma atualização automatizada da configuração do cartão está disponível, visite o editor visual do cartão",
|
||||
"webrtc_card_reported_error": "O cartão WebRTC relatou um erro",
|
||||
|
||||
@@ -307,8 +307,6 @@
|
||||
"media_viewer_secondary": "Opções do visualizador de Snapshots e clipes",
|
||||
"menu": "Menu",
|
||||
"menu_secondary": "Opções de aparência do menu",
|
||||
"move_down": "Descer",
|
||||
"move_up": "Subir",
|
||||
"overrides": "As substituições estão ativas",
|
||||
"overrides_secondary": "Substituições de configuração dinâmica detectadas",
|
||||
"timeline": "Linha do tempo",
|
||||
@@ -351,7 +349,6 @@
|
||||
"no_camera_entity": "Não existe uma entidade câmera",
|
||||
"no_camera_id": "Não foi possível determinar o ID da câmera para a câmera a seguir, pode ser necessário definir o parâmetro 'id' manualmente",
|
||||
"no_live_camera": "O parâmetro camera_entity deve ser definido e válido para este serviço ativo",
|
||||
"reconnecting": "A voltar a ligar",
|
||||
"troubleshooting": "Verifique a solução de problemas",
|
||||
"upgrade_available": "Uma atualização automatizada da configuração do cartão está disponível, visite o editor visual do cartão",
|
||||
"webrtc_card_reported_error": "O cartão WebRTC relatou um erro",
|
||||
|
||||
@@ -13,6 +13,40 @@
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
// The switch between the editors, set apart from what it switches: it is a
|
||||
// control over the editor rather than one of the card settings below it. Its
|
||||
// icon sits beside the form so the row reads as one of the editor's rows, each
|
||||
// of which carries an icon.
|
||||
.mode {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
margin-bottom: 8px;
|
||||
padding: 0 10px 8px;
|
||||
|
||||
border-bottom: 1px solid var(--advanced-camera-card-divider-color);
|
||||
}
|
||||
|
||||
// Spaced and de-emphasized to match the icons on the sections below. The form
|
||||
// insets its own label, so the gap here is the remainder of that spacing.
|
||||
.mode advanced-camera-card-icon {
|
||||
margin-right: 8px;
|
||||
|
||||
color: var(--advanced-camera-card-editor-secondary-text-color);
|
||||
}
|
||||
|
||||
.mode ha-form {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
// The simple editor's settings that are not in a section. They are inset to
|
||||
// line up with the sections above, whose outlines the panels draw themselves.
|
||||
.settings ha-form {
|
||||
display: block;
|
||||
|
||||
margin: 8px 10px;
|
||||
}
|
||||
|
||||
// Each top-level section is an HA expansion panel. An open section draws its
|
||||
// own outline, which would otherwise sit directly against the neighbouring
|
||||
// sections, so every section is spaced whether it is open or not: a gap that
|
||||
|
||||
Reference in New Issue
Block a user