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

450 lines
12 KiB
Svelte

<script lang="ts">
import filesize from 'filesize';
import { formatRelative, fromUnixTime, parseISO } from 'date-fns';
import Ellipsis from '../utils/Ellipsis.svelte';
import UpObject from '../display/UpObject.svelte';
import { createEventDispatcher } from 'svelte';
import type { WidgetChange } from '$lib/types/base';
import type { UpEntry, UpListing } from '@upnd/upend';
import IconButton from '../utils/IconButton.svelte';
import Selector, { type SelectorValue, selectorValueAsValue } from '../utils/Selector.svelte';
import Editable from '../utils/Editable.svelte';
import { query } from '$lib/entity';
import { type Readable, readable } from 'svelte/store';
import { defaultEntitySort, entityValueSort } from '$lib/util/sort';
import { attributeLabels } from '$lib/util/labels';
import { formatDuration } from '$lib/util/fragments/time';
import { i18n } from '$lib/i18n';
import UpLink from '../display/UpLink.svelte';
import { ATTR_ADDED, ATTR_LABEL } from '@upnd/upend/constants';
const dispatch = createEventDispatcher<{ change: WidgetChange }>();
export let columns: string | undefined = undefined;
export let header = true;
export let orderByValue = false;
export let columnWidths: string[] | undefined = undefined;
export let entries: UpEntry[];
export let attributes: string[] | undefined = undefined;
// Display
$: displayColumns = (columns || 'entity, attribute, value').split(',').map((c) => c.trim());
const TIMESTAMP_COL = 'timestamp';
const PROVENANCE_COL = 'provenance';
const USER_COL = 'user';
const ENTITY_COL = 'entity';
const ATTR_COL = 'attribute';
const VALUE_COL = 'value';
$: templateColumns = (
(displayColumns || []).map((column, idx) => {
if (columnWidths?.[idx]) return columnWidths[idx];
return 'minmax(6em, auto)';
}) as string[]
)
.concat(['auto'])
.join(' ');
// Editing
let adding = false;
let addHover = false;
let addFocus = false;
let newAttrSelector: Selector;
let newEntryAttribute = '';
let newEntryValue: SelectorValue | undefined;
$: if (adding && newAttrSelector) newAttrSelector.focus();
$: if (!addFocus && !addHover && !newEntryAttribute && !newEntryValue) adding = false;
async function addEntry(attribute: string, value: SelectorValue) {
dispatch('change', {
type: 'create',
attribute,
value: await selectorValueAsValue(value)
});
newEntryAttribute = '';
newEntryValue = undefined;
}
async function removeEntry(address: string) {
if (confirm($i18n.t('Are you sure you want to remove the property?') || '')) {
dispatch('change', { type: 'delete', address } as WidgetChange);
}
}
async function updateEntry(oldEntry: UpEntry, value: SelectorValue) {
dispatch('change', [
{
type: 'delete',
address: oldEntry.address
},
{
type: 'create',
attribute: oldEntry.attribute,
value: await selectorValueAsValue(value)
}
]);
}
// Labelling
let labelListing: Readable<UpListing | undefined> = readable(undefined);
$: {
const addresses: string[] = [];
entries
.flatMap((e) => (e.value.t === 'Address' ? [e.entity, e.value.c] : [e.entity]))
.forEach((addr) => {
if (!addresses.includes(addr)) {
addresses.push(addr);
}
});
const addressesString = addresses.map((addr) => `@${addr}`).join(' ');
labelListing = query(`(matches (in ${addressesString}) "${ATTR_LABEL}" ? )`).result;
}
// Sorting
let sortedEntries = entries;
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) sortEntries();
}
function sortEntries() {
sortedEntries = orderByValue
? entityValueSort(entries, Object.assign(sortKeys, $attributeLabels))
: defaultEntitySort(entries, Object.assign(sortKeys, $attributeLabels));
}
$: {
if ($labelListing) {
entries.forEach((entry) => {
addSortKeys(entry.entity, $labelListing!.getObject(entry.entity).identify(), false);
if (entry.value.t === 'Address') {
addSortKeys(
entry.value.c,
$labelListing!.getObject(String(entry.value.c)).identify(),
false
);
}
});
sortEntries();
}
}
entries.forEach((entry) => {
addSortKeys(entry.entity, entry.listing?.getObject(entry.entity).identify() || [], false);
if (entry.value.t === 'Address') {
addSortKeys(
entry.value.c,
entry.listing?.getObject(String(entry.value.c)).identify() || [],
false
);
}
});
sortEntries();
// 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);
}
};
}
// Formatting & Display
const COLUMN_LABELS: { [key: string]: string } = {
timestamp: $i18n.t('Added at'),
provenance: $i18n.t('Provenance'),
user: $i18n.t('User'),
entity: $i18n.t('Entity'),
attribute: $i18n.t('Attribute'),
value: $i18n.t('Value')
};
function formatValue(value: string | number | null, attribute: string): string {
try {
switch (attribute) {
case 'FILE_SIZE':
return filesize(parseInt(String(value), 10), { base: 2 });
case ATTR_ADDED:
case 'LAST_VISITED':
return formatRelative(fromUnixTime(parseInt(String(value), 10)), new Date());
case 'NUM_VISITED':
return `${value} times`;
case 'MEDIA_DURATION':
return formatDuration(parseInt(String(value), 10));
}
} catch {
// noop.
}
return String(value);
}
// Unused attributes
let unusedAttributes: string[] = [];
$: (async () => {
unusedAttributes = await Promise.all(
(attributes || []).filter((attr) => !entries.some((entry) => entry.attribute === attr))
);
})();
</script>
<div class="entry-list" style:--template-columns={templateColumns}>
{#if header}
<header>
{#each displayColumns as column}
<div class="label">
{COLUMN_LABELS[column] || $attributeLabels[column] || column}
</div>
{/each}
<div class="attr-action"></div>
</header>
{/if}
{#each sortedEntries as entry (entry.address)}
{#if visible.has(entry.address)}
{#each displayColumns as column}
{#if column == TIMESTAMP_COL}
<div class="cell" title={entry.timestamp}>
{formatRelative(parseISO(entry.timestamp), new Date())}
</div>
{:else if column == PROVENANCE_COL}
<div class="cell">{entry.provenance}</div>
{:else if column == USER_COL}
<div class="cell">
{#if entry.user}
{entry.user}
{:else}
<div class="unset">
{$i18n.t('unset')}
</div>
{/if}
</div>
{:else if column == ENTITY_COL}
<div class="cell entity mark-entity">
<UpObject
link
labels={$labelListing?.getObject(String(entry.entity))?.identify() || []}
address={entry.entity}
on:resolved={(event) => {
addSortKeys(entry.entity, event.detail, true);
}}
/>
</div>
{:else if column == ATTR_COL}
<div
class="cell mark-attribute"
class:formatted={Boolean(Object.keys($attributeLabels).includes(entry.attribute))}
>
<UpLink to={{ attribute: entry.attribute }}>
<Ellipsis
value={$attributeLabels[entry.attribute] || entry.attribute}
title={$attributeLabels[entry.attribute]
? `${$attributeLabels[entry.attribute]} (${entry.attribute})`
: entry.attribute}
/>
</UpLink>
</div>
{:else if column == VALUE_COL}
<div
class="cell value mark-value"
data-address={entry.value.t === 'Address' ? entry.value.c : undefined}
>
<Editable value={entry.value} on:edit={(ev) => updateEntry(entry, ev.detail)}>
{#if entry.value.t === 'Address'}
<UpObject
link
address={String(entry.value.c)}
labels={$labelListing?.getObject(String(entry.value.c))?.identify() || []}
on:resolved={(event) => {
addSortKeys(String(entry.value.c), event.detail, true);
}}
/>
{:else}
<div class:formatted={Boolean(formatValue(entry.value.c, entry.attribute))}>
<Ellipsis
value={formatValue(entry.value.c, entry.attribute) || String(entry.value.c)}
/>
</div>
{/if}
</Editable>
</div>
{:else}
<div>?</div>
{/if}
{/each}
<div class="attr-action">
<IconButton
plain
subdued
name="x-circle"
color="#dc322f"
on:click={() => removeEntry(entry.address)}
/>
</div>
{:else}
<div class="skeleton" data-address={entry.address} use:observe>...</div>
{/if}
{/each}
{#each unusedAttributes as attribute}
{#each displayColumns as column}
{#if column == ATTR_COL}
<div
class="cell mark-attribute"
class:formatted={Boolean(Object.keys($attributeLabels).includes(attribute))}
>
<UpLink to={{ attribute }}>
<Ellipsis
value={$attributeLabels[attribute] || attribute}
title={$attributeLabels[attribute]
? `${$attributeLabels[attribute]} (${attribute})`
: attribute}
/>
</UpLink>
</div>
{:else if column == VALUE_COL}
<div class="cell">
<Editable on:edit={(ev) => addEntry(attribute, ev.detail)}>
<span class="unset">{$i18n.t('(unset)')}</span>
</Editable>
</div>
{:else}
<div class="cell"></div>
{/if}
{/each}
<div class="attr-action"></div>
{/each}
{#if !attributes?.length}
{#if adding}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="add-row"
on:mouseenter={() => (addHover = true)}
on:mouseleave={() => (addHover = false)}
>
{#each displayColumns as column}
{#if column == ATTR_COL}
<div class="cell mark-attribute">
<Selector
types={['Attribute', 'NewAttribute']}
on:input={(ev) => (newEntryAttribute = ev.detail?.name)}
on:focus={(ev) => (addFocus = ev.detail)}
keepFocusOnSet
bind:this={newAttrSelector}
/>
</div>
{:else if column === VALUE_COL}
<div class="cell mark-value">
<Selector
on:input={(ev) => (newEntryValue = ev.detail)}
on:focus={(ev) => (addFocus = ev.detail)}
keepFocusOnSet
/>
</div>
{:else}
<div class="cell"></div>
{/if}
{/each}
<div class="attr-action">
<IconButton
name="save"
on:click={() => newEntryValue && addEntry(newEntryAttribute, newEntryValue)}
/>
</div>
</div>
{:else}
<div class="add-button">
<IconButton outline subdued name="plus-circle" on:click={() => (adding = true)} />
</div>
{/if}
{/if}
</div>
<style lang="scss">
.entry-list {
display: grid;
grid-template-columns: var(--template-columns);
gap: 0.05rem 0.5rem;
header {
display: contents;
.label {
font-weight: 600;
}
}
.cell {
font-family: var(--monospace-font);
line-break: anywhere;
min-width: 0;
border-radius: 4px;
padding: 2px;
&.formatted,
.formatted {
font-family: var(--default-font);
}
}
.attr-action {
display: flex;
justify-content: end;
align-items: center;
}
.add-row {
display: contents;
}
.add-button {
display: flex;
flex-direction: column;
grid-column: 1 / -1;
}
.unset {
opacity: 0.66;
pointer-events: none;
}
}
</style>