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

389 lines
9.3 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 "../../notifications";
import IconButton from "../utils/IconButton.svelte";
import { vaultInfo } from "../../util/info";
import type { BrowseContext } from "../../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 "../../i18n";
import api from "../../lib/api";
import { ATTR_IN, ATTR_LABEL, HIER_ROOT_ADDR } from "@upnd/upend/constants";
import { selected } from "../EntitySelect.svelte";
import { Any } from "@upnd/upend/query";
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> = readable(undefined);
let entityInfo: Readable<EntityInfo> = writable(undefined);
$: if (resolve) ({ entity, entityInfo } = useEntity(address));
$: if (!resolve)
entityInfo = readable(undefined, (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 = []
.concat(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 || undefined;
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()}`);
}
if (response.headers.has("warning")) {
const warningText = response.headers
.get("warning")
.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("KEY")}
<div class="key">{$entity.get("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 "../../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>