upend/webui/src/lib/components/widgets/EntityList.svelte

312 lines
6.7 KiB
Svelte

<script lang="ts">
import { readable, type Readable } from 'svelte/store';
import type { UpListing } from '@upnd/upend';
import type { Address } from '@upnd/upend/types';
import { query } from '$lib/entity';
import UpObject from '../display/UpObject.svelte';
import UpObjectCard from '../display/UpObjectCard.svelte';
import { ATTR_LABEL } from '@upnd/upend/constants';
import { i18n } from '$lib/i18n';
import IconButton from '../utils/IconButton.svelte';
import Selector, { type SelectorValue } from '../utils/Selector.svelte';
import { createEventDispatcher } from 'svelte';
import type { WidgetChange } from '$lib/types/base';
import debug from 'debug';
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;
$: deduplicatedEntities = Array.from(new Set(entities));
let style: 'list' | 'grid' | 'flex' = 'grid';
let clientWidth: number;
$: style = !thumbnails || clientWidth < 600 ? 'list' : 'grid';
// Sorting
let sortedEntities: Address[] = [];
let sortKeys: { [key: string]: string[] } = {};
function addSortKeys(key: string, vals: string[], resort: boolean) {
if (!sortKeys[key]) {
sortKeys[key] = [];
}
let changed = false;
vals.forEach((val) => {
if (!sortKeys[key].includes(val)) {
changed = true;
sortKeys[key].push(val);
}
});
if (resort && changed) sortEntities();
}
function sortEntities() {
if (!sort) return;
sortedEntities = deduplicatedEntities.concat();
sortedEntities.sort((a, b) => {
if (!sortKeys[a]?.length || !sortKeys[b]?.length) {
if (Boolean(sortKeys[a]?.length) && !sortKeys[b]?.length) {
return -1;
} else if (!sortKeys[a]?.length && Boolean(sortKeys[b]?.length)) {
return 1;
} else {
return a.localeCompare(b);
}
} else {
return sortKeys[a][0].localeCompare(sortKeys[b][0], undefined, {
numeric: true
});
}
});
}
// Labelling
let labelListing: Readable<UpListing | undefined> = readable(undefined);
$: {
const addressesString = deduplicatedEntities.map((addr) => `@${addr}`).join(' ');
labelListing = query(`(matches (in ${addressesString}) "${ATTR_LABEL}" ? )`).result;
}
$: {
if ($labelListing) {
deduplicatedEntities.forEach((address) => {
addSortKeys(address, $labelListing?.getObject(address).identify() || [], false);
});
sortEntities();
}
}
if (!sort) {
sortedEntities = entities;
}
// Visibility
let visible: Set<string> = new Set();
let observer = new IntersectionObserver((intersections) => {
intersections.forEach((intersection) => {
const address = (intersection.target as HTMLElement).dataset['address'];
if (!address) {
console.warn('Intersected wrong element?');
return;
}
if (intersection.isIntersecting) {
visible.add(address);
}
visible = visible;
});
});
function observe(node: HTMLElement) {
observer.observe(node);
return {
destroy() {
observer.unobserve(node);
}
};
}
// Adding
let addSelector: Selector | undefined;
let adding = false;
$: if (adding && addSelector) addSelector.focus();
function addEntity(ev: CustomEvent<SelectorValue>) {
dbg('Adding entity', ev.detail);
const addAddress = ev.detail?.t == 'Address' ? ev.detail.c : undefined;
if (!addAddress) return;
dispatch('change', {
type: 'entry-add',
address: addAddress
} as WidgetChange);
}
function removeEntity(address: string) {
if (confirm($i18n.t('Are you sure you want to remove this entry from members?') || '')) {
dbg('Removing entity', address);
dispatch('change', {
type: 'entry-delete',
address
} as WidgetChange);
}
}
</script>
<div class="entitylist style-{style}" class:has-thumbnails={thumbnails} bind:clientWidth>
{#if !sortedEntities.length}
<div class="message">
{$i18n.t('No entries.')}
</div>
{/if}
<div class="items">
{#each sortedEntities as entity (entity)}
<div data-address={entity} data-select-mode={select} use:observe class="item">
{#if visible.has(entity)}
{#if thumbnails}
<UpObjectCard
address={entity}
labels={sortKeys[entity]}
banner={false}
select={select === 'add'}
on:resolved={(event) => {
addSortKeys(entity, event.detail, true);
}}
/>
<div class="icon">
<IconButton name="trash" color="#dc322f" on:click={() => removeEntity(entity)} />
</div>
{:else}
<div class="object">
<UpObject
link
address={entity}
labels={sortKeys[entity]}
select={select === 'add'}
on:resolved={(event) => {
addSortKeys(entity, event.detail, true);
}}
/>
</div>
<div class="icon">
<IconButton name="trash" color="#dc322f" on:click={() => removeEntity(entity)} />
</div>
{/if}
{:else}
<div class="skeleton" style="text-align: center">...</div>
{/if}
</div>
{/each}
{#if address}
<div class="add">
{#if adding}
<Selector
bind:this={addSelector}
placeholder={$i18n.t('Search database or paste an URL') || ''}
types={['Address', 'NewAddress']}
on:input={addEntity}
on:focus={(ev) => {
if (!ev.detail) {
adding = false;
}
}}
/>
{:else}
<IconButton
name="plus-circle"
outline
subdued
on:click={() => {
adding = true;
}}
/>
{/if}
</div>
{/if}
</div>
</div>
<style lang="scss">
@use '../../styles/colors';
.items {
gap: 4px;
}
.entitylist.has-thumbnails .items {
gap: 1rem;
}
:global(.entitylist.style-grid .items) {
display: grid;
grid-template-columns: repeat(4, 1fr);
align-items: end;
}
:global(.entitylist.style-flex .items) {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
}
:global(.entitylist.style-list .items) {
display: flex;
flex-direction: column;
align-items: stretch;
}
.item {
min-width: 0;
overflow: hidden;
}
.message {
text-align: center;
margin: 0.5em;
opacity: 0.66;
}
.entitylist:not(.has-thumbnails) {
.item {
display: flex;
.object {
flex-grow: 1;
max-width: 100%;
min-width: 0;
}
.icon {
width: 0;
transition: width 0.3s ease;
text-align: center;
}
&:hover {
.icon {
width: 1.5em;
}
}
}
}
.entitylist.has-thumbnails {
.item {
position: relative;
.icon {
position: absolute;
top: 0.5em;
right: 0.5em;
opacity: 0;
transition: opacity 0.3s ease;
}
&:hover .icon {
opacity: 1;
}
}
}
.add {
display: flex;
flex-direction: column;
}
.entitylist.style-grid .add {
grid-column: 1 / -1;
}
</style>