2024-01-22 13:12:21 +01:00
|
|
|
<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';
|
2024-02-06 22:32:10 +01:00
|
|
|
import type { WidgetChange } from '$lib/types/base';
|
2024-01-22 13:12:21 +01:00
|
|
|
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';
|
2024-01-22 20:33:12 +01:00
|
|
|
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';
|
2024-01-22 13:12:21 +01:00
|
|
|
import UpLink from '../display/UpLink.svelte';
|
|
|
|
import { ATTR_ADDED, ATTR_LABEL } from '@upnd/upend/constants';
|
|
|
|
|
2024-02-06 22:32:10 +01:00
|
|
|
const dispatch = createEventDispatcher<{ change: WidgetChange }>();
|
2024-01-22 13:12:21 +01:00
|
|
|
|
|
|
|
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';
|
2024-03-31 00:31:32 +01:00
|
|
|
const USER_COL = 'user';
|
2024-01-22 13:12:21 +01:00
|
|
|
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();
|
2024-03-31 16:24:05 +02:00
|
|
|
$: if (!addFocus && !addHover && !newEntryAttribute && !newEntryValue) adding = false;
|
2024-01-22 13:12:21 +01:00
|
|
|
|
|
|
|
async function addEntry(attribute: string, value: SelectorValue) {
|
|
|
|
dispatch('change', {
|
|
|
|
type: 'create',
|
|
|
|
attribute,
|
|
|
|
value: await selectorValueAsValue(value)
|
2024-02-06 22:32:10 +01:00
|
|
|
});
|
2024-01-22 13:12:21 +01:00
|
|
|
newEntryAttribute = '';
|
|
|
|
newEntryValue = undefined;
|
|
|
|
}
|
|
|
|
async function removeEntry(address: string) {
|
2024-01-22 20:33:12 +01:00
|
|
|
if (confirm($i18n.t('Are you sure you want to remove the property?') || '')) {
|
2024-01-22 13:12:21 +01:00
|
|
|
dispatch('change', { type: 'delete', address } as WidgetChange);
|
|
|
|
}
|
|
|
|
}
|
2024-02-06 22:32:10 +01:00
|
|
|
async function updateEntry(oldEntry: UpEntry, value: SelectorValue) {
|
|
|
|
dispatch('change', [
|
|
|
|
{
|
|
|
|
type: 'delete',
|
|
|
|
address: oldEntry.address
|
|
|
|
},
|
|
|
|
{
|
|
|
|
type: 'create',
|
|
|
|
attribute: oldEntry.attribute,
|
|
|
|
value: await selectorValueAsValue(value)
|
|
|
|
}
|
|
|
|
]);
|
2024-01-22 13:12:21 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
// Labelling
|
2024-01-22 20:33:12 +01:00
|
|
|
let labelListing: Readable<UpListing | undefined> = readable(undefined);
|
2024-01-22 13:12:21 +01:00
|
|
|
$: {
|
2024-01-22 20:33:12 +01:00
|
|
|
const addresses: string[] = [];
|
2024-01-22 13:12:21 +01:00
|
|
|
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) => {
|
2024-01-22 20:33:12 +01:00
|
|
|
addSortKeys(entry.entity, $labelListing!.getObject(entry.entity).identify(), false);
|
2024-01-22 13:12:21 +01:00
|
|
|
|
|
|
|
if (entry.value.t === 'Address') {
|
|
|
|
addSortKeys(
|
|
|
|
entry.value.c,
|
2024-01-22 20:33:12 +01:00
|
|
|
$labelListing!.getObject(String(entry.value.c)).identify(),
|
2024-01-22 13:12:21 +01:00
|
|
|
false
|
|
|
|
);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
sortEntries();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
entries.forEach((entry) => {
|
2024-01-22 20:33:12 +01:00
|
|
|
addSortKeys(entry.entity, entry.listing?.getObject(entry.entity).identify() || [], false);
|
2024-01-22 13:12:21 +01:00
|
|
|
if (entry.value.t === 'Address') {
|
2024-01-22 20:33:12 +01:00
|
|
|
addSortKeys(
|
|
|
|
entry.value.c,
|
|
|
|
entry.listing?.getObject(String(entry.value.c)).identify() || [],
|
|
|
|
false
|
|
|
|
);
|
2024-01-22 13:12:21 +01:00
|
|
|
}
|
|
|
|
});
|
|
|
|
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'),
|
2024-03-31 00:31:32 +01:00
|
|
|
user: $i18n.t('User'),
|
2024-01-22 13:12:21 +01:00
|
|
|
entity: $i18n.t('Entity'),
|
|
|
|
attribute: $i18n.t('Attribute'),
|
|
|
|
value: $i18n.t('Value')
|
|
|
|
};
|
|
|
|
|
2024-01-22 20:33:12 +01:00
|
|
|
function formatValue(value: string | number | null, attribute: string): string {
|
2024-01-22 13:12:21 +01:00
|
|
|
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
|
2024-01-22 20:33:12 +01:00
|
|
|
let unusedAttributes: string[] = [];
|
2024-01-22 13:12:21 +01:00
|
|
|
|
|
|
|
$: (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>
|
2024-03-31 00:31:32 +01:00
|
|
|
{:else if column == USER_COL}
|
|
|
|
<div class="cell">
|
|
|
|
{#if entry.user}
|
|
|
|
{entry.user}
|
|
|
|
{:else}
|
|
|
|
<div class="unset">
|
|
|
|
{$i18n.t('unset')}
|
|
|
|
</div>
|
|
|
|
{/if}
|
|
|
|
</div>
|
2024-01-22 13:12:21 +01:00
|
|
|
{: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}
|
|
|
|
>
|
2024-02-06 22:32:10 +01:00
|
|
|
<Editable value={entry.value} on:edit={(ev) => updateEntry(entry, ev.detail)}>
|
2024-01-22 13:12:21 +01:00
|
|
|
{#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}
|
2024-01-22 20:33:12 +01:00
|
|
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
2024-01-22 13:12:21 +01:00
|
|
|
<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']}
|
2024-04-21 21:42:15 +02:00
|
|
|
on:input={(ev) =>
|
|
|
|
(newEntryAttribute = ev.detail?.t === 'Attribute' ? ev.detail?.name : '')}
|
2024-01-22 13:12:21 +01:00
|
|
|
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">
|
2024-01-22 20:33:12 +01:00
|
|
|
<IconButton
|
|
|
|
name="save"
|
|
|
|
on:click={() => newEntryValue && addEntry(newEntryAttribute, newEntryValue)}
|
|
|
|
/>
|
2024-01-22 13:12:21 +01:00
|
|
|
</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;
|
2024-04-12 15:25:47 +02:00
|
|
|
justify-content: end;
|
2024-01-22 13:12:21 +01:00
|
|
|
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>
|