feat: add selection & batch operations
ci/woodpecker/push/woodpecker Pipeline was successful
Details
ci/woodpecker/push/woodpecker Pipeline was successful
Details
Merge pull request 'feat: add selection & batch operations' (#79) from feat/webui-selection into main Reviewed-on: #79feat/vault-scan-modes
commit
8708eccfbe
|
@ -2,12 +2,11 @@
|
|||
import { createEventDispatcher, onMount, tick } from "svelte";
|
||||
import { normUrl } from "../util/history";
|
||||
|
||||
import Inspect from "./Inspect.svelte";
|
||||
import IconButton from "./utils/IconButton.svelte";
|
||||
import { selected } from "./EntitySelect.svelte";
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let address: string;
|
||||
export let index: number;
|
||||
export let detachUrl: string | undefined = undefined;
|
||||
export let only: boolean;
|
||||
|
||||
let detail = only;
|
||||
|
@ -19,9 +18,12 @@
|
|||
// Required to make detail mode detection work in Browse
|
||||
dispatch("detail", detail);
|
||||
});
|
||||
$: if ($selected.length) {
|
||||
detail = false;
|
||||
}
|
||||
|
||||
function visit() {
|
||||
window.open(normUrl(`/browse/${address}`), "_blank");
|
||||
window.open(normUrl(detachUrl), "_blank");
|
||||
}
|
||||
|
||||
let width = 460;
|
||||
|
@ -48,9 +50,11 @@
|
|||
<div class="browse-column" class:detail>
|
||||
<div class="view" style="--width: {width}px">
|
||||
<header>
|
||||
<IconButton name="link" on:click={() => visit()} disabled={only}>
|
||||
Detach
|
||||
</IconButton>
|
||||
{#if detachUrl}
|
||||
<IconButton name="link" on:click={() => visit()} disabled={only}>
|
||||
Detach
|
||||
</IconButton>
|
||||
{/if}
|
||||
<IconButton
|
||||
name={detail ? "zoom-out" : "zoom-in"}
|
||||
on:click={() => {
|
||||
|
@ -69,7 +73,7 @@
|
|||
Close
|
||||
</IconButton>
|
||||
</header>
|
||||
<Inspect {address} {index} {detail} on:resolved on:close />
|
||||
<slot {detail} />
|
||||
</div>
|
||||
<div class="resizeHandle" on:mousedown|preventDefault={drag} />
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,178 @@
|
|||
<script lang="ts" context="module">
|
||||
import { writable } from "svelte/store";
|
||||
import api from "../lib/api";
|
||||
|
||||
export const selected = writable<string[]>([]);
|
||||
export async function selectGroup(address: string) {
|
||||
const result = await api.query(
|
||||
new Query().matches(undefined, ATTR_IN, `@${address}`),
|
||||
);
|
||||
result.entities
|
||||
.map((e) => e.replace(/^@/, ""))
|
||||
.forEach((entity) => {
|
||||
selected.update((selected) => {
|
||||
if (!selected.includes(entity)) {
|
||||
return [...selected, entity];
|
||||
} else {
|
||||
return selected;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { i18n } from "../i18n";
|
||||
|
||||
import { Query } from "@upnd/upend";
|
||||
import { ATTR_IN } from "@upnd/upend/constants";
|
||||
|
||||
let canvas: HTMLCanvasElement;
|
||||
|
||||
onMount(() => {
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
function resizeCanvas() {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
}
|
||||
window.addEventListener("resize", resizeCanvas);
|
||||
resizeCanvas();
|
||||
|
||||
let selecting = false;
|
||||
let selectAllArea: DOMRect | undefined = undefined;
|
||||
let selectAllAddress: string | undefined = undefined;
|
||||
let addressesToRemove = new Set();
|
||||
document.addEventListener("mousedown", (ev) => {
|
||||
if (ev.ctrlKey) {
|
||||
ev.preventDefault();
|
||||
|
||||
selecting = true;
|
||||
addressesToRemove = new Set();
|
||||
|
||||
const el = document.elementFromPoint(
|
||||
ev.clientX,
|
||||
ev.clientY,
|
||||
) as HTMLElement;
|
||||
|
||||
const groupElement = el.closest("[data-address-group]") as
|
||||
| HTMLElement
|
||||
| undefined;
|
||||
|
||||
if (groupElement) {
|
||||
const banner = groupElement.querySelector("h2 .banner");
|
||||
|
||||
if (banner) {
|
||||
const rect = banner.getBoundingClientRect();
|
||||
selectAllArea = rect;
|
||||
selectAllAddress = groupElement.dataset.addressGroup;
|
||||
|
||||
ctx.rect(rect.left, rect.top, rect.width, rect.height);
|
||||
ctx.fillStyle = "#dc322f33";
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = "#dc322f77";
|
||||
ctx.font = `bold ${rect.height / 2}px Inter`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(
|
||||
$i18n.t("Select All"),
|
||||
rect.left + rect.width / 2,
|
||||
rect.top + rect.height / 2,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.strokeStyle = "#dc322f77";
|
||||
ctx.lineWidth = 7;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ev.clientX, ev.clientY);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("mousemove", (ev) => {
|
||||
if (selecting) {
|
||||
ev.preventDefault();
|
||||
|
||||
if (selectAllArea) {
|
||||
if (
|
||||
ev.clientX > selectAllArea.left &&
|
||||
ev.clientX < selectAllArea.right &&
|
||||
ev.clientY > selectAllArea.top &&
|
||||
ev.clientY < selectAllArea.bottom
|
||||
) {
|
||||
selectGroup(selectAllAddress);
|
||||
stop();
|
||||
}
|
||||
}
|
||||
|
||||
const el = document.elementFromPoint(
|
||||
ev.clientX,
|
||||
ev.clientY,
|
||||
) as HTMLElement;
|
||||
|
||||
const addressElement = el.closest("[data-address]") as
|
||||
| HTMLElement
|
||||
| undefined;
|
||||
if (addressElement) {
|
||||
const address = addressElement.dataset.address;
|
||||
const selectMode = addressElement.dataset.selectMode;
|
||||
if (selectMode === "add" || selectMode === undefined) {
|
||||
selected.update((selected) => {
|
||||
if (!selected.includes(address)) {
|
||||
return [...selected, address];
|
||||
} else {
|
||||
return selected;
|
||||
}
|
||||
});
|
||||
} else if (selectMode === "remove") {
|
||||
addressesToRemove.add(address);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.lineTo(ev.clientX, ev.clientY);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ev.clientX, ev.clientY);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("mouseup", () => {
|
||||
stop();
|
||||
});
|
||||
|
||||
function stop() {
|
||||
selectAllArea = undefined;
|
||||
selectAllAddress = undefined;
|
||||
selecting = false;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
for (const address of addressesToRemove) {
|
||||
selected.update((selected) => {
|
||||
return selected.filter((a) => a !== address);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="selectIndicator">
|
||||
<canvas bind:this={canvas}></canvas>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.selectIndicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
|
||||
pointer-events: none;
|
||||
|
||||
canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -9,6 +9,7 @@
|
|||
icon?: string;
|
||||
components: (input: {
|
||||
entries: UpEntry[];
|
||||
entities: string[];
|
||||
group?: string;
|
||||
address?: string;
|
||||
}) => Array<WidgetComponent>;
|
||||
|
@ -25,7 +26,8 @@
|
|||
import LabelBorder from "./utils/LabelBorder.svelte";
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let entries: UpEntry[];
|
||||
export let entries: UpEntry[] = [];
|
||||
export let entities: string[] = [];
|
||||
export let widgets: Widget[] | undefined = undefined;
|
||||
export let initialWidget: string | undefined = undefined;
|
||||
export let title: string | undefined = undefined;
|
||||
|
@ -43,18 +45,23 @@
|
|||
|
||||
let availableWidgets: Widget[] = [];
|
||||
$: {
|
||||
availableWidgets = [
|
||||
{
|
||||
name: "Entry List",
|
||||
icon: "table",
|
||||
components: ({ entries }) => [
|
||||
{
|
||||
component: EntryList,
|
||||
props: { entries, columns: "entity, attribute, value" },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
availableWidgets = [];
|
||||
|
||||
if (entries.length) {
|
||||
availableWidgets = [
|
||||
...availableWidgets,
|
||||
{
|
||||
name: "Entry List",
|
||||
icon: "table",
|
||||
components: ({ entries }) => [
|
||||
{
|
||||
component: EntryList,
|
||||
props: { entries, columns: "entity, attribute, value" },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (widgets?.length) {
|
||||
availableWidgets = [...widgets, ...availableWidgets];
|
||||
|
@ -71,11 +78,11 @@
|
|||
$: {
|
||||
components = availableWidgets
|
||||
.find((w) => w.name === currentWidget)
|
||||
.components({ entries, group, address });
|
||||
.components({ entries, entities, group, address });
|
||||
}
|
||||
</script>
|
||||
|
||||
<LabelBorder hide={entries.length === 0}>
|
||||
<LabelBorder hide={entries.length === 0 && entities.length === 0}>
|
||||
<svelte:fragment slot="header-full">
|
||||
<h3 class:highlighted>
|
||||
{#if group}
|
||||
|
|
|
@ -17,7 +17,12 @@
|
|||
import EntryList from "./widgets/EntryList.svelte";
|
||||
import api from "../lib/api";
|
||||
import EntityList from "./widgets/EntityList.svelte";
|
||||
import { ATTR_IN, ATTR_LABEL, ATTR_KEY, ATTR_OF } from "@upnd/upend/constants";
|
||||
import {
|
||||
ATTR_IN,
|
||||
ATTR_LABEL,
|
||||
ATTR_KEY,
|
||||
ATTR_OF,
|
||||
} from "@upnd/upend/constants";
|
||||
import InspectGroups from "./InspectGroups.svelte";
|
||||
import InspectTypeEditor from "./InspectTypeEditor.svelte";
|
||||
import LabelBorder from "./utils/LabelBorder.svelte";
|
||||
|
@ -281,7 +286,7 @@
|
|||
],
|
||||
},
|
||||
{
|
||||
name: "EntityList",
|
||||
name: "Entity List",
|
||||
icon: "image",
|
||||
components: ({ entries, address }) => [
|
||||
{
|
||||
|
@ -355,7 +360,12 @@
|
|||
});
|
||||
</script>
|
||||
|
||||
<div class="inspect" class:detail class:blob={blobHandled}>
|
||||
<div
|
||||
class="inspect"
|
||||
class:detail
|
||||
class:blob={blobHandled}
|
||||
data-address-group={address}
|
||||
>
|
||||
<header>
|
||||
<h2>
|
||||
{#if $entity}
|
||||
|
|
|
@ -0,0 +1,163 @@
|
|||
<script lang="ts">
|
||||
import UpObjectDisplay from "./display/UpObject.svelte";
|
||||
import Selector from "./utils/Selector.svelte";
|
||||
import IconButton from "./utils/IconButton.svelte";
|
||||
import type { IValue } from "@upnd/upend/types";
|
||||
import api from "../lib/api";
|
||||
import { ATTR_IN } from "@upnd/upend/constants";
|
||||
import { i18n } from "../i18n";
|
||||
import LabelBorder from "./utils/LabelBorder.svelte";
|
||||
import { Query, UpListing } from "@upnd/upend";
|
||||
|
||||
export let entities: string[];
|
||||
|
||||
let adding = false;
|
||||
let groupSelector: Selector;
|
||||
|
||||
$: if (adding && groupSelector) groupSelector.focus();
|
||||
|
||||
let groups = [];
|
||||
let groupListing: UpListing | undefined = undefined;
|
||||
async function updateGroups() {
|
||||
const currentEntities = entities.concat();
|
||||
const allGroups = await api.query(
|
||||
new Query().matches(
|
||||
currentEntities.map((e) => `@${e}`),
|
||||
ATTR_IN,
|
||||
undefined,
|
||||
),
|
||||
);
|
||||
|
||||
const commonGroups = new Set(
|
||||
allGroups.values
|
||||
.filter((v) => v.t == "Address")
|
||||
.map((v) => v.c)
|
||||
.filter((groupAddr) => {
|
||||
return Object.values(allGroups.objects).every((obj) => {
|
||||
return obj.attr[ATTR_IN].some((v) => v.value.c === groupAddr);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
if (entities.toString() == currentEntities.toString()) {
|
||||
groups = Array.from(commonGroups);
|
||||
groupListing = allGroups;
|
||||
}
|
||||
}
|
||||
$: entities && updateGroups();
|
||||
|
||||
let groupToAdd: IValue | undefined;
|
||||
async function addGroup() {
|
||||
if (!groupToAdd) {
|
||||
return;
|
||||
}
|
||||
await api.putEntry(
|
||||
entities.map((entity) => ({
|
||||
entity,
|
||||
attribute: ATTR_IN,
|
||||
value: {
|
||||
t: "Address",
|
||||
c: String(groupToAdd.c),
|
||||
},
|
||||
})),
|
||||
);
|
||||
groupToAdd = undefined;
|
||||
await updateGroups();
|
||||
}
|
||||
|
||||
async function removeGroup(groupAddress: string) {
|
||||
if (confirm($i18n.t("Are you sure you want to remove this group?"))) {
|
||||
await Promise.all(
|
||||
entities.map((entity) =>
|
||||
api.deleteEntry(
|
||||
groupListing.objects[entity].attr[ATTR_IN].find(
|
||||
(v) => v.value.c === groupAddress,
|
||||
).address,
|
||||
),
|
||||
),
|
||||
);
|
||||
await updateGroups();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<LabelBorder>
|
||||
<span slot="header">{$i18n.t("Common groups")}</span>
|
||||
|
||||
{#if adding}
|
||||
<div class="selector">
|
||||
<Selector
|
||||
bind:this={groupSelector}
|
||||
type="value"
|
||||
valueTypes={["Address"]}
|
||||
bind:value={groupToAdd}
|
||||
on:input={addGroup}
|
||||
on:focus={(ev) => {
|
||||
if (!ev.detail) adding = false;
|
||||
}}
|
||||
placeholder={$i18n.t("Choose an entity...")}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="body">
|
||||
<div class="group-list">
|
||||
{#each groups as groupAddress}
|
||||
<div class="group">
|
||||
<UpObjectDisplay address={groupAddress} link />
|
||||
<IconButton
|
||||
subdued
|
||||
name="x-circle"
|
||||
on:click={() => removeGroup(groupAddress)}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="no-groups">
|
||||
{$i18n.t("Entities have no groups in common.")}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if !adding}
|
||||
<div class="add-button">
|
||||
<IconButton
|
||||
outline
|
||||
small
|
||||
name="folder-plus"
|
||||
on:click={() => (adding = true)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</LabelBorder>
|
||||
|
||||
<style lang="scss">
|
||||
.group-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem 0.2rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
|
||||
.group-list {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.selector {
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.no-groups {
|
||||
opacity: 0.66;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,72 @@
|
|||
<script lang="ts">
|
||||
import { i18n } from "../i18n";
|
||||
import { selected } from "./EntitySelect.svelte";
|
||||
import EntryView from "./EntryView.svelte";
|
||||
import MultiGroupEditor from "./MultiGroupEditor.svelte";
|
||||
import Icon from "./utils/Icon.svelte";
|
||||
import EntityList from "./widgets/EntityList.svelte";
|
||||
|
||||
const selectedWidgets = [
|
||||
{
|
||||
name: "List",
|
||||
icon: "list-check",
|
||||
components: ({ entities }) => [
|
||||
{
|
||||
component: EntityList,
|
||||
props: {
|
||||
entities,
|
||||
thumbnails: false,
|
||||
select: "remove",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "EntityList",
|
||||
icon: "image",
|
||||
components: ({ entities }) => [
|
||||
{
|
||||
component: EntityList,
|
||||
props: {
|
||||
entities,
|
||||
thumbnails: true,
|
||||
select: "remove",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="view">
|
||||
<h2><Icon plain name="select-multiple" /> {$i18n.t("Selected")}: {$selected.length}</h2>
|
||||
<div class="actions">
|
||||
<MultiGroupEditor entities={$selected} />
|
||||
</div>
|
||||
<div class="entities">
|
||||
<EntryView
|
||||
title={$i18n.t("Selected entities")}
|
||||
entities={$selected}
|
||||
widgets={selectedWidgets}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.entities {
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
height: 0;
|
||||
}
|
||||
</style>
|
|
@ -7,17 +7,18 @@
|
|||
import UpObjectCard from "../display/UpObjectCard.svelte";
|
||||
import { ATTR_LABEL } from "@upnd/upend/constants";
|
||||
import { i18n } from "../../i18n";
|
||||
import Icon from "../utils/Icon.svelte";
|
||||
import IconButton from "../utils/IconButton.svelte";
|
||||
import Selector from "../utils/Selector.svelte";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import type { WidgetChange } from "src/types/base";
|
||||
import debug from "debug";
|
||||
import { selected } from "../EntitySelect.svelte";
|
||||
const dispatch = createEventDispatcher();
|
||||
const dbg = debug(`kestrel:EntityList`);
|
||||
|
||||
export let entities: Address[];
|
||||
export let thumbnails = true;
|
||||
export let select: "add" | "remove" = "add";
|
||||
export let sort = true;
|
||||
export let address: Address | undefined = undefined;
|
||||
|
||||
|
@ -169,7 +170,13 @@
|
|||
{/if}
|
||||
<div class="items">
|
||||
{#each sortedEntities as entity (entity)}
|
||||
<div data-address={entity} use:observe class="item">
|
||||
<div
|
||||
data-address={entity}
|
||||
data-select-mode={select}
|
||||
use:observe
|
||||
class="item"
|
||||
class:selected={select === "add" && $selected.includes(entity)}
|
||||
>
|
||||
{#if visible.has(entity)}
|
||||
{#if thumbnails}
|
||||
<UpObjectCard
|
||||
|
@ -242,6 +249,8 @@
|
|||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@use "../../styles/colors";
|
||||
|
||||
.items {
|
||||
gap: 4px;
|
||||
}
|
||||
|
@ -327,4 +336,9 @@
|
|||
.entitylist.style-grid .add {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.selected {
|
||||
margin: 0.12rem;
|
||||
box-shadow: 0 0 0.1rem 0.11rem colors.$red;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -3,6 +3,9 @@
|
|||
import BrowseAdd from "../components/BrowseAdd.svelte";
|
||||
import BrowseColumn from "../components/BrowseColumn.svelte";
|
||||
import { updateTitle } from "../util/title";
|
||||
import EntitySelect, { selected } from "../components/EntitySelect.svelte";
|
||||
import SelectedColumn from "../components/SelectedColumn.svelte";
|
||||
import Inspect from "../components/Inspect.svelte";
|
||||
const navigate = useNavigate();
|
||||
const params = useParams();
|
||||
|
||||
|
@ -51,12 +54,16 @@
|
|||
}
|
||||
|
||||
let detailMode = false;
|
||||
function onDetailChanged(index: number, ev: CustomEvent<boolean>) {
|
||||
function onDetailChanged(
|
||||
index: number | "selected",
|
||||
ev: CustomEvent<boolean>,
|
||||
) {
|
||||
if (ev.detail) {
|
||||
scrollToVisible(index);
|
||||
}
|
||||
detailMode = addresses.length === 1 && ev.detail;
|
||||
}
|
||||
$: only = addresses.length === 1 && !$selected.length;
|
||||
|
||||
$: updateTitle("Browse", identities.join(" / "));
|
||||
</script>
|
||||
|
@ -69,15 +76,28 @@
|
|||
{#each addresses as address, index}
|
||||
<div class="column" data-index={index}>
|
||||
<BrowseColumn
|
||||
{address}
|
||||
{index}
|
||||
only={addresses.length === 1}
|
||||
detachUrl="/browse/{address}"
|
||||
{only}
|
||||
on:close={() => close(index)}
|
||||
on:resolved={(ev) => onIdentified(index, ev)}
|
||||
on:detail={(ev) => onDetailChanged(index, ev)}
|
||||
/>
|
||||
let:detail
|
||||
>
|
||||
<Inspect {address} {index} {detail} on:resolved on:close />
|
||||
</BrowseColumn>
|
||||
</div>
|
||||
{/each}
|
||||
{#if $selected.length}
|
||||
<div class="column" data-index="selected">
|
||||
<BrowseColumn
|
||||
{only}
|
||||
on:close={() => selected.set([])}
|
||||
on:detail={(ev) => onDetailChanged("selected", ev)}
|
||||
>
|
||||
<SelectedColumn />
|
||||
</BrowseColumn>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !detailMode}
|
||||
{#key addresses}
|
||||
<div class="column" data-index="add">
|
||||
|
@ -90,6 +110,8 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
<EntitySelect />
|
||||
|
||||
<style lang="scss">
|
||||
.browser {
|
||||
height: 100%;
|
||||
|
|
Loading…
Reference in New Issue