Simplify schema.
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
// TODO use the schema default remover code to simplify config schema
|
// TODO use the schema default remover code to simplify config schema
|
||||||
|
// TODO in fullscreen mode on live view with mixed cameras, center video
|
||||||
// TODO verify README links worked correctly (e.g. basic cameras configuration)
|
// TODO verify README links worked correctly (e.g. basic cameras configuration)
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|||||||
+66
-125
@@ -11,6 +11,8 @@ import {
|
|||||||
} from 'custom-card-helpers';
|
} from 'custom-card-helpers';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { deepRemoveDefaults } from './zod-util';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface HTMLElementTagNameMap {
|
interface HTMLElementTagNameMap {
|
||||||
'frigate-card-editor': LovelaceCardEditor;
|
'frigate-card-editor': LovelaceCardEditor;
|
||||||
@@ -101,15 +103,13 @@ const customActionSchema = schemaForType<CustomActionConfig>()(
|
|||||||
action: z.literal('fire-dom-event'),
|
action: z.literal('fire-dom-event'),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const frigateCardCustomActionBaseSchema = customActionSchema.merge(
|
const frigateCardCustomActionBaseSchema = customActionSchema.extend({
|
||||||
z.object({
|
|
||||||
// Syntactic sugar to avoid 'fire-dom-event' as part of an external API.
|
// Syntactic sugar to avoid 'fire-dom-event' as part of an external API.
|
||||||
action: z
|
action: z
|
||||||
.literal('custom:frigate-card-action')
|
.literal('custom:frigate-card-action')
|
||||||
.transform((): 'fire-dom-event' => 'fire-dom-event')
|
.transform((): 'fire-dom-event' => 'fire-dom-event')
|
||||||
.or(z.literal('fire-dom-event')),
|
.or(z.literal('fire-dom-event')),
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const FRIGATE_CARD_GENERAL_ACTIONS = [
|
const FRIGATE_CARD_GENERAL_ACTIONS = [
|
||||||
'frigate',
|
'frigate',
|
||||||
@@ -126,17 +126,13 @@ const FRIGATE_CARD_GENERAL_ACTIONS = [
|
|||||||
const FRIGATE_CARD_ACTIONS = [...FRIGATE_CARD_GENERAL_ACTIONS, 'camera_select'] as const;
|
const FRIGATE_CARD_ACTIONS = [...FRIGATE_CARD_GENERAL_ACTIONS, 'camera_select'] as const;
|
||||||
export type FrigateCardAction = typeof FRIGATE_CARD_ACTIONS[number];
|
export type FrigateCardAction = typeof FRIGATE_CARD_ACTIONS[number];
|
||||||
|
|
||||||
const frigateCardGeneralActionSchema = frigateCardCustomActionBaseSchema.merge(
|
const frigateCardGeneralActionSchema = frigateCardCustomActionBaseSchema.extend({
|
||||||
z.object({
|
|
||||||
frigate_card_action: z.enum(FRIGATE_CARD_GENERAL_ACTIONS),
|
frigate_card_action: z.enum(FRIGATE_CARD_GENERAL_ACTIONS),
|
||||||
}),
|
});
|
||||||
);
|
const frigateCardCameraSelectActionSchema = frigateCardCustomActionBaseSchema.extend({
|
||||||
const frigateCardCameraSelectActionSchema = frigateCardCustomActionBaseSchema.merge(
|
|
||||||
z.object({
|
|
||||||
frigate_card_action: z.literal('camera_select'),
|
frigate_card_action: z.literal('camera_select'),
|
||||||
camera: z.string(),
|
camera: z.string(),
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
export const frigateCardCustomActionSchema = z.union([
|
export const frigateCardCustomActionSchema = z.union([
|
||||||
frigateCardGeneralActionSchema,
|
frigateCardGeneralActionSchema,
|
||||||
frigateCardCameraSelectActionSchema,
|
frigateCardCameraSelectActionSchema,
|
||||||
@@ -169,12 +165,10 @@ const actionsSchema = z.object({
|
|||||||
actions: actionBaseSchema.optional(),
|
actions: actionBaseSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const elementsBaseSchema = actionBaseSchema.merge(
|
const elementsBaseSchema = actionBaseSchema.extend({
|
||||||
z.object({
|
|
||||||
style: z.object({}).passthrough().optional(),
|
style: z.object({}).passthrough().optional(),
|
||||||
title: z.string().nullable().optional(),
|
title: z.string().nullable().optional(),
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Picture Element Configuration.
|
* Picture Element Configuration.
|
||||||
@@ -185,57 +179,46 @@ const elementsBaseSchema = actionBaseSchema.merge(
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// https://www.home-assistant.io/lovelace/picture-elements/#state-badge
|
// https://www.home-assistant.io/lovelace/picture-elements/#state-badge
|
||||||
const stateBadgeIconSchema = elementsBaseSchema.merge(
|
const stateBadgeIconSchema = elementsBaseSchema.extend({
|
||||||
z.object({
|
|
||||||
type: z.literal('state-badge'),
|
type: z.literal('state-badge'),
|
||||||
entity: z.string(),
|
entity: z.string(),
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
|
|
||||||
// https://www.home-assistant.io/lovelace/picture-elements/#state-icon
|
// https://www.home-assistant.io/lovelace/picture-elements/#state-icon
|
||||||
const stateIconSchema = elementsBaseSchema.merge(
|
const stateIconSchema = elementsBaseSchema.extend({
|
||||||
z.object({
|
|
||||||
type: z.literal('state-icon'),
|
type: z.literal('state-icon'),
|
||||||
entity: z.string(),
|
entity: z.string(),
|
||||||
icon: z.string().optional(),
|
icon: z.string().optional(),
|
||||||
state_color: z.boolean().default(true),
|
state_color: z.boolean().default(true),
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
|
|
||||||
// https://www.home-assistant.io/lovelace/picture-elements/#state-label
|
// https://www.home-assistant.io/lovelace/picture-elements/#state-label
|
||||||
const stateLabelSchema = elementsBaseSchema.merge(
|
const stateLabelSchema = elementsBaseSchema.extend({
|
||||||
z.object({
|
|
||||||
type: z.literal('state-label'),
|
type: z.literal('state-label'),
|
||||||
entity: z.string(),
|
entity: z.string(),
|
||||||
attribute: z.string().optional(),
|
attribute: z.string().optional(),
|
||||||
prefix: z.string().optional(),
|
prefix: z.string().optional(),
|
||||||
suffix: z.string().optional(),
|
suffix: z.string().optional(),
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
|
|
||||||
// https://www.home-assistant.io/lovelace/picture-elements/#service-call-button
|
// https://www.home-assistant.io/lovelace/picture-elements/#service-call-button
|
||||||
const serviceCallButtonSchema = elementsBaseSchema.merge(
|
const serviceCallButtonSchema = elementsBaseSchema.extend({
|
||||||
z.object({
|
|
||||||
type: z.literal('service-button'),
|
type: z.literal('service-button'),
|
||||||
// Title is required for service button.
|
// Title is required for service button.
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
service: z.string(),
|
service: z.string(),
|
||||||
service_data: z.object({}).passthrough().optional(),
|
service_data: z.object({}).passthrough().optional(),
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
|
|
||||||
// https://www.home-assistant.io/lovelace/picture-elements/#icon-element
|
// https://www.home-assistant.io/lovelace/picture-elements/#icon-element
|
||||||
const iconSchema = elementsBaseSchema.merge(
|
const iconSchema = elementsBaseSchema.extend({
|
||||||
z.object({
|
|
||||||
type: z.literal('icon'),
|
type: z.literal('icon'),
|
||||||
icon: z.string(),
|
icon: z.string(),
|
||||||
entity: z.string().optional(),
|
entity: z.string().optional(),
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
|
|
||||||
// https://www.home-assistant.io/lovelace/picture-elements/#image-element
|
// https://www.home-assistant.io/lovelace/picture-elements/#image-element
|
||||||
const imageSchema = elementsBaseSchema.merge(
|
const imageSchema = elementsBaseSchema.extend({
|
||||||
z.object({
|
|
||||||
type: z.literal('image'),
|
type: z.literal('image'),
|
||||||
entity: z.string().optional(),
|
entity: z.string().optional(),
|
||||||
image: z.string().optional(),
|
image: z.string().optional(),
|
||||||
@@ -245,8 +228,7 @@ const imageSchema = elementsBaseSchema.merge(
|
|||||||
filter: z.string().optional(),
|
filter: z.string().optional(),
|
||||||
state_filter: z.object({}).passthrough().optional(),
|
state_filter: z.object({}).passthrough().optional(),
|
||||||
aspect_ratio: z.string().optional(),
|
aspect_ratio: z.string().optional(),
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
|
|
||||||
// https://www.home-assistant.io/lovelace/picture-elements/#image-element
|
// https://www.home-assistant.io/lovelace/picture-elements/#image-element
|
||||||
const conditionalSchema = z.object({
|
const conditionalSchema = z.object({
|
||||||
@@ -318,36 +300,28 @@ export type CameraConfig = z.infer<typeof cameraConfigSchema>;
|
|||||||
* Custom Element Types.
|
* Custom Element Types.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const menuIconSchema = iconSchema.merge(
|
export const menuIconSchema = iconSchema.extend({
|
||||||
z.object({
|
|
||||||
type: z.literal('custom:frigate-card-menu-icon'),
|
type: z.literal('custom:frigate-card-menu-icon'),
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
export type MenuIcon = z.infer<typeof menuIconSchema>;
|
export type MenuIcon = z.infer<typeof menuIconSchema>;
|
||||||
|
|
||||||
export const menuStateIconSchema = stateIconSchema.merge(
|
export const menuStateIconSchema = stateIconSchema.extend({
|
||||||
z.object({
|
|
||||||
type: z.literal('custom:frigate-card-menu-state-icon'),
|
type: z.literal('custom:frigate-card-menu-state-icon'),
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
export type MenuStateIcon = z.infer<typeof menuStateIconSchema>;
|
export type MenuStateIcon = z.infer<typeof menuStateIconSchema>;
|
||||||
|
|
||||||
const menuSubmenuItemSchema = elementsBaseSchema.merge(
|
const menuSubmenuItemSchema = elementsBaseSchema.extend({
|
||||||
z.object({
|
|
||||||
entity: z.string().optional(),
|
entity: z.string().optional(),
|
||||||
icon: z.string().optional(),
|
icon: z.string().optional(),
|
||||||
state_color: z.boolean().default(true),
|
state_color: z.boolean().default(true),
|
||||||
selected: z.boolean().default(false),
|
selected: z.boolean().default(false),
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
export type MenuSubmenuItem = z.infer<typeof menuSubmenuItemSchema>;
|
export type MenuSubmenuItem = z.infer<typeof menuSubmenuItemSchema>;
|
||||||
|
|
||||||
export const menuSubmenuSchema = iconSchema.merge(
|
export const menuSubmenuSchema = iconSchema.extend({
|
||||||
z.object({
|
|
||||||
type: z.literal('custom:frigate-card-menu-submenu'),
|
type: z.literal('custom:frigate-card-menu-submenu'),
|
||||||
items: menuSubmenuItemSchema.array(),
|
items: menuSubmenuItemSchema.array(),
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
export type MenuSubmenu = z.infer<typeof menuSubmenuSchema>;
|
export type MenuSubmenu = z.infer<typeof menuSubmenuSchema>;
|
||||||
|
|
||||||
const frigateCardConditionSchema = z.object({
|
const frigateCardConditionSchema = z.object({
|
||||||
@@ -492,45 +466,15 @@ export type JSMPEGConfig = z.infer<typeof jsmpegConfigSchema>;
|
|||||||
|
|
||||||
const liveNextPreviousControlConfigSchema = nextPreviousControlConfigSchema.extend({
|
const liveNextPreviousControlConfigSchema = nextPreviousControlConfigSchema.extend({
|
||||||
// Live cannot show thumbnails, remove that option.
|
// Live cannot show thumbnails, remove that option.
|
||||||
style: z.enum(['none', 'chevrons', 'icons']),
|
style: z
|
||||||
});
|
.enum(['none', 'chevrons', 'icons'])
|
||||||
|
.default(liveConfigDefault.controls.next_previous.style),
|
||||||
const liveOverridableConfigSchema = z
|
size: nextPreviousControlConfigSchema.shape.size.default(
|
||||||
.object({
|
|
||||||
webrtc: webrtcConfigSchema,
|
|
||||||
jsmpeg: jsmpegConfigSchema,
|
|
||||||
controls: z
|
|
||||||
.object({
|
|
||||||
next_previous: liveNextPreviousControlConfigSchema.optional(),
|
|
||||||
thumbnails: thumbnailsControlSchema
|
|
||||||
.merge(
|
|
||||||
z.object({
|
|
||||||
media: z.enum(['clips', 'snapshots']),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
.merge(actionsSchema);
|
|
||||||
|
|
||||||
const liveConfigSchema = liveOverridableConfigSchema
|
|
||||||
.extend({
|
|
||||||
// Replace attributes from the overridable schema with those with defaults.
|
|
||||||
controls: z
|
|
||||||
.object({
|
|
||||||
next_previous: liveNextPreviousControlConfigSchema
|
|
||||||
.extend({
|
|
||||||
size: liveNextPreviousControlConfigSchema.shape.size.default(
|
|
||||||
liveConfigDefault.controls.next_previous.size,
|
liveConfigDefault.controls.next_previous.size,
|
||||||
),
|
),
|
||||||
style: liveNextPreviousControlConfigSchema.shape.style.default(
|
});
|
||||||
liveConfigDefault.controls.next_previous.style,
|
|
||||||
),
|
const liveThumbnailControlConfigSchema = thumbnailsControlSchema.extend({
|
||||||
})
|
|
||||||
.default(liveConfigDefault.controls.next_previous),
|
|
||||||
thumbnails: thumbnailsControlSchema
|
|
||||||
.extend({
|
|
||||||
mode: thumbnailsControlSchema.shape.mode.default(
|
mode: thumbnailsControlSchema.shape.mode.default(
|
||||||
liveConfigDefault.controls.thumbnails.mode,
|
liveConfigDefault.controls.thumbnails.mode,
|
||||||
),
|
),
|
||||||
@@ -540,11 +484,27 @@ const liveConfigSchema = liveOverridableConfigSchema
|
|||||||
media: z
|
media: z
|
||||||
.enum(['clips', 'snapshots'])
|
.enum(['clips', 'snapshots'])
|
||||||
.default(liveConfigDefault.controls.thumbnails.media),
|
.default(liveConfigDefault.controls.thumbnails.media),
|
||||||
})
|
});
|
||||||
.default(liveConfigDefault.controls.thumbnails),
|
|
||||||
|
const liveOverridableConfigSchema = z
|
||||||
|
.object({
|
||||||
|
webrtc: webrtcConfigSchema,
|
||||||
|
jsmpeg: jsmpegConfigSchema,
|
||||||
|
controls: z
|
||||||
|
.object({
|
||||||
|
next_previous: liveNextPreviousControlConfigSchema.default(
|
||||||
|
liveConfigDefault.controls.next_previous,
|
||||||
|
),
|
||||||
|
thumbnails: liveThumbnailControlConfigSchema.default(
|
||||||
|
liveConfigDefault.controls.thumbnails,
|
||||||
|
),
|
||||||
})
|
})
|
||||||
.default(liveConfigDefault.controls),
|
.default(liveConfigDefault.controls),
|
||||||
|
})
|
||||||
|
.merge(actionsSchema);
|
||||||
|
|
||||||
|
const liveConfigSchema = liveOverridableConfigSchema
|
||||||
|
.extend({
|
||||||
// Non-overrideable parameters.
|
// Non-overrideable parameters.
|
||||||
preload: z.boolean().default(liveConfigDefault.preload),
|
preload: z.boolean().default(liveConfigDefault.preload),
|
||||||
lazy_load: z.boolean().default(liveConfigDefault.lazy_load),
|
lazy_load: z.boolean().default(liveConfigDefault.lazy_load),
|
||||||
@@ -572,27 +532,9 @@ const menuConfigDefault = {
|
|||||||
button_size: '40px',
|
button_size: '40px',
|
||||||
};
|
};
|
||||||
|
|
||||||
const menuOverridableConfigSchema = z.object({
|
const menuConfigSchema = z
|
||||||
mode: z.enum(FRIGATE_MENU_MODES).optional(),
|
|
||||||
buttons: z
|
|
||||||
.object({
|
.object({
|
||||||
frigate: z.boolean().optional(),
|
mode: z.enum(FRIGATE_MENU_MODES).default(menuConfigDefault.mode),
|
||||||
cameras: z.boolean().optional(),
|
|
||||||
live: z.boolean().optional(),
|
|
||||||
clips: z.boolean().optional(),
|
|
||||||
snapshots: z.boolean().optional(),
|
|
||||||
image: z.boolean().optional(),
|
|
||||||
download: z.boolean().optional(),
|
|
||||||
frigate_ui: z.boolean().optional(),
|
|
||||||
fullscreen: z.boolean().optional(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
button_size: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const menuConfigSchema = menuOverridableConfigSchema
|
|
||||||
.extend({
|
|
||||||
mode: menuOverridableConfigSchema.shape.mode.default(menuConfigDefault.mode),
|
|
||||||
buttons: z
|
buttons: z
|
||||||
.object({
|
.object({
|
||||||
frigate: z.boolean().default(menuConfigDefault.buttons.frigate),
|
frigate: z.boolean().default(menuConfigDefault.buttons.frigate),
|
||||||
@@ -606,9 +548,7 @@ const menuConfigSchema = menuOverridableConfigSchema
|
|||||||
fullscreen: z.boolean().default(menuConfigDefault.buttons.fullscreen),
|
fullscreen: z.boolean().default(menuConfigDefault.buttons.fullscreen),
|
||||||
})
|
})
|
||||||
.default(menuConfigDefault.buttons),
|
.default(menuConfigDefault.buttons),
|
||||||
button_size: menuOverridableConfigSchema.shape.button_size.default(
|
button_size: z.string().default(menuConfigDefault.button_size),
|
||||||
menuConfigDefault.button_size,
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
.default(menuConfigDefault);
|
.default(menuConfigDefault);
|
||||||
export type MenuConfig = z.infer<typeof menuConfigSchema>;
|
export type MenuConfig = z.infer<typeof menuConfigSchema>;
|
||||||
@@ -631,14 +571,12 @@ const viewerConfigDefault = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const viewerNextPreviousControlConfigSchema = nextPreviousControlConfigSchema.merge(
|
const viewerNextPreviousControlConfigSchema = nextPreviousControlConfigSchema.extend({
|
||||||
z.object({
|
|
||||||
style: z
|
style: z
|
||||||
.enum(['none', 'thumbnails', 'chevrons'])
|
.enum(['none', 'thumbnails', 'chevrons'])
|
||||||
.default(viewerConfigDefault.controls.next_previous.style),
|
.default(viewerConfigDefault.controls.next_previous.style),
|
||||||
size: z.string().default(viewerConfigDefault.controls.next_previous.size),
|
size: z.string().default(viewerConfigDefault.controls.next_previous.size),
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
export type ViewerNextPreviousControlConfig = z.infer<
|
export type ViewerNextPreviousControlConfig = z.infer<
|
||||||
typeof viewerNextPreviousControlConfigSchema
|
typeof viewerNextPreviousControlConfigSchema
|
||||||
>;
|
>;
|
||||||
@@ -705,9 +643,12 @@ const dimensionsConfigSchema = z
|
|||||||
/**
|
/**
|
||||||
* Configuration overrides
|
* Configuration overrides
|
||||||
*/
|
*/
|
||||||
const overrideConfigurationSchema = z.object({
|
// Strip all defaults from the override schemas, to ensure values are only what
|
||||||
live: liveOverridableConfigSchema.optional(),
|
// the user has specified.
|
||||||
menu: menuOverridableConfigSchema.optional(),
|
const overrideConfigurationSchema =
|
||||||
|
z.object({
|
||||||
|
live: deepRemoveDefaults(liveOverridableConfigSchema).optional(),
|
||||||
|
menu: deepRemoveDefaults(menuConfigSchema).optional(),
|
||||||
});
|
});
|
||||||
export type OverrideConfigurationKey = keyof z.infer<typeof overrideConfigurationSchema>;
|
export type OverrideConfigurationKey = keyof z.infer<typeof overrideConfigurationSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively remove defaults from a zod schema.
|
||||||
|
*
|
||||||
|
* See: https://github.com/colinhacks/zod/discussions/845#discussioncomment-1936943
|
||||||
|
*
|
||||||
|
* @param schema The Zod schema.
|
||||||
|
* @returns A new Zod schema.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export function deepRemoveDefaults<T extends z.ZodTypeAny>(schema: T): any {
|
||||||
|
if (schema instanceof z.ZodDefault) {
|
||||||
|
return deepRemoveDefaults(schema.removeDefault());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema instanceof z.ZodObject) {
|
||||||
|
const newShape = {};
|
||||||
|
|
||||||
|
for (const key in schema.shape) {
|
||||||
|
const fieldSchema = schema.shape[key];
|
||||||
|
newShape[key] = z.ZodOptional.create(deepRemoveDefaults(fieldSchema));
|
||||||
|
}
|
||||||
|
return new z.ZodObject({
|
||||||
|
...schema._def,
|
||||||
|
shape: () => newShape,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema instanceof z.ZodArray) {
|
||||||
|
return z.ZodArray.create(deepRemoveDefaults(schema.element));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema instanceof z.ZodOptional) {
|
||||||
|
return z.ZodOptional.create(deepRemoveDefaults(schema.unwrap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema instanceof z.ZodNullable) {
|
||||||
|
return z.ZodNullable.create(deepRemoveDefaults(schema.unwrap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema instanceof z.ZodTuple) {
|
||||||
|
return z.ZodTuple.create(
|
||||||
|
schema.items.map((item: z.ZodTypeAny) => deepRemoveDefaults(item)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return schema;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user