upend/webui/src/lib/components/display/UpObject.svelte

386 lines
9.1 KiB
Svelte

<script lang="ts">
import { createEventDispatcher, getContext } from 'svelte';
import HashBadge from './HashBadge.svelte';
import UpObjectLabel from './UpObjectLabel.svelte';
import UpLink from './UpLink.svelte';
import Icon from '../utils/Icon.svelte';
import { readable, type Readable, writable } from 'svelte/store';
import { notify, UpNotification } from '$lib/notifications';
import IconButton from '../utils/IconButton.svelte';
import { vaultInfo } from '$lib/util/info';
import type { BrowseContext } from '$lib/util/browse';
import { Query, type UpObject } from '@upnd/upend';
import type { ADDRESS_TYPE, EntityInfo } from '@upnd/upend/types';
import { useEntity } from '$lib/entity';
import { i18n } from '$lib/i18n';
import api from '$lib/api';
import { ATTR_IN, ATTR_KEY, ATTR_LABEL, HIER_ROOT_ADDR } from '@upnd/upend/constants';
import { selected } from '../EntitySelect.svelte';
import { Any } from '@upnd/upend/query';
import type { AddressComponents } from '@upnd/upend/wasm';
import Editable from '$lib/components/utils/Editable.svelte';
import type { WidgetChange } from '$lib/types/base';
import type { SelectorValue } from '$lib/components/utils/Selector.svelte';
const dispatch = createEventDispatcher<{ change: WidgetChange; resolved: string[] }>();
export let address: string;
export let labels: string[] | undefined = undefined;
export let link = false;
export let banner = false;
export let resolve: boolean = !(labels || []).length || banner;
export let backpath = 0;
export let select = true;
export let plain = false;
let entity: Readable<UpObject | undefined> = readable(undefined);
let entityInfo: Readable<EntityInfo | AddressComponents | undefined> = writable(undefined);
$: if (resolve) ({ entity, entityInfo } = useEntity(address));
$: if (!resolve)
entityInfo = readable(undefined as undefined | AddressComponents, (set) => {
api.addressToComponents(address).then((info) => {
set(info);
});
});
let hasFile = false;
$: {
if ($entityInfo?.t == 'Hash' && banner) {
fetch(api.getRaw(address), {
method: 'HEAD'
}).then((response) => {
hasFile = response.ok;
});
}
}
// Identification
let inferredIds: string[] = [];
$: inferredIds = $entity?.identify() || [];
let addressIds: string[] = [];
$: resolving = inferredIds.concat(labels || []).length == 0 && !$entity;
$: fetchAddressLabels(address);
async function fetchAddressLabels(address: string) {
addressIds = [];
await Promise.all(
(['Hash', 'Uuid', 'Attribute', 'Url'] as ADDRESS_TYPE[]).map(async (t) => {
if ((await api.getAddress(t)) == address) {
addressIds.push(`∈ ${t}`);
}
})
);
addressIds = addressIds;
}
let displayLabel = address;
$: {
const allLabels = inferredIds.concat(addressIds).concat(labels || []);
displayLabel = Array.from(new Set(allLabels)).join(' | ');
if (!displayLabel && $entityInfo?.t === 'Attribute') {
displayLabel = `${$entityInfo.c}`;
}
displayLabel = displayLabel || address;
}
$: dispatch('resolved', inferredIds);
// Resolved backpath
let resolvedBackpath: string[] = [];
$: if (backpath) resolveBackpath();
async function resolveBackpath() {
resolvedBackpath = [];
let levels = 0;
let current = address;
while (levels < backpath && current !== HIER_ROOT_ADDR) {
const parent = await api.query(Query.matches(`@${current}`, ATTR_IN, Any));
if (parent.entries.length) {
current = parent.entries[0].value.c as string;
const label = await api.query(Query.matches(`@${current}`, ATTR_LABEL, Any));
if (label.entries.length) {
resolvedBackpath = [label.entries[0].value.c as string, ...resolvedBackpath];
}
}
levels++;
}
}
// Navigation highlights
const context = getContext('browse') as BrowseContext | undefined;
const index = context?.index || readable(0);
const addresses = context?.addresses || readable([]);
// Native open
function nativeOpen() {
notify.emit(
'notification',
new UpNotification(
$i18n.t('Opening {{identity}} in a default native application...', {
identity: inferredIds[0] || address
})
)
);
api
.nativeOpen(address)
.then(async (response) => {
if (!response.ok) {
throw new Error(`${response.statusText} - ${await response.text()}`);
}
const rawWarning = response.headers.get('warning');
if (rawWarning) {
const warningText = rawWarning?.split(' ').slice(2).join(' ');
notify.emit('notification', new UpNotification(warningText, 'warning'));
}
})
.catch((err) => {
notify.emit(
'notification',
new UpNotification(
$i18n.t('Failed to open in native application! ({{err}})', { err }),
'error'
)
);
});
}
function onLabelEdit(ev: CustomEvent<SelectorValue>) {
if (ev.detail.t == 'String') {
dispatch('change', { type: 'upsert', attribute: ATTR_LABEL, value: ev.detail });
}
}
</script>
<div
class="upobject"
class:left-active={address == $addresses[$index - 1]}
class:right-active={address == $addresses[$index + 1]}
class:selected={select && $selected.includes(address)}
class:plain
>
<div
class="address"
class:identified={inferredIds.length || addressIds.length || labels?.length}
class:banner
class:show-type={$entityInfo?.t === 'Url' && !addressIds.length}
>
<HashBadge {address} />
<div class="label" class:resolving title={displayLabel}>
<Editable
value={{ t: 'String', c: displayLabel }}
editable={banner}
types={['String']}
on:edit={onLabelEdit}
>
<div class="label-inner">
{#if banner && hasFile}
<UpObjectLabel label={displayLabel} backpath={resolvedBackpath} />
{:else if link}
<UpLink to={{ entity: address }}>
<UpObjectLabel label={displayLabel} backpath={resolvedBackpath} />
</UpLink>
{:else}
<UpObjectLabel label={displayLabel} backpath={resolvedBackpath} />
{/if}
</div>
</Editable>
{#if $entity?.get(ATTR_KEY) && !$entity?.get(ATTR_KEY)?.toString()?.startsWith('TYPE_')}
<div class="key">{$entity.get(ATTR_KEY)}</div>
{/if}
<div class="secondary">
<div class="type">
{$entityInfo?.t}
{#if $entityInfo?.t === 'Url' || $entityInfo?.t === 'Attribute'}
&mdash; {$entityInfo.c}
{/if}
</div>
</div>
</div>
{#if banner}
{#if $entityInfo?.t === 'Attribute'}
<div class="icon">
<UpLink to={{ surfaceAttribute: $entityInfo.c }} title={$i18n.t('Open on surface') || ''}>
<Icon name="cross" />
</UpLink>
</div>
{/if}
{#if $entityInfo?.t == 'Hash'}
<div
class="icon"
title={hasFile ? $i18n.t('Download as file') : $i18n.t('File not present in vault')}
>
<a
class="link-button"
class:disabled={!hasFile}
href="{api.apiUrl}/raw/{address}"
download={inferredIds[0]}
>
<Icon name="download" />
</a>
</div>
{#if $vaultInfo?.desktop && hasFile}
<div class="icon">
<IconButton
name="window-alt"
on:click={nativeOpen}
title={$i18n.t('Open in default application...') || ''}
/>
</div>
{/if}
{/if}
{/if}
</div>
</div>
<style lang="scss">
@use '$lib/styles/colors';
.upobject {
height: 100%;
display: flex;
align-items: stretch;
justify-content: stretch;
border-radius: 4px;
&.left-active {
background: linear-gradient(90deg, var(--color-active) 0%, transparent 100%);
padding: 2px 0 2px 2px;
}
&.right-active {
background: linear-gradient(90deg, transparent 0%, var(--color-active) 100%);
padding: 2px 2px 2px 0;
}
&.plain .address {
border: none;
background: none;
padding: 0;
}
}
.address {
flex-grow: 1;
min-width: 0;
display: flex;
align-items: center;
padding: 0.1em 0.25em;
font-family: var(--monospace-font);
line-break: anywhere;
background: var(--background-lighter);
border: 0.1em solid var(--foreground-lighter);
border-radius: 0.2em;
&.banner {
border: 0.12em solid var(--foreground);
padding: 0.5em 0.25em;
}
&.identified {
font-family: var(--default-font);
font-size: 0.95em;
line-break: auto;
}
.label {
display: flex;
flex-wrap: wrap;
align-items: baseline;
margin-left: 0.25em;
}
.label-inner {
max-width: 100%;
}
&.banner .label {
flex-direction: column;
gap: 0.1em;
}
.secondary {
font-size: 0.66em;
display: none;
opacity: 0.8;
}
.key {
font-family: var(--monospace-font);
color: colors.$yellow;
opacity: 0.8;
&:before {
content: '⌘';
margin-right: 0.1em;
}
}
&.banner .key {
font-size: 0.66em;
}
&:not(.banner) .key {
flex-grow: 1;
text-align: right;
}
&.show-type .secondary,
&.banner .secondary {
display: unset;
}
}
.label {
flex-grow: 1;
min-width: 0;
:global(a) {
text-decoration: none;
}
}
.icon {
margin: 0 0.1em;
}
.resolving {
opacity: 0.7;
}
.link-button {
opacity: 0.66;
transition:
opacity 0.2s,
color 0.2s;
&:hover {
opacity: 1;
color: var(--active-color, var(--primary));
}
}
.upobject {
transition:
margin 0.2s ease,
box-shadow 0.2s ease;
}
.selected {
margin: 0.12rem;
box-shadow: 0 0 0.1rem 0.11rem colors.$red;
}
.disabled {
pointer-events: none;
opacity: 0.7;
}
</style>