Add custom date/time media filtering.

This commit is contained in:
Dermot Duffy
2024-02-07 22:06:21 -08:00
parent 88a87e822a
commit e21756887d
14 changed files with 1667 additions and 492 deletions
+26 -11
View File
@@ -1,5 +1,5 @@
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement } from 'lit/decorators.js';
import { customElement, property } from 'lit/decorators.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { localize } from '../localize/localize';
import datePickerStyle from '../scss/date-picker.scss';
@@ -7,32 +7,47 @@ import { stopEventFromActivatingCardWideActions } from '../utils/action';
import { dispatchFrigateCardEvent } from '../utils/basic';
export interface DatePickerEvent {
date: Date;
date: Date | null;
}
@customElement('frigate-card-date-picker')
export class FrigateCardDatePicker extends LitElement {
@property({ attribute: false })
public icon?: string;
protected _refInput: Ref<HTMLInputElement> = createRef();
get value(): Date | null {
return this._refInput.value?.value ? new Date(this._refInput.value.value) : null;
}
public reset(): void {
if (this._refInput.value) {
this._refInput.value.value = '';
}
}
protected render(): TemplateResult {
const changed = () => {
const value = this._refInput.value?.value;
dispatchFrigateCardEvent<DatePickerEvent>(this, 'date-picker:change', {
date: value ? new Date(value) : null,
});
};
return html`<input
aria-label="${localize('timeline.select_date')}"
title="${localize('timeline.select_date')}"
${ref(this._refInput)}
type="datetime-local"
@input=${() => {
const value = this._refInput.value?.value;
if (value) {
dispatchFrigateCardEvent<DatePickerEvent>(this, 'date-picker:change', {
date: new Date(value),
});
}
}}
@input=${() => changed()}
@change=${() => changed()}
/>
<ha-icon
aria-label="${localize('timeline.select_date')}"
title="${localize('timeline.select_date')}"
.icon=${`mdi:calendar-search`}
.icon=${this.icon ?? `mdi:calendar-search`}
@click=${(ev: Event) => {
stopEventFromActivatingCardWideActions(ev);
this._refInput.value?.showPicker();
+130 -474
View File
@@ -1,73 +1,32 @@
import { ScopedRegistryHost } from '@lit-labs/scoped-registry-mixin';
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
import endOfDay from 'date-fns/endOfDay';
import endOfMonth from 'date-fns/endOfMonth';
import endOfYesterday from 'date-fns/endOfYesterday';
import endOfToday from 'date-fns/esm/endOfToday';
import startOfToday from 'date-fns/esm/startOfToday';
import format from 'date-fns/format';
import parse from 'date-fns/parse';
import startOfDay from 'date-fns/startOfDay';
import startOfYesterday from 'date-fns/startOfYesterday';
import sub from 'date-fns/sub';
import { ScopedRegistryHost } from '@lit-labs/scoped-registry-mixin';
import {
CSSResultGroup,
html,
LitElement,
PropertyValues,
ReactiveController,
ReactiveControllerHost,
TemplateResult,
unsafeCSS,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import isEqual from 'lodash-es/isEqual';
import orderBy from 'lodash-es/orderBy';
import uniqWith from 'lodash-es/uniqWith';
import { CameraManager } from '../camera-manager/manager';
import { DateRange } from '../camera-manager/range';
import { DataQuery, MediaMetadata, QueryType } from '../camera-manager/types';
import {
MediaFilterController,
MediaFilterCoreFavoriteSelection,
MediaFilterCoreWhen,
MediaFilterMediaType,
} from '../components-lib/media-filter-controller';
import { CardWideConfig } from '../config/types';
import { localize } from '../localize/localize';
import mediaFilterStyle from '../scss/media-filter.scss';
import { errorToConsole, formatDate, prettifyTitle } from '../utils/basic';
import { executeMediaQueryForViewWithErrorDispatching } from '../utils/media-to-view.js';
import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries';
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
import { View } from '../view/view';
import './select';
import { FrigateCardSelect, SelectOption, SelectValues } from './select';
import { FrigateCardDatePicker } from './date-picker';
import './date-picker.js';
import { FrigateCardSelect } from './select';
import './select.js';
interface MediaFilterCoreDefaults {
cameraIDs?: string[];
favorite?: MediaFilterCoreFavoriteSelection;
mediaType?: MediaFilterMediaType;
what?: string[];
when?: string;
where?: string[];
tags?: string[];
}
export enum MediaFilterCoreFavoriteSelection {
Favorite = 'favorite',
NotFavorite = 'not-favorite',
}
export enum MediaFilterCoreWhen {
Today = 'today',
Yesterday = 'yesterday',
PastWeek = 'past-week',
PastMonth = 'past-month',
}
export enum MediaFilterMediaType {
Clips = 'clips',
Snapshots = 'snapshots',
Recordings = 'recordings',
}
@customElement('frigate-card-media-filter')
class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
@property({ attribute: false })
@@ -84,429 +43,196 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
static elementDefinitions = {
'frigate-card-select': FrigateCardSelect,
'frigate-card-date-picker': FrigateCardDatePicker,
};
protected _mediaMetadataController?: MediaMetadataController;
protected _mediaTypeOptions: SelectOption[];
protected _cameraOptions?: SelectOption[];
protected _whenOptions?: SelectOption[];
protected _favoriteOptions: SelectOption[];
protected _defaults: MediaFilterCoreDefaults | null = null;
protected _mediaFilterController = new MediaFilterController(this);
protected _refMediaType: Ref<FrigateCardSelect> = createRef();
protected _refCamera: Ref<FrigateCardSelect> = createRef();
protected _refWhen: Ref<FrigateCardSelect> = createRef();
protected _refWhenFrom: Ref<FrigateCardDatePicker> = createRef();
protected _refWhenTo: Ref<FrigateCardDatePicker> = createRef();
protected _refWhat: Ref<FrigateCardSelect> = createRef();
protected _refWhere: Ref<FrigateCardSelect> = createRef();
protected _refFavorite: Ref<FrigateCardSelect> = createRef();
protected _refTags: Ref<FrigateCardSelect> = createRef();
constructor() {
super();
this._favoriteOptions = [
{
value: MediaFilterCoreFavoriteSelection.Favorite,
label: localize('media_filter.favorite'),
},
{
value: MediaFilterCoreFavoriteSelection.NotFavorite,
label: localize('media_filter.not_favorite'),
},
];
this._mediaTypeOptions = [
{
value: MediaFilterMediaType.Clips,
label: localize('media_filter.media_types.clips'),
},
{
value: MediaFilterMediaType.Snapshots,
label: localize('media_filter.media_types.snapshots'),
},
{
value: MediaFilterMediaType.Recordings,
label: localize('media_filter.media_types.recordings'),
},
];
}
protected _stringToDateRange(input: string): DateRange {
const dates = input.split(',');
return {
start: parse(dates[0], 'yyyy-MM-dd', new Date()),
end: parse(dates[1], 'yyyy-MM-dd', new Date()),
};
}
protected _dateRangeToString(when: DateRange): string {
return `${formatDate(when.start)},${formatDate(when.end)}`;
}
protected _getWhen(): DateRange | null {
const value = this._refWhen.value?.value;
if (!value || Array.isArray(value)) {
return null;
}
const now = new Date();
switch (value) {
case MediaFilterCoreWhen.Today:
return { start: startOfToday(), end: endOfToday() };
case MediaFilterCoreWhen.Yesterday:
return { start: startOfYesterday(), end: endOfYesterday() };
case MediaFilterCoreWhen.PastWeek:
return { start: startOfDay(sub(now, { days: 7 })), end: endOfDay(now) };
case MediaFilterCoreWhen.PastMonth:
return { start: startOfDay(sub(now, { months: 1 })), end: endOfDay(now) };
default:
return this._stringToDateRange(value);
}
}
protected async _valueChangedHandler(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_ev: CustomEvent<{ value: unknown }>,
): Promise<void> {
const visibleCameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs();
if (!this.hass || !visibleCameraIDs || !this.cameraManager || !this.view) {
return;
}
const getArrayValueAsSet = (val?: SelectValues): Set<string> | null => {
// The reported value may be '' if the field is clearable (i.e. the user
// can click 'x').
if (val && Array.isArray(val) && val.length && !val.includes('')) {
return new Set([...val]);
}
return null;
};
const cameraIDs =
getArrayValueAsSet(this._refCamera.value?.value) ?? visibleCameraIDs;
const mediaType = this._refMediaType.value?.value as
| MediaFilterMediaType
| undefined;
const when = this._getWhen();
const favorite = this._refFavorite.value?.value
? this._refFavorite.value.value === MediaFilterCoreFavoriteSelection.Favorite
: null;
// A note on views:
// - In the below, if the user selects a camera to view media for, the main
// view camera is also set to that value (e.g. a user browsing the
// gallery, chooses a different camera in the media filter, then
// subsequently chooses the live button -- they would expect the live view
// for that filtered camera not the prior camera).
// - Similarly, if the user chooses clips or snapshots, set the actual view
// to 'clips' or 'snapshots' in order to ensure the right icon is shown as
// selected in the menu.
const limit = this.cardWideConfig?.performance?.features.media_chunk_size;
if (
mediaType === MediaFilterMediaType.Clips ||
mediaType === MediaFilterMediaType.Snapshots
) {
const where = getArrayValueAsSet(this._refWhere.value?.value);
const what = getArrayValueAsSet(this._refWhat.value?.value);
const tags = getArrayValueAsSet(this._refTags.value?.value);
const queries = new EventMediaQueries([
{
type: QueryType.Event,
cameraIDs: cameraIDs,
...(tags && { tags: tags }),
...(what && { what: what }),
...(where && { where: where }),
...(favorite !== null && { favorite: favorite }),
...(when && { start: when.start, end: when.end }),
...(limit && { limit: limit }),
...(mediaType === MediaFilterMediaType.Clips && { hasClip: true }),
...(mediaType === MediaFilterMediaType.Snapshots && {
hasSnapshot: true,
}),
},
]);
(
await executeMediaQueryForViewWithErrorDispatching(
this,
this.cameraManager,
this.view,
queries,
{
// See 'A note on views' above for these two arguments.
...(cameraIDs.size === 1 && { targetCameraID: [...cameraIDs][0] }),
targetView: mediaType === MediaFilterMediaType.Clips ? 'clips' : 'snapshots',
},
)
)?.dispatchChangeEvent(this);
} else if (mediaType === MediaFilterMediaType.Recordings) {
const queries = new RecordingMediaQueries([
{
type: QueryType.Recording,
cameraIDs: cameraIDs,
...(limit && { limit: limit }),
...(when && { start: when.start, end: when.end }),
},
]);
(
await executeMediaQueryForViewWithErrorDispatching(
this,
this.cameraManager,
this.view,
queries,
{
// See 'A note on views' above for these two arguments.
...(cameraIDs.size === 1 && { targetCameraID: [...cameraIDs][0] }),
targetView: 'recordings',
},
)
)?.dispatchChangeEvent(this);
}
}
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('cameraManager')) {
const cameras = this.cameraManager?.getStore().getVisibleCameraIDs();
if (cameras) {
this._cameraOptions = [...cameras].map((cameraID) => ({
value: cameraID,
label: this.hass
? this.cameraManager?.getCameraMetadata(cameraID)?.title ?? ''
: '',
}));
}
if (changedProps.has('cameraManager') && this.cameraManager) {
this._mediaFilterController.computeCameraOptions(this.cameraManager);
this._mediaFilterController.computeMetadataOptions(this.cameraManager);
}
if (changedProps.has('cameraManager') && this.hass && this.cameraManager) {
this._mediaMetadataController = new MediaMetadataController(
this,
if (
changedProps.has('view') &&
!changedProps.get('view') &&
this.view &&
this.cameraManager
) {
this._mediaFilterController.computeInitialDefaultsFromView(
this.cameraManager,
this.view,
);
}
// Relative time based options are not pre-computed here to ensure relative
// dates (e.g. 'today') are always calculated when activated not when
// rendered.
this._whenOptions = [
{
value: MediaFilterCoreWhen.Today,
label: localize('media_filter.whens.today'),
},
{
value: MediaFilterCoreWhen.Yesterday,
label: localize('media_filter.whens.yesterday'),
},
{
value: MediaFilterCoreWhen.PastWeek,
label: localize('media_filter.whens.past_week'),
},
{
value: MediaFilterCoreWhen.PastMonth,
label: localize('media_filter.whens.past_month'),
},
...(this._mediaMetadataController?.whenOptions ?? []),
];
if (changedProps.has('view')) {
const newDefaults = this._getDefaultsFromView();
if (!isEqual(newDefaults, this._defaults)) {
this._defaults = newDefaults;
}
}
}
protected _getDefaultsFromView(): MediaFilterCoreDefaults | null {
const queries = this.view?.query?.getQueries();
const visibleCameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs();
if (!this.view || !queries || !visibleCameraIDs) {
return null;
}
let mediaType: MediaFilterMediaType | undefined;
let cameraIDs: string[] | undefined;
let what: string[] | undefined;
let where: string[] | undefined;
let favorite: MediaFilterCoreFavoriteSelection | undefined;
let tags: string[] | undefined;
const cameraIDSets = uniqWith(
queries.map((query: DataQuery) => query.cameraIDs),
isEqual,
);
// Special note: If all visible cameras are selected, this is the same as no
// selector at all.
if (cameraIDSets.length === 1 && !isEqual(queries[0].cameraIDs, visibleCameraIDs)) {
cameraIDs = [...queries[0].cameraIDs];
}
const favoriteValues = uniqWith(
queries.map((query) => query.favorite),
isEqual,
);
if (favoriteValues.length === 1 && queries[0].favorite !== undefined) {
favorite = queries[0].favorite
? MediaFilterCoreFavoriteSelection.Favorite
: MediaFilterCoreFavoriteSelection.NotFavorite;
}
if (MediaQueriesClassifier.areEventQueries(this.view.query)) {
const queries = this.view.query.getQueries();
if (!queries) {
return null;
}
const hasClips = uniqWith(
queries.map((query) => query.hasClip),
isEqual,
);
const hasSnapshots = uniqWith(
queries.map((query) => query.hasSnapshot),
isEqual,
);
if (hasClips.length === 1 && hasSnapshots.length === 1) {
mediaType = !!hasClips[0]
? MediaFilterMediaType.Clips
: !!hasSnapshots[0]
? MediaFilterMediaType.Snapshots
: undefined;
}
const whatSets = uniqWith(
queries.map((query) => query.what),
isEqual,
);
if (whatSets.length === 1 && queries[0].what?.size) {
what = [...queries[0].what];
}
const whereSets = uniqWith(
queries.map((query) => query.where),
isEqual,
);
if (whereSets.length === 1 && queries[0].where?.size) {
where = [...queries[0].where];
}
const tagsSets = uniqWith(
queries.map((query) => query.tags),
isEqual,
);
if (tagsSets.length === 1 && queries[0].tags?.size) {
tags = [...queries[0].tags];
}
} else if (MediaQueriesClassifier.areRecordingQueries(this.view.query)) {
mediaType = MediaFilterMediaType.Recordings;
}
return {
...(mediaType && { mediaType: mediaType }),
...(cameraIDs && { cameraIDs: cameraIDs }),
...(what && { what: what }),
...(where && { where: where }),
...(favorite !== undefined && { favorite: favorite }),
...(tags && { tags: tags }),
};
}
protected render(): TemplateResult | void {
if (!this._mediaMetadataController) {
const valueChange = async () => {
if (!this.cameraManager || !this.view || !this.cardWideConfig) {
return;
}
await this._mediaFilterController.valueChangeHandler(
this.cameraManager,
this.view,
this.cardWideConfig,
{
camera: this._refCamera.value?.value,
mediaType: this._refMediaType.value?.value as MediaFilterMediaType | undefined,
when: {
selected: this._refWhen.value?.value,
from: this._refWhenFrom.value?.value,
to: this._refWhenTo.value?.value,
},
favorite: this._refFavorite.value?.value as
| MediaFilterCoreFavoriteSelection
| undefined,
where: this._refWhere.value?.value,
what: this._refWhat.value?.value,
tags: this._refTags.value?.value,
},
);
};
// Ensure that the "When" selector and the custom calendar to/from selectors
// are ~mutually exclusive.
const whenChange = async (whenPriority?: 'custom' | 'selected'): Promise<void> => {
if (whenPriority === 'custom' && this._refWhen.value) {
if (!this._refWhenFrom.value?.value && !this._refWhenTo.value?.value) {
this._refWhen.value.reset();
} else {
this._refWhen.value.value = MediaFilterCoreWhen.Custom;
}
} else if (this._refWhen.value?.value !== MediaFilterCoreWhen.Custom) {
this._refWhenFrom.value?.reset();
this._refWhenTo.value?.reset();
}
await valueChange();
};
if (!this.cameraManager || !this.view) {
return;
}
const areEvents = !!(
this.view?.query && MediaQueriesClassifier.areEventQueries(this.view.query)
const controls = this._mediaFilterController.getControlsToShow(
this.cameraManager,
this.view,
);
const areRecordings = !!(
this.view?.query && MediaQueriesClassifier.areRecordingQueries(this.view.query)
);
const managerCapabilities = this.cameraManager?.getAggregateCameraCapabilities();
// Which media controls are shown depends on the view.
const showFavoriteControl = areEvents
? !!managerCapabilities?.canFavoriteEvents
: areRecordings
? !!managerCapabilities?.canFavoriteRecordings
: false;
const defaults = this._mediaFilterController.getDefaults();
const whatOptions = this._mediaFilterController.getWhatOptions();
const tagsOptions = this._mediaFilterController.getTagsOptions();
const whereOptions = this._mediaFilterController.getWhereOptions();
return html` <frigate-card-select
${ref(this._refMediaType)}
label=${localize('media_filter.media_type')}
placeholder=${localize('media_filter.select_media_type')}
.options=${this._mediaTypeOptions}
.value=${this._defaults?.mediaType}
@frigate-card:select:change=${this._valueChangedHandler.bind(this)}
>
</frigate-card-select>
<frigate-card-select
${ref(this._refWhen)}
.label=${localize('media_filter.when')}
placeholder=${localize('media_filter.select_when')}
.options=${this._whenOptions}
.value=${this._defaults?.when}
clearable
@frigate-card:select:change=${this._valueChangedHandler.bind(this)}
.options=${this._mediaFilterController.getMediaTypeOptions()}
.initialValue=${defaults?.mediaType}
@frigate-card:select:change=${() => valueChange()}
>
</frigate-card-select>
<div class="when">
<frigate-card-select
${ref(this._refWhen)}
.label=${localize('media_filter.when')}
placeholder=${localize('media_filter.select_when')}
.options=${this._mediaFilterController.getWhenOptions()}
.initialValue=${defaults?.when}
clearable
@frigate-card:select:change=${() => whenChange('selected')}
>
</frigate-card-select>
<frigate-card-date-picker
class="${classMap({
selected: !!this._refWhenFrom.value?.value,
hidden: this._refWhen.value?.value !== MediaFilterCoreWhen.Custom,
})}"
${ref(this._refWhenFrom)}
.icon=${'mdi:calendar-arrow-right'}
@frigate-card:date-picker:change=${() => whenChange('custom')}
>
</frigate-card-date-picker>
<frigate-card-date-picker
class="${classMap({
selected: !!this._refWhenTo.value?.value,
hidden: this._refWhen.value?.value !== MediaFilterCoreWhen.Custom,
})}"
${ref(this._refWhenTo)}
.icon=${'mdi:calendar-arrow-left'}
@frigate-card:date-picker:change=${() => whenChange('custom')}
>
</frigate-card-date-picker>
</div>
<frigate-card-select
${ref(this._refCamera)}
.label=${localize('media_filter.camera')}
placeholder=${localize('media_filter.select_camera')}
.options=${this._cameraOptions}
.value=${this._defaults?.cameraIDs}
.options=${this._mediaFilterController.getCameraOptions()}
.initialValue=${defaults?.cameraIDs}
clearable
multiple
@frigate-card:select:change=${this._valueChangedHandler.bind(this)}
@frigate-card:select:change=${() => valueChange()}
>
</frigate-card-select>
${areEvents && this._mediaMetadataController.whatOptions.length
${controls.events && whatOptions.length
? html` <frigate-card-select
${ref(this._refWhat)}
label=${localize('media_filter.what')}
placeholder=${localize('media_filter.select_what')}
clearable
multiple
.options=${this._mediaMetadataController.whatOptions}
.value=${this._defaults?.what}
@frigate-card:select:change=${this._valueChangedHandler.bind(this)}
.options=${whatOptions}
.initialValue=${defaults?.what}
@frigate-card:select:change=${() => valueChange()}
>
</frigate-card-select>`
: ''}
${areEvents && this._mediaMetadataController.tagsOptions.length
${controls.events && tagsOptions.length
? html` <frigate-card-select
${ref(this._refTags)}
label=${localize('media_filter.tag')}
placeholder=${localize('media_filter.select_tag')}
clearable
multiple
.options=${this._mediaMetadataController.tagsOptions}
.value=${this._defaults?.tags}
@frigate-card:select:change=${this._valueChangedHandler.bind(this)}
.options=${tagsOptions}
.initialValue=${defaults?.tags}
@frigate-card:select:change=${() => valueChange()}
>
</frigate-card-select>`
: ''}
${areEvents && this._mediaMetadataController.whereOptions.length
${controls.events && whereOptions.length
? html` <frigate-card-select
${ref(this._refWhere)}
label=${localize('media_filter.where')}
placeholder=${localize('media_filter.select_where')}
clearable
multiple
.options=${this._mediaMetadataController.whereOptions}
.value=${this._defaults?.where}
@frigate-card:select:change=${this._valueChangedHandler.bind(this)}
.options=${whereOptions}
.initialValue=${defaults?.where}
@frigate-card:select:change=${() => valueChange()}
>
</frigate-card-select>`
: ''}
${showFavoriteControl
${controls.favorites
? html`
<frigate-card-select
${ref(this._refFavorite)}
label=${localize('media_filter.favorite')}
placeholder=${localize('media_filter.select_favorite')}
.options=${this._favoriteOptions}
.value=${this._defaults?.favorite}
.options=${this._mediaFilterController.getFavoriteOptions()}
.initialValue=${defaults?.favorite}
clearable
@frigate-card:select:change=${this._valueChangedHandler.bind(this)}
@frigate-card:select:change=${() => valueChange()}
>
</frigate-card-select>
`
@@ -518,76 +244,6 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
}
}
export class MediaMetadataController implements ReactiveController {
protected _host: ReactiveControllerHost;
protected _cameraManager: CameraManager;
public tagsOptions: SelectOption[] = [];
public whenOptions: SelectOption[] = [];
public whatOptions: SelectOption[] = [];
public whereOptions: SelectOption[] = [];
constructor(host: ReactiveControllerHost, cameraManager: CameraManager) {
this._host = host;
this._cameraManager = cameraManager;
host.addController(this);
}
protected _dateRangeToString(when: DateRange): string {
return `${formatDate(when.start)},${formatDate(when.end)}`;
}
async hostConnected() {
let metadata: MediaMetadata | null;
try {
metadata = await this._cameraManager.getMediaMetadata();
} catch (e) {
errorToConsole(e as Error);
return;
}
if (!metadata) {
return;
}
if (metadata.what) {
this.whatOptions = [...metadata.what]
.sort()
.map((what) => ({ value: what, label: prettifyTitle(what) }));
}
if (metadata.where) {
this.whereOptions = [...metadata.where]
.sort()
.map((where) => ({ value: where, label: prettifyTitle(where) }));
}
if (metadata.tags) {
this.tagsOptions = [...metadata.tags]
.sort()
.map((tag) => ({ value: tag, label: prettifyTitle(tag) }));
}
if (metadata.days) {
const yearMonths: Set<string> = new Set();
[...metadata.days].forEach((day) => {
// An efficient conversion: "2023-01-26" -> "2023-01"
yearMonths.add(day.substring(0, 7));
});
const monthStarts: Date[] = [];
yearMonths.forEach((yearMonth) => {
monthStarts.push(parse(yearMonth, 'yyyy-MM', new Date()));
});
this.whenOptions = orderBy(monthStarts, (date) => date.getTime(), 'desc').map(
(monthStart) => ({
label: format(monthStart, 'MMMM yyyy'),
value: this._dateRangeToString({
start: monthStart,
end: endOfMonth(monthStart),
}),
}),
);
}
this._host.requestUpdate();
}
}
declare global {
interface HTMLElementTagNameMap {
'frigate-card-media-filter': FrigateCardMediaFilter;
+22 -2
View File
@@ -1,4 +1,11 @@
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import {
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
unsafeCSS,
} from 'lit';
import { property } from 'lit/decorators.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import selectStyle from '../scss/select.scss';
@@ -26,6 +33,9 @@ export class FrigateCardSelect extends ScopedRegistryHost(LitElement) {
@property({ attribute: false, hasChanged: contentsChanged })
public value?: SelectValues;
@property({ attribute: false, hasChanged: contentsChanged })
public initialValue?: SelectValues;
@property({ attribute: true })
public label?: string;
@@ -45,6 +55,10 @@ export class FrigateCardSelect extends ScopedRegistryHost(LitElement) {
...grSelectElements,
};
public reset(): void {
this.value = undefined;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected _valueChangedHandler(_ev: CustomEvent<{ value: unknown }>): void {
const value: SelectValues | undefined = this._refSelect.value?.value;
@@ -57,6 +71,12 @@ export class FrigateCardSelect extends ScopedRegistryHost(LitElement) {
}
}
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('initialValue') && this.initialValue && !this.value) {
this.value = this.initialValue;
}
}
protected render(): TemplateResult | void {
return html` <gr-select
${ref(this._refSelect)}
@@ -65,7 +85,7 @@ export class FrigateCardSelect extends ScopedRegistryHost(LitElement) {
size="small"
?multiple=${this.multiple}
?clearable=${this.clearable}
.value=${this.value ?? this._refSelect.value?.value ?? []}
.value=${this.value ?? []}
@gr-change=${this._valueChangedHandler.bind(this)}
>
${this.options?.map(
+3 -1
View File
@@ -335,7 +335,9 @@ export class FrigateCardTimelineCore extends LitElement {
<frigate-card-date-picker
${ref(this._refDatePicker)}
@frigate-card:date-picker:change=${(ev: CustomEvent<DatePickerEvent>) => {
this._timeline?.moveTo(ev.detail.date);
if (ev.detail.date) {
this._timeline?.moveTo(ev.detail.date);
}
}}
>
</frigate-card-date-picker>