upend/webui/src/components/Inspect.svelte

412 lines
10 KiB
Svelte

<script lang="ts">
import AttributeView from "./AttributeView.svelte";
import { query, useEntity } from "../lib/entity";
import UpObject from "./display/UpObject.svelte";
import { UpType } from "../lib/types";
import { createEventDispatcher, setContext } from "svelte";
import { writable } from "svelte/store";
import type { UpEntry } from "upend";
import Spinner from "./utils/Spinner.svelte";
import NotesEditor from "./utils/NotesEditor.svelte";
import type { AttributeChange } from "../types/base";
import Selector from "./utils/Selector.svelte";
import type { IValue } from "upend/types";
import IconButton from "./utils/IconButton.svelte";
import type { BrowseContext } from "../util/browse";
import { useParams } from "svelte-navigator";
import { GROUP_TYPE_ADDR } from "upend/constants";
import { deleteEntry, putEntityAttribute, putEntry } from "../lib/api";
import Icon from "./utils/Icon.svelte";
import BlobViewer from "./display/BlobViewer.svelte";
import { i18n } from "../i18n";
const dispatch = createEventDispatcher();
const params = useParams();
export let address: string;
export let index: number | undefined;
export let detail: boolean;
export let editable = false;
let blobHandled = false;
let indexStore = writable(index);
$: $indexStore = index;
let addressesStore = writable([]);
$: $addressesStore = $params.addresses?.split(",") || [];
setContext("browse", {
index: indexStore,
addresses: addressesStore,
} as BrowseContext);
$: ({ entity, error, revalidate } = useEntity(address));
$: allTypeAddresses = ($entity?.attr["IS"] || []).map((attr) => attr.value.c);
$: allTypeEntries = query(
`(matches (in ${allTypeAddresses.map((addr) => `@${addr}`).join(" ")}) ? ?)`
).result;
let allTypes: { [key: string]: UpType } = {};
$: {
allTypes = {};
($allTypeEntries?.entries || []).forEach((entry) => {
if (allTypes[entry.entity] === undefined) {
allTypes[entry.entity] = new UpType(entry.entity);
}
switch (entry.attribute) {
case "TYPE":
allTypes[entry.entity].name = String(entry.value.c);
break;
case "LBL":
allTypes[entry.entity].label = String(entry.value.c);
break;
case "TYPE_HAS":
allTypes[entry.entity].attributes.push(String(entry.value.c));
break;
}
});
allTypes = allTypes;
}
let typedAttributes = {} as { [key: string]: UpEntry[] };
let untypedAttributes = [] as UpEntry[];
$: {
typedAttributes = {};
untypedAttributes = [];
($entity?.attributes || []).forEach((entry) => {
const entryTypes = Object.entries(allTypes).filter(([_, t]) =>
t.attributes.includes(entry.attribute)
);
if (entryTypes.length > 0) {
entryTypes.forEach(([addr, _]) => {
if (typedAttributes[addr] == undefined) {
typedAttributes[addr] = [];
}
typedAttributes[addr].push(entry);
});
} else {
untypedAttributes.push(entry);
}
});
typedAttributes = typedAttributes;
untypedAttributes = untypedAttributes;
}
$: filteredUntypedAttributes = untypedAttributes.filter(
(entry) =>
![
"IS",
"LBL",
"NOTE",
"LAST_VISITED",
"NUM_VISITED",
"LAST_ATTRIBUTE_WIDGET",
].includes(entry.attribute)
);
$: currentUntypedAttributes = editable
? untypedAttributes
: filteredUntypedAttributes;
$: currentBacklinks =
(editable
? $entity?.backlinks
: $entity?.backlinks.filter(
(entry) => !["HAS"].includes(entry.attribute)
)) || [];
$: groups = ($entity?.backlinks || [])
.filter((e) => e.attribute === "HAS")
.map((e) => [e.address, e.entity])
.sort(); // TODO
async function onChange(ev: CustomEvent<AttributeChange>) {
const change = ev.detail;
switch (change.type) {
case "create":
await putEntry({
entity: address,
attribute: change.attribute,
value: change.value,
});
break;
case "delete":
await deleteEntry(change.address);
break;
case "update":
await putEntityAttribute(address, change.attribute, change.value);
break;
default:
console.error("Unimplemented AttributeChange", change);
return;
}
revalidate();
}
let identities = [address];
function onResolved(ev: CustomEvent<string[]>) {
identities = ev.detail;
dispatch("resolved", ev.detail);
}
let groupToAdd: IValue | undefined;
async function addGroup() {
if (!groupToAdd) {
return;
}
await putEntry([
{
entity: String(groupToAdd.c),
attribute: "HAS",
value: {
t: "Address",
c: address,
},
},
{
entity: String(groupToAdd.c),
attribute: "IS",
value: {
t: "Address",
c: GROUP_TYPE_ADDR,
},
},
]);
revalidate();
groupToAdd = undefined;
}
async function removeGroup(groupAddress: string) {
await deleteEntry(groupAddress);
revalidate();
}
async function deleteObject() {
if (confirm(`${$i18n.t("Really delete")} "${identities.join(" | ")}"?`)) {
await deleteEntry(address);
dispatch("close");
}
}
async function onAttributeWidgetSwitch(ev: CustomEvent<string>) {
await putEntityAttribute(address, "LAST_ATTRIBUTE_WIDGET", {
t: "String",
c: ev.detail,
});
}
$: entity.subscribe(async (object) => {
if (object && object.listing.entries.length) {
await putEntityAttribute(object.address, "LAST_VISITED", {
t: "Number",
c: new Date().getTime() / 1000,
});
await putEntityAttribute(object.address, "NUM_VISITED", {
t: "Number",
c: (parseInt(String(object.get("NUM_VISITED"))) || 0) + 1,
});
}
});
</script>
<div class="inspect" class:detail class:blob={blobHandled}>
<header>
<h2>
{#if $entity}
<UpObject banner {address} on:resolved={onResolved} />
{:else}
<Spinner centered />
{/if}
</h2>
</header>
<div class="main-content">
<div class="detail-col">
{#if groups?.length || editable}
<section class="groups labelborder">
<header><h3>{$i18n.t("Groups")}</h3></header>
<div class="content">
{#each groups as [entryAddress, address]}
<div class="group">
<UpObject {address} link />
{#if editable}
<IconButton
name="x-circle"
on:click={() => removeGroup(entryAddress)}
/>
{/if}
</div>
{/each}
{#if editable}
<div class="selector">
<Selector
type="value"
valueTypes={["Address"]}
bind:value={groupToAdd}
on:input={addGroup}
placeholder="Choose an entity..."
/>
</div>
{/if}
</div>
</section>
{/if}
<div class="blob-viewer">
<BlobViewer
{address}
{editable}
{detail}
on:handled={(ev) => (blobHandled = ev.detail)}
/>
</div>
<NotesEditor {address} {editable} on:change={onChange} />
{#if !$error}
{#if Boolean($allTypeEntries)}
<div class="attributes">
{#each Object.entries(typedAttributes) as [typeAddr, entries] (typeAddr)}
<AttributeView
{entries}
type={allTypes[typeAddr]}
{editable}
on:change={onChange}
initialWidget={String($entity.get("LAST_ATTRIBUTE_WIDGET"))}
on:widgetSwitched={onAttributeWidgetSwitch}
/>
{/each}
{#if currentUntypedAttributes.length > 0 || editable}
<AttributeView
title={$i18n.t("Other attributes")}
{editable}
entries={currentUntypedAttributes}
on:change={onChange}
/>
{/if}
{#if currentBacklinks.length > 0}
<AttributeView
title={`${$i18n.t("Referred to")} (${
$entity.backlinks.length
})`}
entries={currentBacklinks}
reverse
on:change={onChange}
/>
{/if}
</div>
{#if editable}
<div class="button" on:click={deleteObject}>
<Icon name="trash" />
</div>
{/if}
{:else}
<Spinner centered />
{/if}
{:else}
<div class="error">
{$error}
</div>
{/if}
</div>
</div>
</div>
<style scoped lang="scss">
@use "./util";
header h2 {
margin-bottom: 0;
}
.inspect,
.main-content {
flex: auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
min-height: 0;
}
.groups {
margin: 0.25rem 0;
.content {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 0.5rem;
align-items: center;
}
.group {
display: inline-flex;
align-items: center;
}
.selector {
width: 100%;
}
}
.attributes {
flex: auto;
height: 0; // https://stackoverflow.com/a/14964944
min-height: 12em;
overflow-y: auto;
}
@media screen and (max-height: 1080px) {
.main-content {
overflow-y: auto;
// min-height: 0;
}
.attributes {
height: unset;
min-height: unset;
overflow-y: unset;
}
}
.inspect.detail {
.main-content {
position: relative;
flex-direction: row;
justify-content: end;
}
.blob-viewer {
width: 73%;
position: absolute;
left: 1%;
top: 0;
}
.detail-col {
width: 25%;
}
&.blob {
.detail-col {
flex-grow: 0;
}
}
}
.main-content .detail-col {
display: flex;
flex-direction: column;
flex-grow: 1;
}
.error {
color: red;
}
</style>