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

339 lines
8.8 KiB
Svelte

<script lang="ts">
import filesize from "filesize";
import { format, formatRelative, fromUnixTime } from "date-fns";
import Ellipsis from "../utils/Ellipsis.svelte";
import UpObject from "../display/UpObject.svelte";
import { createEventDispatcher } from "svelte";
import type { AttributeChange, AttributeUpdate } from "../../types/base";
import type { UpEntry, UpListing } from "upend";
import IconButton from "../utils/IconButton.svelte";
import Selector from "../utils/Selector.svelte";
import type { IValue } from "upend/types";
import Editable from "../utils/Editable.svelte";
import { query } from "../../lib/entity";
import { type Readable, readable } from "svelte/store";
import { defaultEntitySort, entityValueSort } from "../../util/sort";
import { attributeLabels } from "../../util/labels";
import { formatDuration } from "../../util/fragments/time";
import { i18n } from "../../i18n";
import UpLink from "../display/UpLink.svelte";
const dispatch = createEventDispatcher();
export let columns: string | undefined = undefined;
export let header = true;
export let orderByValue = false;
export let columnWidths: string[] = [];
export let entries: UpEntry[];
export let editable = false;
// Display
$: displayColumns = (columns || "attribute, value")
.split(",")
.map((c) => c.trim());
const ENTITY_COL = "entity";
const ATTR_COL = "attribute";
const VALUE_COL = "value";
// Editing
let newEntryAttribute = "";
let newEntryValue: IValue | undefined;
async function addEntry() {
dispatch("change", {
type: "create",
attribute: newEntryAttribute,
value: newEntryValue,
} as AttributeChange);
newEntryAttribute = "";
newEntryValue = undefined;
}
async function removeEntry(address: string) {
if (confirm($i18n.t("Are you sure you want to remove the attribute?"))) {
dispatch("change", { type: "delete", address } as AttributeChange);
}
}
async function updateEntry(
address: string,
attribute: string,
value: IValue
) {
dispatch("change", {
type: "update",
address,
attribute,
value,
} as AttributeUpdate);
}
// Labelling
let labelListing: Readable<UpListing> = readable(undefined);
$: {
const addresses = [];
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}) "LBL" ? )`).result;
}
// Sorting
let sortedAttributes = entries;
let resort = false;
let sortKeys: { [key: string]: string[] } = {};
function addSortKeys(key: string, vals: string[]) {
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) sortAttributes();
}
function sortAttributes() {
sortedAttributes = orderByValue
? entityValueSort(entries, sortKeys)
: defaultEntitySort(entries, sortKeys);
}
$: {
if ($labelListing) {
entries.forEach((entry) => {
addSortKeys(
entry.entity,
$labelListing.getObject(entry.entity).identify()
);
if (entry.value.t === "Address") {
addSortKeys(
entry.value.c,
$labelListing.getObject(String(entry.value.c)).identify()
);
}
});
sortAttributes();
resort = true;
}
}
entries.forEach((entry) => {
addSortKeys(entry.entity, entry.listing.getObject(entry.entity).identify());
if (entry.value.t === "Address") {
addSortKeys(
entry.value.c,
entry.listing.getObject(String(entry.value.c)).identify()
);
}
});
// Formatting & Display
const COLUMN_LABELS: { [key: string]: string } = {
entity: $i18n.t("Entity"),
attribute: $i18n.t("Attribute"),
value: $i18n.t("Value"),
};
function formatValue(value: string | number, attribute: string): string {
switch (attribute) {
case "FILE_MTIME":
return format(fromUnixTime(parseInt(String(value), 10)), "PPpp");
case "FILE_SIZE":
return filesize(parseInt(String(value), 10), { base: 2 });
case "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));
default:
return String(value);
}
}
</script>
<table>
<colgroup>
{#if editable}
<col class="action-col" />
{/if}
{#each displayColumns as column, idx}
{#if columnWidths.length}
<col
class="{column}-col"
style="width: {columnWidths[idx] || 'unset'}"
/>
{:else}
<col class="{column}-col" />
{/if}
{/each}
</colgroup>
{#if header}
<tr>
{#if editable}
<th />
{/if}
{#each displayColumns as column}
<th>{COLUMN_LABELS[column] || $attributeLabels[column] || column}</th>
{/each}
</tr>
{/if}
{#each sortedAttributes as entry (entry.address)}
<tr>
{#if editable}
<td class="attr-action">
<IconButton
name="x-circle"
on:click={() => removeEntry(entry.address)}
/>
</td>
{/if}
{#each displayColumns as column}
{#if column == ENTITY_COL}
<td class="entity">
<UpObject
link
labels={$labelListing
?.getObject(String(entry.entity))
?.identify() || []}
address={entry.entity}
on:resolved={(event) => {
addSortKeys(entry.entity, event.detail);
}}
/>
</td>
{:else if column == ATTR_COL}
<td
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>
</td>
{:else if column == VALUE_COL}
<td class="value">
<Editable
{editable}
attribute={entry.attribute}
value={entry.value}
on:edit={(ev) =>
updateEntry(entry.address, entry.attribute, 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);
}}
/>
{: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>
</td>
{:else}
<td>?</td>
{/if}
{/each}
</tr>
{/each}
{#if editable}
<tr class="add-row">
<td class="attr-action">
<IconButton name="plus-circle" on:click={addEntry} />
</td>
{#if displayColumns.includes(ATTR_COL)}
<td>
<Selector type="attribute" bind:attribute={newEntryAttribute} />
</td>
{/if}
{#if displayColumns.includes(VALUE_COL)}
<td>
<Selector type="value" bind:value={newEntryValue} />
</td>
{/if}
</tr>
{/if}
</table>
<style lang="scss" scoped>
table {
width: 100%;
table-layout: fixed;
border-spacing: 0.5em 0;
th {
text-align: left;
}
td {
font-family: var(--monospace-font);
line-break: anywhere;
border-radius: 4px;
padding: 2px;
&.attr-action {
max-width: 1em;
}
&.formatted,
.formatted {
font-family: var(--default-font);
}
}
.action-col {
width: 1.5em;
}
.attribute-col {
width: 33%;
}
}
</style>