move name + views slot from editor layout to entity editor + use entity editor in node editor
This commit is contained in:
@@ -17,14 +17,32 @@ class UmbEditorEntity extends UmbContextConsumerMixin(LitElement) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#header {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#footer {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
#actions {
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
uui-input {
|
||||
width: 100%;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
uui-tab-group {
|
||||
--uui-tab-divider: var(--uui-color-border);
|
||||
border-left: 1px solid var(--uui-color-border);
|
||||
border-right: 1px solid var(--uui-color-border);
|
||||
flex-wrap: nowrap;
|
||||
height: 60px;
|
||||
}
|
||||
@@ -126,27 +144,31 @@ class UmbEditorEntity extends UmbContextConsumerMixin(LitElement) {
|
||||
render() {
|
||||
return html`
|
||||
<umb-editor-layout>
|
||||
<uui-input slot="name" .value="${this.name}"></uui-input>
|
||||
|
||||
<uui-tab-group slot="views">
|
||||
${this._editorViews.map(
|
||||
(view: UmbExtensionManifestEditorView) => html`
|
||||
<uui-tab
|
||||
.label="${view.name}"
|
||||
href="${this._routerFolder}/view/${view.meta.pathname}"
|
||||
?active="${this._currentView.includes(view.meta.pathname)}">
|
||||
<uui-icon slot="icon" name="${view.meta.icon}"></uui-icon>
|
||||
${view.name}
|
||||
</uui-tab>
|
||||
`
|
||||
)}
|
||||
</uui-tab-group>
|
||||
<div id="header" slot="header">
|
||||
<slot id="icon" name="icon"></slot>
|
||||
<uui-input .value="${this.name}"></uui-input>
|
||||
<uui-tab-group slot="views">
|
||||
${this._editorViews.map(
|
||||
(view: UmbExtensionManifestEditorView) => html`
|
||||
<uui-tab
|
||||
.label="${view.name}"
|
||||
href="${this._routerFolder}/view/${view.meta.pathname}"
|
||||
?active="${this._currentView.includes(view.meta.pathname)}">
|
||||
<uui-icon slot="icon" name="${view.meta.icon}"></uui-icon>
|
||||
${view.name}
|
||||
</uui-tab>
|
||||
`
|
||||
)}
|
||||
</uui-tab-group>
|
||||
</div>
|
||||
|
||||
<router-slot .routes="${this._routes}"></router-slot>
|
||||
|
||||
<slot></slot>
|
||||
|
||||
<slot name="actions" slot="actions"></slot>
|
||||
<div id="footer" slot="footer">
|
||||
<slot name="footer"></slot>
|
||||
<slot id="actions" name="actions"></slot>
|
||||
</div>
|
||||
</umb-editor-layout>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { UUITextStyles } from '@umbraco-ui/uui-css/lib';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
|
||||
@customElement('umb-editor-layout')
|
||||
class UmbEditorLayout extends LitElement {
|
||||
@@ -24,11 +24,9 @@ class UmbEditorLayout extends LitElement {
|
||||
#header {
|
||||
background-color: var(--uui-color-surface);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex: none;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--uui-color-border);
|
||||
box-sizing: border-box;
|
||||
padding: 0 var(--uui-size-6);
|
||||
}
|
||||
|
||||
#main {
|
||||
@@ -40,14 +38,9 @@ class UmbEditorLayout extends LitElement {
|
||||
}
|
||||
|
||||
#footer {
|
||||
display: flex;
|
||||
flex: none;
|
||||
justify-content: end;
|
||||
align-items: center;
|
||||
height: 70px;
|
||||
width: 100%;
|
||||
gap: 16px;
|
||||
padding-right: 24px;
|
||||
padding: 0 var(--uui-size-6);
|
||||
border-top: 1px solid var(--uui-color-border);
|
||||
background-color: var(--uui-color-surface);
|
||||
box-sizing: border-box;
|
||||
@@ -59,15 +52,14 @@ class UmbEditorLayout extends LitElement {
|
||||
return html`
|
||||
<div id="editor-frame">
|
||||
<div id="header">
|
||||
<slot name="name"></slot>
|
||||
<slot name="views"></slot>
|
||||
<slot name="header"></slot>
|
||||
</div>
|
||||
<uui-scroll-container id="main">
|
||||
<slot></slot>
|
||||
</uui-scroll-container>
|
||||
<div id="footer">
|
||||
<!-- only show footer if slot has elements -->
|
||||
<slot name="actions"></slot>
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { UUITextStyles } from '@umbraco-ui/uui-css/lib';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { DocumentNode, NodeProperty } from '../../mocks/data/content.data';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import { NodeEntity, NodeProperty } from '../../mocks/data/content.data';
|
||||
import { UmbContextConsumerMixin } from '../../core/context';
|
||||
import { UmbNodeContext } from '../editors/node/node.context';
|
||||
import { Subscription, distinctUntilChanged } from 'rxjs';
|
||||
|
||||
@customElement('umb-editor-view-node-edit')
|
||||
export class UmbEditorViewNodeEdit extends LitElement {
|
||||
export class UmbEditorViewNodeEdit extends UmbContextConsumerMixin(LitElement) {
|
||||
static styles = [
|
||||
UUITextStyles,
|
||||
css`
|
||||
@@ -16,17 +19,40 @@ export class UmbEditorViewNodeEdit extends LitElement {
|
||||
`,
|
||||
];
|
||||
|
||||
@property({ type: Object })
|
||||
node?: DocumentNode;
|
||||
@state()
|
||||
_node?: NodeEntity;
|
||||
|
||||
private _nodeContext?: UmbNodeContext;
|
||||
private _nodeContextSubscription?: Subscription;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.consumeContext('umbNodeContext', (nodeContext) => {
|
||||
this._nodeContext = nodeContext;
|
||||
this._useNode();
|
||||
});
|
||||
}
|
||||
|
||||
private _useNode() {
|
||||
this._nodeContextSubscription = this._nodeContext?.data.pipe(distinctUntilChanged()).subscribe((data) => {
|
||||
this._node = data;
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._nodeContextSubscription?.unsubscribe();
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<uui-box>
|
||||
${this.node?.properties.map(
|
||||
${this._node?.properties.map(
|
||||
(property: NodeProperty) => html`
|
||||
<umb-node-property
|
||||
.property=${property}
|
||||
.value=${this.node?.data.find((data) => data.alias === property.alias)?.value}></umb-node-property>
|
||||
.value=${this._node?.data.find((data) => data.alias === property.alias)?.value}></umb-node-property>
|
||||
<hr />
|
||||
`
|
||||
)}
|
||||
|
||||
@@ -17,7 +17,7 @@ export class UmbDataTypeContext {
|
||||
}
|
||||
|
||||
// TODO: figure out how we want to update data
|
||||
public update(data: any) {
|
||||
public update(data: Partial<DataTypeEntity>) {
|
||||
this._data.next({ ...this._data.getValue(), ...data });
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export class UmbDocumentTypeContext {
|
||||
}
|
||||
|
||||
// TODO: figure out how we want to update data
|
||||
public update(data: any) {
|
||||
public update(data: Partial<DocumentTypeEntity>) {
|
||||
this._data.next({ ...this._data.getValue(), ...data });
|
||||
}
|
||||
|
||||
|
||||
@@ -111,6 +111,10 @@ export class UmbEditorDocumentTypeElement extends UmbContextProviderMixin(UmbCon
|
||||
alias="Umb.Editor.DocumentType"
|
||||
name="${ifDefined(this._documentType?.name)}"
|
||||
@input="${this._handleInput}">
|
||||
<div slot="icon">Icon</div>
|
||||
|
||||
<div slot="footer">Keyboard Shortcuts</div>
|
||||
|
||||
<!-- TODO: these could be extensions points too -->
|
||||
<div slot="actions">
|
||||
<uui-button
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { UUITextStyles } from '@umbraco-ui/uui-css/lib';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { UmbContextConsumerMixin } from '../../core/context';
|
||||
import { UmbContextConsumerMixin, UmbContextProviderMixin } from '../../core/context';
|
||||
import { UmbNodeStore } from '../../core/stores/node.store';
|
||||
import { map, Subscription } from 'rxjs';
|
||||
import { DocumentNode } from '../../mocks/data/content.data';
|
||||
import { distinctUntilChanged, Subscription } from 'rxjs';
|
||||
import { NodeEntity } from '../../mocks/data/content.data';
|
||||
import { UmbNotificationService } from '../../core/services/notification.service';
|
||||
import { UmbExtensionManifestEditorView, UmbExtensionRegistry } from '../../core/extension';
|
||||
import { IRoute, IRoutingInfo, RouterSlot } from 'router-slot';
|
||||
import { UmbNodeContext } from './node/node.context';
|
||||
|
||||
import '../components/editor-entity.element';
|
||||
|
||||
// Lazy load
|
||||
// TODO: Make this dynamic, use load-extensions method to loop over extensions for this node.
|
||||
@@ -15,7 +16,7 @@ import '../editor-views/editor-view-node-edit.element';
|
||||
import '../editor-views/editor-view-node-info.element';
|
||||
|
||||
@customElement('umb-editor-node')
|
||||
export class UmbEditorNodeElement extends UmbContextConsumerMixin(LitElement) {
|
||||
export class UmbEditorNodeElement extends UmbContextProviderMixin(UmbContextConsumerMixin(LitElement)) {
|
||||
static styles = [
|
||||
UUITextStyles,
|
||||
css`
|
||||
@@ -51,27 +52,16 @@ export class UmbEditorNodeElement extends UmbContextConsumerMixin(LitElement) {
|
||||
id!: string;
|
||||
|
||||
@state()
|
||||
_node?: DocumentNode;
|
||||
|
||||
@state()
|
||||
private _routes: Array<IRoute> = [];
|
||||
|
||||
@state()
|
||||
private _editorViews: Array<UmbExtensionManifestEditorView> = [];
|
||||
|
||||
@state()
|
||||
private _currentView = '';
|
||||
_node?: NodeEntity;
|
||||
|
||||
private _nodeStore?: UmbNodeStore;
|
||||
private _nodeSubscription?: Subscription;
|
||||
private _nodeStoreSubscription?: Subscription;
|
||||
|
||||
private _nodeContext = new UmbNodeContext();
|
||||
private _nodeContextSubscription?: Subscription;
|
||||
|
||||
private _notificationService?: UmbNotificationService;
|
||||
|
||||
private _extensionRegistry?: UmbExtensionRegistry;
|
||||
private _editorViewsSubscription?: Subscription;
|
||||
|
||||
private _routerFolder = '';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
@@ -84,18 +74,9 @@ export class UmbEditorNodeElement extends UmbContextConsumerMixin(LitElement) {
|
||||
this._notificationService = service;
|
||||
});
|
||||
|
||||
this.consumeContext('umbExtensionRegistry', (extensionRegistry: UmbExtensionRegistry) => {
|
||||
this._extensionRegistry = extensionRegistry;
|
||||
this._useEditorViews();
|
||||
});
|
||||
|
||||
this.addEventListener('property-value-change', this._onPropertyValueChange);
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
/* TODO: find a way to construct absolute urls */
|
||||
this._routerFolder = window.location.pathname.split('/view')[0];
|
||||
this.provideContext('umbNodeContext', this._nodeContext);
|
||||
}
|
||||
|
||||
private _onPropertyValueChange = (e: Event) => {
|
||||
@@ -119,34 +100,19 @@ export class UmbEditorNodeElement extends UmbContextConsumerMixin(LitElement) {
|
||||
}
|
||||
|
||||
private _useNode() {
|
||||
this._nodeSubscription?.unsubscribe();
|
||||
this._nodeStoreSubscription?.unsubscribe();
|
||||
|
||||
this._nodeSubscription = this._nodeStore?.getById(parseInt(this.id)).subscribe((node) => {
|
||||
this._nodeStoreSubscription = this._nodeStore?.getById(parseInt(this.id)).subscribe((node) => {
|
||||
if (!node) return; // TODO: Handle nicely if there is no node.
|
||||
this._node = node;
|
||||
// TODO: merge observables
|
||||
this._createRoutes();
|
||||
});
|
||||
}
|
||||
|
||||
private _useEditorViews() {
|
||||
this._editorViewsSubscription?.unsubscribe();
|
||||
this._nodeContextSubscription?.unsubscribe();
|
||||
|
||||
// TODO: how do we know which editor to show the views for?
|
||||
this._editorViewsSubscription = this._extensionRegistry
|
||||
?.extensionsOfType('editorView')
|
||||
.pipe(
|
||||
map((extensions) =>
|
||||
extensions
|
||||
.filter((extension) => extension.meta.editors.includes('Umb.Editor.Node'))
|
||||
.sort((a, b) => b.meta.weight - a.meta.weight)
|
||||
)
|
||||
)
|
||||
.subscribe((editorViews) => {
|
||||
this._editorViews = editorViews;
|
||||
// TODO: merge observables
|
||||
this._createRoutes();
|
||||
this._nodeContext?.update(node);
|
||||
|
||||
this._nodeContextSubscription = this._nodeContext.data.pipe(distinctUntilChanged()).subscribe((data) => {
|
||||
this._node = data;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private _onSaveAndPublish() {
|
||||
@@ -166,69 +132,17 @@ export class UmbEditorNodeElement extends UmbContextConsumerMixin(LitElement) {
|
||||
this._onSave();
|
||||
}
|
||||
|
||||
// TODO: simplify setting up editors with views. This code has to be duplicated in each editor.
|
||||
private async _createRoutes() {
|
||||
if (this._node && this._editorViews.length > 0) {
|
||||
this._routes = [];
|
||||
|
||||
this._routes = this._editorViews.map((view) => {
|
||||
return {
|
||||
path: `view/${view.meta.pathname}`,
|
||||
component: () => document.createElement(view.elementName || 'div'),
|
||||
setup: (element: HTMLElement, info: IRoutingInfo) => {
|
||||
// TODO: make interface for EditorViews
|
||||
const editorView = element as any;
|
||||
// TODO: how do we pass data to views? Maybe we should use a context?
|
||||
editorView.node = this._node;
|
||||
this._currentView = info.match.route.path;
|
||||
},
|
||||
};
|
||||
});
|
||||
this._routes.push({
|
||||
path: '**',
|
||||
redirectTo: `view/${this._editorViews?.[0].meta.pathname}`,
|
||||
});
|
||||
|
||||
this.requestUpdate();
|
||||
await this.updateComplete;
|
||||
|
||||
this._forceRouteRender();
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Fgure out why this has been necessary for this case. Come up with another case
|
||||
private _forceRouteRender() {
|
||||
const routerSlotEl = this.shadowRoot?.querySelector('router-slot') as RouterSlot;
|
||||
if (routerSlotEl) {
|
||||
routerSlotEl.render();
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._nodeSubscription?.unsubscribe();
|
||||
this._nodeStoreSubscription?.unsubscribe();
|
||||
this._nodeContextSubscription?.unsubscribe();
|
||||
delete this._node;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<umb-editor-layout>
|
||||
<uui-input slot="name" .value="${this._node?.name}"></uui-input>
|
||||
<uui-tab-group slot="views">
|
||||
${this._editorViews.map(
|
||||
(view: UmbExtensionManifestEditorView) => html`
|
||||
<uui-tab
|
||||
.label="${view.name}"
|
||||
href="${this._routerFolder}/view/${view.meta.pathname}"
|
||||
?active="${this._currentView.includes(view.meta.pathname)}">
|
||||
<uui-icon slot="icon" name="${view.meta.icon}"></uui-icon>
|
||||
${view.name}
|
||||
</uui-tab>
|
||||
`
|
||||
)}
|
||||
</uui-tab-group>
|
||||
|
||||
<router-slot .routes="${this._routes}"></router-slot>
|
||||
<umb-editor-entity alias="Umb.Editor.Node">
|
||||
<div slot="footer">Breadcrumbs</div>
|
||||
|
||||
<div slot="actions">
|
||||
<uui-button @click=${this._onSaveAndPreview} label="Save and preview"></uui-button>
|
||||
@@ -239,7 +153,7 @@ export class UmbEditorNodeElement extends UmbContextConsumerMixin(LitElement) {
|
||||
color="positive"
|
||||
label="Save and publish"></uui-button>
|
||||
</div>
|
||||
</umb-editor-layout>
|
||||
</umb-editor-entity>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
import { NodeEntity } from '../../../mocks/data/content.data';
|
||||
|
||||
export class UmbNodeContext {
|
||||
// TODO: figure out how fine grained we want to make our observables.
|
||||
private _data: BehaviorSubject<NodeEntity> = new BehaviorSubject({
|
||||
id: -1,
|
||||
key: '',
|
||||
name: '',
|
||||
alias: '',
|
||||
icon: '',
|
||||
properties: [
|
||||
{
|
||||
alias: '',
|
||||
label: '',
|
||||
description: '',
|
||||
dataTypeKey: '',
|
||||
},
|
||||
],
|
||||
data: [
|
||||
{
|
||||
alias: '',
|
||||
value: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
public readonly data: Observable<NodeEntity> = this._data.asObservable();
|
||||
|
||||
constructor(node?: NodeEntity) {
|
||||
if (!node) return;
|
||||
this._data.next(node);
|
||||
}
|
||||
|
||||
// TODO: figure out how we want to update data
|
||||
public update(data: Partial<NodeEntity>) {
|
||||
this._data.next({ ...this._data.getValue(), ...data });
|
||||
}
|
||||
|
||||
public getData() {
|
||||
return this._data.getValue();
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { BehaviorSubject, map, Observable } from 'rxjs';
|
||||
import { DocumentNode } from '../../mocks/data/content.data';
|
||||
import { NodeEntity } from '../../mocks/data/content.data';
|
||||
|
||||
export class UmbNodeStore {
|
||||
private _nodes: BehaviorSubject<Array<DocumentNode>> = new BehaviorSubject(<Array<DocumentNode>>[]);
|
||||
public readonly nodes: Observable<Array<DocumentNode>> = this._nodes.asObservable();
|
||||
private _nodes: BehaviorSubject<Array<NodeEntity>> = new BehaviorSubject(<Array<NodeEntity>>[]);
|
||||
public readonly nodes: Observable<Array<NodeEntity>> = this._nodes.asObservable();
|
||||
|
||||
getById(id: number): Observable<DocumentNode | null> {
|
||||
getById(id: number): Observable<NodeEntity | null> {
|
||||
// fetch from server and update store
|
||||
fetch(`/umbraco/backoffice/content/${id}`)
|
||||
.then((res) => res.json())
|
||||
@@ -13,14 +13,12 @@ export class UmbNodeStore {
|
||||
this._updateStore(data);
|
||||
});
|
||||
|
||||
return this.nodes.pipe(
|
||||
map((nodes: Array<DocumentNode>) => nodes.find((node: DocumentNode) => node.id === id) || null)
|
||||
);
|
||||
return this.nodes.pipe(map((nodes: Array<NodeEntity>) => nodes.find((node: NodeEntity) => node.id === id) || null));
|
||||
}
|
||||
|
||||
// TODO: Use Node type, to not be specific about Document.
|
||||
// TODO: make sure UI somehow can follow the status of this action.
|
||||
save(data: DocumentNode[]): Promise<void> {
|
||||
save(data: NodeEntity[]): Promise<void> {
|
||||
// fetch from server and update store
|
||||
// TODO: use Fetcher API.
|
||||
let body: string;
|
||||
@@ -48,7 +46,7 @@ export class UmbNodeStore {
|
||||
|
||||
private _updateStore(fetchedNodes: Array<any>) {
|
||||
const storedNodes = this._nodes.getValue();
|
||||
const updated: DocumentNode[] = [...storedNodes];
|
||||
const updated: NodeEntity[] = [...storedNodes];
|
||||
|
||||
fetchedNodes.forEach((fetchedNode) => {
|
||||
const index = storedNodes.map((storedNode) => storedNode.id).indexOf(fetchedNode.id);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { UmbData } from './data';
|
||||
|
||||
export interface DocumentNode {
|
||||
export interface NodeEntity {
|
||||
id: number;
|
||||
key: string;
|
||||
name: string;
|
||||
@@ -20,7 +20,7 @@ export interface NodeProperty {
|
||||
|
||||
export interface NodePropertyData {
|
||||
alias: string;
|
||||
value: unknown;
|
||||
value: any;
|
||||
}
|
||||
|
||||
/* TODO:
|
||||
@@ -30,7 +30,7 @@ We would like the tree items to stay up to date, without requesting the server a
|
||||
|
||||
If we split entityData into its own object, then that could go in the entityStore and be merged with the nodeStore (we would have a subscription on both).
|
||||
*/
|
||||
export const data: Array<DocumentNode> = [
|
||||
export const data: Array<NodeEntity> = [
|
||||
{
|
||||
id: 1,
|
||||
key: '74e4008a-ea4f-4793-b924-15e02fd380d1',
|
||||
@@ -135,7 +135,7 @@ export const data: Array<DocumentNode> = [
|
||||
];
|
||||
|
||||
// Temp mocked database
|
||||
class UmbContentData extends UmbData<DocumentNode> {
|
||||
class UmbContentData extends UmbData<NodeEntity> {
|
||||
constructor() {
|
||||
super(data);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { rest } from 'msw';
|
||||
|
||||
import { DocumentNode, umbContentData } from '../data/content.data';
|
||||
import { NodeEntity, umbContentData } from '../data/content.data';
|
||||
|
||||
// TODO: add schema
|
||||
export const handlers = [
|
||||
@@ -14,7 +14,7 @@ export const handlers = [
|
||||
return res(ctx.status(200), ctx.json([document]));
|
||||
}),
|
||||
|
||||
rest.post<DocumentNode[]>('/umbraco/backoffice/content/save', (req, res, ctx) => {
|
||||
rest.post<NodeEntity[]>('/umbraco/backoffice/content/save', (req, res, ctx) => {
|
||||
const data = req.body;
|
||||
if (!data) return;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user