367 lines
8.5 KiB
Svelte
367 lines
8.5 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';
|
|
|
|
const dispatch = createEventDispatcher();
|
|
|
|
export let address: string;
|
|
export let labels: string[] | undefined = undefined;
|
|
export let link = false;
|
|
export let banner = false;
|
|
export let resolve = !(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'
|
|
)
|
|
);
|
|
});
|
|
}
|
|
</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="separator" />
|
|
<div class="label" class:resolving title={displayLabel}>
|
|
<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>
|
|
{#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'}
|
|
— {$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 {
|
|
border-radius: 4px;
|
|
|
|
&.left-active {
|
|
background: linear-gradient(90deg, colors.$orange 0%, transparent 100%);
|
|
padding: 2px 0 2px 2px;
|
|
}
|
|
|
|
&.right-active {
|
|
background: linear-gradient(90deg, transparent 0%, colors.$orange 100%);
|
|
padding: 2px 2px 2px 0;
|
|
}
|
|
|
|
&.plain .address {
|
|
border: none;
|
|
background: none;
|
|
padding: 0;
|
|
}
|
|
}
|
|
|
|
.address {
|
|
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;
|
|
}
|
|
|
|
.label-inner {
|
|
max-width: 100%;
|
|
margin-right: 0.25em;
|
|
}
|
|
|
|
&.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;
|
|
}
|
|
}
|
|
|
|
.separator {
|
|
width: 0.5em;
|
|
}
|
|
|
|
.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>
|