wip
This commit is contained in:
151
README.md
151
README.md
@@ -1,23 +1,23 @@
|
|||||||
# tp-tree-nav
|
# tp-tree-nav
|
||||||
|
|
||||||
`tp-tree-nav` is a low-level, virtualized tree renderer for Lit. It is intentionally “dumb”: it only renders what it is given and emits events. Parents own all state (expanded/selected/custom), perform mutations, and pass updated data back in. This makes it a flexible foundation for building higher-level trees (e.g., VS Code–style explorer, Git ignore/selected indicators, custom icon trees).
|
`tp-tree-nav` is a low-level, virtualized tree renderer for Lit. It renders what it is given and emits events; parents own state. You can wrap it directly or extend it to add opinionated behavior (selection, expansion, inline actions, empty states, etc.).
|
||||||
|
|
||||||
## Key ideas
|
## Key ideas
|
||||||
- **Multiple roots**: pass a `roots` array; each node may have `children`.
|
- **Multiple roots**: pass a `roots` array; each node may have `children`.
|
||||||
- **Virtualized list**: uses `@lit-labs/virtualizer` to render a flat, indented list for large trees.
|
- **Virtualized list**: uses `@lit-labs/virtualizer` to render a flat, indented list for large trees.
|
||||||
- **Events only**: emits `node-click`, `node-context`, and `node-action` (for chevron toggle and context menu actions). Every event includes `originalEvent` so parents can cancel or coordinate.
|
- **Events only**: emits `node-click`, `node-context`, and `node-action` (for chevron toggle, inline actions, and context menu actions). Each event carries `originalEvent` so parents can cancel or coordinate.
|
||||||
- **Opt-in actions**: provide `defaultActions` (array of action objects), and per-node `actions`; node actions override defaults on the same `action` key. Use `beforeContextMenu(node, actions)` to modify or block the menu.
|
- **Opt-in actions**: provide `defaultActions` and per-node `actions`; node actions override defaults on the same `action` key. Use `beforeContextMenu(node, actions)` to modify or block the menu.
|
||||||
- **Custom render**: `renderNode(node, meta)` lets you override row rendering (e.g., icons per node type, state-based styling). Common states like `expanded`/`collapsed` are parent-managed via `node.states`.
|
- **Custom render**: `renderNode(node, meta)` lets you override row rendering (icons per node type, state-based styling). Common states like `expanded`/`selected` are parent-managed via `node.states` or managed mode.
|
||||||
- **Helper methods (pure)**: `getNodesWithState`, `clearState`, `applyStateIf` return data; they don’t mutate internal state—parents update `roots`.
|
- **Helpers (pure)**: `getNodesWithState`, `clearState`, `applyStateIf` return new data; parents reassign `roots`.
|
||||||
- **Default icons**: `chevron`, `folder`, `file` are available via `tp-icon`; pass string keys or custom icons.
|
- **Default icons**: `chevron`, `folder`, `file` are available via `tp-icon`; pass string keys or custom icons.
|
||||||
|
|
||||||
## Usage example
|
## Quick example (wrap directly)
|
||||||
```js
|
```js
|
||||||
import './tp-tree-nav.js';
|
import './tp-tree-nav.js';
|
||||||
|
|
||||||
const tree = document.querySelector('tp-tree-nav');
|
const tree = document.querySelector('tp-tree-nav');
|
||||||
|
|
||||||
const roots = [
|
tree.roots = [
|
||||||
{
|
{
|
||||||
label: 'Project A',
|
label: 'Project A',
|
||||||
slug: 'project-a',
|
slug: 'project-a',
|
||||||
@@ -38,52 +38,111 @@ const roots = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
tree.roots = roots;
|
|
||||||
tree.defaultActions = [
|
tree.defaultActions = [
|
||||||
{ label: 'Rename', action: 'rename', icon: 'pencil' },
|
{ label: 'Rename', action: 'rename', icon: 'pencil' },
|
||||||
{ label: 'Delete', action: 'delete', icon: 'delete' },
|
{ label: 'Delete', action: 'delete', icon: 'delete' },
|
||||||
];
|
];
|
||||||
|
|
||||||
tree.beforeContextMenu = (node, actions) => {
|
tree.beforeContextMenu = (node, actions) =>
|
||||||
// Example: hide delete on roots
|
node.slug === 'project-a' ? actions.filter((a) => a.action !== 'delete') : actions;
|
||||||
if (node.slug === 'project-a') {
|
|
||||||
return actions.filter((a) => a.action !== 'delete');
|
|
||||||
}
|
|
||||||
return actions;
|
|
||||||
};
|
|
||||||
|
|
||||||
tree.renderNode = (node, { depth, states, path, hasChildren }) => {
|
// simple toggle handler
|
||||||
const selected = states.includes('selected');
|
const targetPath = (path) => path.join('/');
|
||||||
return html`
|
|
||||||
<div
|
|
||||||
class="row ${selected ? 'selected' : ''}"
|
|
||||||
style="--depth: ${depth}"
|
|
||||||
@click=${(e) => tree.dispatchEvent(new CustomEvent('node-click', { detail: { node, path, originalEvent: e }, bubbles: true, composed: true, cancelable: true }))}
|
|
||||||
>
|
|
||||||
<div class="indent"></div>
|
|
||||||
${hasChildren
|
|
||||||
? html`<tp-icon class="icon" .icon=${TpTreeNav.chevron}></tp-icon>`
|
|
||||||
: html`<span class="icon" aria-hidden="true"></span>`}
|
|
||||||
<tp-icon class="icon" .icon=${tree._resolveIcon(node.icon)}></tp-icon>
|
|
||||||
<div class="label">${node.label}</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Listen to actions (toggle, context menu items, etc.)
|
|
||||||
tree.addEventListener('node-action', (e) => {
|
tree.addEventListener('node-action', (e) => {
|
||||||
const { action, node, path } = e.detail;
|
if (e.detail.action !== 'toggle') return;
|
||||||
if (action === 'toggle') {
|
const target = targetPath(e.detail.path);
|
||||||
// Parent mutates data: flip expanded state and reassign roots
|
tree.roots = tree.applyStateIf('expanded', (_n, p) => targetPath(p) === target);
|
||||||
const next = tree.applyStateIf('expanded', (n, p) => p.join('/') === path.join('/'));
|
|
||||||
tree.roots = next;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
## Building higher-level trees
|
## Two ways to use it
|
||||||
Wrap `tp-tree-nav` in a specialized component (e.g., file explorer) that:
|
|
||||||
- Tracks expansion/selection state on nodes.
|
### A) Wrap directly (keep tp-tree-nav “dumb”)
|
||||||
- Supplies icons per node type via `renderNode` or `icon` strings.
|
- Manage all state yourself; set `roots` and optional `defaultActions`/`actions`.
|
||||||
- Defines default/context actions relevant to the domain.
|
- Listen to `node-action` (chevron/double-click toggles, inline actions, context actions) and `node-click`, mutate your data, then reassign `roots`.
|
||||||
- Handles action events to mutate the source data and pass updated `roots` back.
|
- Use `renderNode` for custom rows; `renderEmpty` for empty states.
|
||||||
|
- Use helpers (`applyStateIf`, `clearState`, `getNodesWithState`) for immutable transforms/queries.
|
||||||
|
|
||||||
|
### B) Extend tp-tree-nav (add opinionated behavior)
|
||||||
|
Leverage managed mode and hooks in a subclass:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { TpTreeNav } from './tp-tree-nav.js';
|
||||||
|
|
||||||
|
class MyTree extends TpTreeNav {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.manageState = true; // base tracks expand/select
|
||||||
|
this.autoExpandNew = false; // set true to auto-expand unseen paths
|
||||||
|
this.selectionState = 'selected'; // state name for selection
|
||||||
|
this.expandOnDoubleClick = true; // toggle expand via double-click
|
||||||
|
this.multiSelect = false; // enable multi-select if needed
|
||||||
|
this.showActions = true; // render inline actions when present
|
||||||
|
this.renderEmpty = () => html`<div class="empty">No items</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
set data(items) {
|
||||||
|
this.items = items; // raw items with slug/label/icon/children/actions
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderRow(item, meta) {
|
||||||
|
const { node } = item;
|
||||||
|
const selected = meta.states.includes(this.selectionState);
|
||||||
|
return html`
|
||||||
|
<div
|
||||||
|
class="row ${selected ? 'selected' : ''}"
|
||||||
|
style="--depth:${meta.depth}"
|
||||||
|
@click=${(e) => this._onRowClick(item, e)}
|
||||||
|
>
|
||||||
|
<div class="indent"></div>
|
||||||
|
<div
|
||||||
|
class="chevron-btn ${meta.expanded ? 'expanded' : ''}"
|
||||||
|
?hidden=${!meta.hasChildren}
|
||||||
|
@click=${(e) => this._onChevronClick(item, e)}
|
||||||
|
>
|
||||||
|
<tp-icon class="icon" .icon=${TpTreeNav.chevron}></tp-icon>
|
||||||
|
</div>
|
||||||
|
<tp-icon class="icon" .icon=${this._resolveIcon(node.icon)}></tp-icon>
|
||||||
|
<div class="label">${node.label}</div>
|
||||||
|
${node.actions?.length
|
||||||
|
? html`<div class="actions">${node.actions.map((action) => html`
|
||||||
|
<tp-icon
|
||||||
|
class="action-icon"
|
||||||
|
.icon=${this._resolveIcon(action.icon)}
|
||||||
|
@click=${(e) => this._onInlineAction(item, action, e)}
|
||||||
|
></tp-icon>
|
||||||
|
`)}</div>`
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('my-tree', MyTree);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key props when extending
|
||||||
|
- `items`: raw data (`slug`, `label`, `icon`, `children`, optional `actions`).
|
||||||
|
- `manageState`: base manages expand/select when true.
|
||||||
|
- `expandedPaths` / `selectedPaths`: arrays or Sets to seed/restore state.
|
||||||
|
- `selectionState`: state name for selection (default `'selected'`).
|
||||||
|
- `multiSelect`: allow multiple selections.
|
||||||
|
- `expandOnDoubleClick`: toggle expansion on double-click (uses click detail).
|
||||||
|
- `autoExpandNew`: auto-expand unseen nodes when first seen (default false).
|
||||||
|
- `applyStates`: `(node, pathParts, states) => extraStates[]` to add custom states.
|
||||||
|
- `applySelection`: override selection logic `(node, pathParts, states, originalEvent) => nextStates`.
|
||||||
|
- `applyToggle`: override expand/collapse logic `(node, pathParts, states, originalEvent) => nextStates`.
|
||||||
|
- `showActions`: render inline actions if provided.
|
||||||
|
- `renderEmpty`: custom empty renderer.
|
||||||
|
|
||||||
|
### Events (both modes)
|
||||||
|
- `node-click`: emitted on row click.
|
||||||
|
- `node-action`: emitted for toggles (`action: 'toggle'`, source chevron/double-click), inline actions, and context menu actions.
|
||||||
|
- `node-context`: before showing context menu; `preventDefault` to cancel.
|
||||||
|
|
||||||
|
### Helpers (both modes)
|
||||||
|
- `TpTreeNav.buildTree(items, { expandedPaths, selectedPaths, selectionState, applyStates, knownPaths, autoExpandNew })` — pure helper used by managed mode; can be used externally.
|
||||||
|
- `applyStateIf(state, predicate)`, `clearState(state)`, `getNodesWithState(state)` for immutable transforms/queries.
|
||||||
|
|
||||||
|
### Data shape
|
||||||
|
- `slug` (unique per sibling), `label`, `icon` (string key or icon data), `children` (array), optional `actions` (`{ action, label?, icon?, tooltip? }`).
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tp/tp-icon": "^1.0.1",
|
"@tp/tp-icon": "^1.0.1",
|
||||||
"@tp/tp-spinner": "^1.0.0",
|
"@tp/tp-popup": "^1.0.0",
|
||||||
|
"@lit-labs/virtualizer": "^2.0.0",
|
||||||
"lit": "^3.0.0"
|
"lit": "^3.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
123
tp-tree-nav.js
123
tp-tree-nav.js
@@ -4,10 +4,13 @@ Copyright (c) 2025 trading_peter
|
|||||||
This program is available under Apache License Version 2.0
|
This program is available under Apache License Version 2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import '@tp/tp-icon/tp-icon.js';
|
||||||
|
import '@tp/tp-popup/tp-popup-menu.js';
|
||||||
import { LitElement, html, css, svg } from 'lit';
|
import { LitElement, html, css, svg } from 'lit';
|
||||||
import { virtualize } from '@lit-labs/virtualizer/virtualize.js';
|
import { virtualize } from '@lit-labs/virtualizer/virtualize.js';
|
||||||
|
import { Position } from '../helpers/position.js';
|
||||||
|
|
||||||
export class TpTreeNav extends LitElement {
|
export class TpTreeNav extends Position(LitElement) {
|
||||||
static get styles() {
|
static get styles() {
|
||||||
return [
|
return [
|
||||||
css`
|
css`
|
||||||
@@ -98,40 +101,10 @@ export class TpTreeNav extends LitElement {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-menu {
|
tp-popup-menu.context-menu {
|
||||||
position: absolute;
|
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
background: var(--tp-tree-menu-bg, #fff);
|
|
||||||
border: 1px solid var(--tp-tree-menu-border, rgba(0,0,0,0.1));
|
|
||||||
border-radius: 6px;
|
|
||||||
box-shadow: 0 6px 18px rgba(0,0,0,0.18);
|
box-shadow: 0 6px 18px rgba(0,0,0,0.18);
|
||||||
padding: 4px 0;
|
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-item {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 8px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
box-sizing: border-box;
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-item:hover {
|
|
||||||
background: var(--tp-tree-menu-hover-bg, rgba(0,0,0,0.06));
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-icon {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
];
|
];
|
||||||
@@ -173,20 +146,9 @@ export class TpTreeNav extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div
|
<div class="row" part="row" style="--depth: ${depth}" @click=${(e) => this._onRowClick(item, e)} @contextmenu=${(e) => this._onContextMenu(item, e)} >
|
||||||
class="row"
|
|
||||||
part="row"
|
|
||||||
style="--depth: ${depth}"
|
|
||||||
@click=${(e) => this._onRowClick(item, e)}
|
|
||||||
@contextmenu=${(e) => this._onContextMenu(item, e)}
|
|
||||||
>
|
|
||||||
<div class="indent"></div>
|
<div class="indent"></div>
|
||||||
<div
|
<div class="chevron-btn ${expanded ? 'expanded' : ''}" part="chevron" ?hidden=${!hasChildren} @click=${(e) => this._onChevronClick(item, e)} >
|
||||||
class="chevron-btn ${expanded ? 'expanded' : ''}"
|
|
||||||
part="chevron"
|
|
||||||
?hidden=${!hasChildren}
|
|
||||||
@click=${(e) => this._onChevronClick(item, e)}
|
|
||||||
>
|
|
||||||
<tp-icon class="icon" part="chevron-icon" .icon=${TpTreeNav.chevron}></tp-icon>
|
<tp-icon class="icon" part="chevron-icon" .icon=${TpTreeNav.chevron}></tp-icon>
|
||||||
</div>
|
</div>
|
||||||
${icon ? html`<tp-icon class="icon" part="icon" .icon=${icon}></tp-icon>` : html`<span class="icon" part="icon" aria-hidden="true"></span>`}
|
${icon ? html`<tp-icon class="icon" part="icon" .icon=${icon}></tp-icon>` : html`<span class="icon" part="icon" aria-hidden="true"></span>`}
|
||||||
@@ -212,22 +174,17 @@ export class TpTreeNav extends LitElement {
|
|||||||
|
|
||||||
_renderContextMenu() {
|
_renderContextMenu() {
|
||||||
if (!this._contextMenu) return null;
|
if (!this._contextMenu) return null;
|
||||||
const { x, y, item, actions } = this._contextMenu;
|
const { item, actions } = this._contextMenu;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="context-menu-overlay">
|
<div class="context-menu-overlay">
|
||||||
<div class="context-menu" part="context-menu" style="left:${x}px; top:${y}px;">
|
<tp-popup-menu class="context-menu" part="context-menu">
|
||||||
${actions.map((action) => html`
|
${actions.map((action) => html`
|
||||||
<button
|
<tp-popup-menu-item .icon=${action.icon} part="context-menu-item" @click=${(e) => this._onMenuAction(action, item, e)}>
|
||||||
class="menu-item"
|
${action?.label ?? null}
|
||||||
part="context-menu-item"
|
</tp-popup-menu-item>
|
||||||
@click=${(e) => this._onMenuAction(action, item, e)}
|
|
||||||
>
|
|
||||||
${action?.icon ? html`<tp-icon class="menu-icon" part="context-menu-icon" .icon=${this._resolveIcon(action.icon)}></tp-icon>` : html`<span class="menu-icon" part="context-menu-icon" aria-hidden="true"></span>`}
|
|
||||||
<span>${action?.label ?? ''}</span>
|
|
||||||
</button>
|
|
||||||
`)}
|
`)}
|
||||||
</div>
|
</tp-popup-menu>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -257,6 +214,7 @@ export class TpTreeNav extends LitElement {
|
|||||||
emptyMessage: { type: String },
|
emptyMessage: { type: String },
|
||||||
showActions: { type: Boolean },
|
showActions: { type: Boolean },
|
||||||
expandOnDoubleClick: { type: Boolean },
|
expandOnDoubleClick: { type: Boolean },
|
||||||
|
selectOnRightClick: { type: Boolean },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,6 +256,7 @@ export class TpTreeNav extends LitElement {
|
|||||||
this.emptyMessage = 'No items';
|
this.emptyMessage = 'No items';
|
||||||
this.showActions = false;
|
this.showActions = false;
|
||||||
this.expandOnDoubleClick = false;
|
this.expandOnDoubleClick = false;
|
||||||
|
this.selectOnRightClick = false;
|
||||||
this._contextMenu = null;
|
this._contextMenu = null;
|
||||||
this._outsideHandler = null;
|
this._outsideHandler = null;
|
||||||
this._keyHandler = null;
|
this._keyHandler = null;
|
||||||
@@ -420,6 +379,37 @@ export class TpTreeNav extends LitElement {
|
|||||||
async _onContextMenu(item, originalEvent) {
|
async _onContextMenu(item, originalEvent) {
|
||||||
originalEvent.preventDefault();
|
originalEvent.preventDefault();
|
||||||
|
|
||||||
|
if (this.selectOnRightClick && this.manageState) {
|
||||||
|
const pathStr = item.path.join('/');
|
||||||
|
const set = new Set(this._selectedPathSet);
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
if (this.multiSelect) {
|
||||||
|
if (!set.has(pathStr)) {
|
||||||
|
set.add(pathStr);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!set.has(pathStr) || set.size !== 1) {
|
||||||
|
set.clear();
|
||||||
|
set.add(pathStr);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
this._selectedPathSet = set;
|
||||||
|
this.selectedPaths = Array.from(set);
|
||||||
|
this._rebuildManagedTree();
|
||||||
|
|
||||||
|
this.dispatchEvent(new CustomEvent('node-selected', {
|
||||||
|
detail: { node: item.node, path: item.path, originalEvent },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const contextEvent = new CustomEvent('node-context', {
|
const contextEvent = new CustomEvent('node-context', {
|
||||||
detail: { item, originalEvent },
|
detail: { item, originalEvent },
|
||||||
bubbles: true,
|
bubbles: true,
|
||||||
@@ -442,12 +432,23 @@ export class TpTreeNav extends LitElement {
|
|||||||
const actions = await Promise.resolve(maybeModified);
|
const actions = await Promise.resolve(maybeModified);
|
||||||
if (actions === false) return;
|
if (actions === false) return;
|
||||||
|
|
||||||
const rect = this.getBoundingClientRect();
|
const x = originalEvent.clientX;
|
||||||
const x = originalEvent.clientX - rect.left;
|
const y = originalEvent.clientY;
|
||||||
const y = originalEvent.clientY - rect.top;
|
|
||||||
|
|
||||||
this._contextMenu = { x, y, item, actions: Array.isArray(actions) ? actions : baseActions };
|
this._contextMenu = { item, actions: Array.isArray(actions) ? actions : baseActions };
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
|
await this.updateComplete;
|
||||||
|
|
||||||
|
const menu = this.shadowRoot.querySelector('.context-menu');
|
||||||
|
if (menu) {
|
||||||
|
const anchor = {
|
||||||
|
getBoundingClientRect: () => ({
|
||||||
|
top: y, bottom: y, left: x, right: x, width: 0, height: 0
|
||||||
|
})
|
||||||
|
};
|
||||||
|
this._posFixed(anchor, menu, { valign: 'bottom', halign: 'left' });
|
||||||
|
}
|
||||||
|
|
||||||
this._attachOutsideHandlers();
|
this._attachOutsideHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,7 +476,7 @@ export class TpTreeNav extends LitElement {
|
|||||||
_attachOutsideHandlers() {
|
_attachOutsideHandlers() {
|
||||||
if (this._outsideHandler) return;
|
if (this._outsideHandler) return;
|
||||||
this._outsideHandler = (e) => {
|
this._outsideHandler = (e) => {
|
||||||
const menu = this.shadowRoot?.querySelector('.context-menu');
|
const menu = this.shadowRoot.querySelector('.context-menu');
|
||||||
if (menu && e.composedPath().includes(menu)) return;
|
if (menu && e.composedPath().includes(menu)) return;
|
||||||
this._closeContextMenu();
|
this._closeContextMenu();
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user