Merge branch 'feat/modeless-webui'
ci/woodpecker/push/woodpecker Pipeline failed
Details
ci/woodpecker/push/woodpecker Pipeline failed
Details
commit
769b62d02e
|
@ -30,6 +30,9 @@
|
|||
dispatch("input", ev.detail);
|
||||
editable = false;
|
||||
}}
|
||||
on:focus={(ev) => {
|
||||
if (!ev.detail) editable = false;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
@ -47,7 +50,10 @@
|
|||
|
||||
cursor: pointer;
|
||||
|
||||
transition: opacity 0.3s, width 0.5s, min-width 0.5s;
|
||||
transition:
|
||||
opacity 0.3s,
|
||||
width 0.5s,
|
||||
min-width 0.5s;
|
||||
|
||||
opacity: 0.4;
|
||||
width: 48px;
|
||||
|
|
|
@ -10,7 +10,6 @@
|
|||
export let index: number;
|
||||
export let only: boolean;
|
||||
|
||||
let editable = false;
|
||||
let detail = only;
|
||||
let detailChanged = false;
|
||||
$: if (!detailChanged) detail = only;
|
||||
|
@ -47,14 +46,10 @@
|
|||
</script>
|
||||
|
||||
<div class="browse-column" class:detail>
|
||||
<div class="view" class:editable style="--width: {width}px">
|
||||
<div class="view" style="--width: {width}px">
|
||||
<header>
|
||||
<IconButton
|
||||
name="pencil"
|
||||
on:click={() => (editable = !editable)}
|
||||
active={editable}
|
||||
>
|
||||
Edit
|
||||
<IconButton name="link" on:click={() => visit()} disabled={only}>
|
||||
Detach
|
||||
</IconButton>
|
||||
<IconButton
|
||||
name={detail ? "zoom-out" : "zoom-in"}
|
||||
|
@ -66,9 +61,6 @@
|
|||
>
|
||||
Detail
|
||||
</IconButton>
|
||||
<IconButton name="link" on:click={() => visit()} disabled={only}>
|
||||
Detach
|
||||
</IconButton>
|
||||
<IconButton
|
||||
name="x-circle"
|
||||
on:click={() => dispatch("close")}
|
||||
|
@ -77,14 +69,7 @@
|
|||
Close
|
||||
</IconButton>
|
||||
</header>
|
||||
<Inspect
|
||||
{address}
|
||||
editable={editable || false}
|
||||
{index}
|
||||
{detail}
|
||||
on:resolved
|
||||
on:close
|
||||
/>
|
||||
<Inspect {address} {index} {detail} on:resolved on:close />
|
||||
</div>
|
||||
<div class="resizeHandle" on:mousedown|preventDefault={drag} />
|
||||
</div>
|
||||
|
@ -123,10 +108,6 @@
|
|||
// transition: min-width 0.2s, max-width 0.2s;
|
||||
// TODO - container has nowhere to scroll, breaking `detail` scroll
|
||||
|
||||
&.editable {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
header {
|
||||
font-size: 20px;
|
||||
position: relative;
|
||||
|
|
|
@ -7,7 +7,11 @@
|
|||
export interface Widget {
|
||||
name: string;
|
||||
icon?: string;
|
||||
components: (entries: UpEntry[]) => Array<WidgetComponent>;
|
||||
components: (input: {
|
||||
entries: UpEntry[];
|
||||
group?: string;
|
||||
address?: string;
|
||||
}) => Array<WidgetComponent>;
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -18,6 +22,7 @@
|
|||
import IconButton from "./utils/IconButton.svelte";
|
||||
import { createEventDispatcher, type ComponentType } from "svelte";
|
||||
import UpObject from "./display/UpObject.svelte";
|
||||
import LabelBorder from "./utils/LabelBorder.svelte";
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let entries: UpEntry[];
|
||||
|
@ -25,9 +30,9 @@
|
|||
export let initialWidget: string | undefined = undefined;
|
||||
export let title: string | undefined = undefined;
|
||||
export let group: string | undefined = undefined;
|
||||
export let address: string | undefined = undefined;
|
||||
export let icon: string | undefined = undefined;
|
||||
export let highlighted = false;
|
||||
export let editable = false;
|
||||
|
||||
let currentWidget: string | undefined;
|
||||
|
||||
|
@ -42,7 +47,7 @@
|
|||
{
|
||||
name: "Entry List",
|
||||
icon: "table",
|
||||
components: (entries) => [
|
||||
components: ({ entries }) => [
|
||||
{
|
||||
component: EntryList,
|
||||
props: { entries, columns: "entity, attribute, value" },
|
||||
|
@ -66,13 +71,13 @@
|
|||
$: {
|
||||
components = availableWidgets
|
||||
.find((w) => w.name === currentWidget)
|
||||
.components(entries);
|
||||
.components({ entries, group, address });
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="entry-view labelborder" class:highlighted>
|
||||
<header>
|
||||
<h3>
|
||||
<LabelBorder>
|
||||
<svelte:fragment slot="header-full">
|
||||
<h3 class:highlighted>
|
||||
{#if group}
|
||||
{#if icon}
|
||||
<div class="icon">
|
||||
|
@ -90,7 +95,7 @@
|
|||
{/if}
|
||||
</h3>
|
||||
|
||||
{#if currentWidget && (availableWidgets.length > 1 || editable)}
|
||||
{#if currentWidget && availableWidgets.length > 1}
|
||||
<div class="views">
|
||||
{#each availableWidgets as widget (widget.name)}
|
||||
<IconButton
|
||||
|
@ -105,36 +110,30 @@
|
|||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
<div class="content">
|
||||
{#each components as component}
|
||||
<svelte:component
|
||||
this={component.component}
|
||||
{...component.props || {}}
|
||||
{editable}
|
||||
on:change
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
</svelte:fragment>
|
||||
{#each components as component}
|
||||
<svelte:component
|
||||
this={component.component}
|
||||
{...component.props || {}}
|
||||
on:change
|
||||
/>
|
||||
{/each}
|
||||
</LabelBorder>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "./util";
|
||||
.icon {
|
||||
display: inline-block;
|
||||
font-size: 1.25em;
|
||||
margin-top: -0.3em;
|
||||
position: relative;
|
||||
bottom: -2px;
|
||||
}
|
||||
|
||||
section.entry-view {
|
||||
.icon {
|
||||
display: inline-block;
|
||||
font-size: 1.25em;
|
||||
margin-top: -0.3em;
|
||||
position: relative;
|
||||
bottom: -2px;
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
transition: text-shadow 0.2s;
|
||||
|
||||
h3 {
|
||||
transition: text-shadow 0.2s;
|
||||
}
|
||||
|
||||
&.highlighted h3 {
|
||||
&.highlighted {
|
||||
text-shadow: #cb4b16 0 0 0.5em;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,20 +7,20 @@
|
|||
import type { UpEntry } from "upend";
|
||||
import Spinner from "./utils/Spinner.svelte";
|
||||
import NotesEditor from "./utils/NotesEditor.svelte";
|
||||
import type { AttributeChange } from "../types/base";
|
||||
import type { WidgetChange } from "../types/base";
|
||||
import type { EntityInfo } from "upend/types";
|
||||
import IconButton from "./utils/IconButton.svelte";
|
||||
import type { BrowseContext } from "../util/browse";
|
||||
import { Link, useParams } from "svelte-navigator";
|
||||
import Icon from "./utils/Icon.svelte";
|
||||
import BlobViewer from "./display/BlobViewer.svelte";
|
||||
import { i18n } from "../i18n";
|
||||
import EntryList from "./widgets/EntryList.svelte";
|
||||
import api from "../lib/api";
|
||||
import Gallery from "./widgets/Gallery.svelte";
|
||||
import EntityList from "./widgets/EntityList.svelte";
|
||||
import { ATTR_IN, ATTR_LABEL, ATTR_KEY, ATTR_OF } from "upend/constants";
|
||||
import InspectGroups from "./InspectGroups.svelte";
|
||||
import InspectTypeEditor from "./InspectTypeEditor.svelte";
|
||||
import LabelBorder from "./utils/LabelBorder.svelte";
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const params = useParams();
|
||||
|
@ -28,7 +28,6 @@
|
|||
export let address: string;
|
||||
export let index: number | undefined;
|
||||
export let detail: boolean;
|
||||
export let editable = false;
|
||||
let showAsEntries = false;
|
||||
let highlightedType: string | undefined;
|
||||
|
||||
|
@ -128,11 +127,11 @@
|
|||
return result;
|
||||
}
|
||||
|
||||
let untypedAttributes = [] as UpEntry[];
|
||||
let untypedProperties = [] as UpEntry[];
|
||||
let untypedLinks = [] as UpEntry[];
|
||||
|
||||
$: {
|
||||
untypedAttributes = [];
|
||||
untypedProperties = [];
|
||||
untypedLinks = [];
|
||||
|
||||
($entity?.attributes || []).forEach((entry) => {
|
||||
|
@ -143,16 +142,16 @@
|
|||
if (entry.value.t === "Address") {
|
||||
untypedLinks.push(entry);
|
||||
} else {
|
||||
untypedAttributes.push(entry);
|
||||
untypedProperties.push(entry);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
untypedAttributes = untypedAttributes;
|
||||
untypedProperties = untypedProperties;
|
||||
untypedLinks = untypedLinks;
|
||||
}
|
||||
|
||||
$: filteredUntypedAttributes = untypedAttributes.filter(
|
||||
$: filteredUntypedProperties = untypedProperties.filter(
|
||||
(entry) =>
|
||||
![
|
||||
ATTR_LABEL,
|
||||
|
@ -165,22 +164,18 @@
|
|||
].includes(entry.attribute),
|
||||
);
|
||||
|
||||
$: currentUntypedAttributes = editable
|
||||
? untypedAttributes
|
||||
: filteredUntypedAttributes;
|
||||
$: currentUntypedProperties = filteredUntypedProperties;
|
||||
|
||||
$: filteredUntypedLinks = untypedLinks.filter(
|
||||
(entry) => ![ATTR_IN, ATTR_OF].includes(entry.attribute),
|
||||
);
|
||||
|
||||
$: currentUntypedLinks = editable ? untypedLinks : filteredUntypedLinks;
|
||||
$: currentUntypedLinks = filteredUntypedLinks;
|
||||
|
||||
$: currentBacklinks =
|
||||
(editable
|
||||
? $entity?.backlinks
|
||||
: $entity?.backlinks.filter(
|
||||
(entry) => ![ATTR_IN, ATTR_OF].includes(entry.attribute),
|
||||
)) || [];
|
||||
$entity?.backlinks.filter(
|
||||
(entry) => ![ATTR_IN, ATTR_OF].includes(entry.attribute),
|
||||
) || [];
|
||||
|
||||
$: tagged = $entity?.attr[`~${ATTR_IN}`] || [];
|
||||
|
||||
|
@ -193,7 +188,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function onChange(ev: CustomEvent<AttributeChange>) {
|
||||
async function onChange(ev: CustomEvent<WidgetChange>) {
|
||||
const change = ev.detail;
|
||||
switch (change.type) {
|
||||
case "create":
|
||||
|
@ -209,6 +204,27 @@
|
|||
case "update":
|
||||
await api.putEntityAttribute(address, change.attribute, change.value);
|
||||
break;
|
||||
case "entry-add":
|
||||
await api.putEntry({
|
||||
entity: address,
|
||||
attribute: ATTR_IN,
|
||||
value: { t: "Address", c: change.address },
|
||||
});
|
||||
break;
|
||||
case "entry-delete": {
|
||||
const inEntry = $entity?.attr[`~${ATTR_IN}`].find(
|
||||
(e) => e.entity === change.address,
|
||||
);
|
||||
if (inEntry) {
|
||||
await api.deleteEntry(inEntry.address);
|
||||
} else {
|
||||
console.warn(
|
||||
"Couldn't find IN entry for entity %s?!",
|
||||
change.address,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.error("Unimplemented AttributeChange", change);
|
||||
return;
|
||||
|
@ -234,7 +250,7 @@
|
|||
{
|
||||
name: "List",
|
||||
icon: "list-check",
|
||||
components: (entries) => [
|
||||
components: ({ entries }) => [
|
||||
{
|
||||
component: EntryList,
|
||||
props: {
|
||||
|
@ -250,23 +266,25 @@
|
|||
{
|
||||
name: "List",
|
||||
icon: "list-check",
|
||||
components: (entries) => [
|
||||
components: ({ entries, group }) => [
|
||||
{
|
||||
component: EntryList,
|
||||
props: {
|
||||
entries,
|
||||
columns: "attribute, value",
|
||||
attributes: $allTypes[group]?.attributes || [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Gallery",
|
||||
name: "EntityList",
|
||||
icon: "image",
|
||||
components: (entries) => [
|
||||
components: ({ entries, address }) => [
|
||||
{
|
||||
component: Gallery,
|
||||
component: EntityList,
|
||||
props: {
|
||||
address,
|
||||
entities: entries
|
||||
.filter((e) => e.value.t == "Address")
|
||||
.map((e) => e.value.c),
|
||||
|
@ -281,10 +299,11 @@
|
|||
{
|
||||
name: "List",
|
||||
icon: "list-check",
|
||||
components: (entries) => [
|
||||
components: ({ entries, address }) => [
|
||||
{
|
||||
component: Gallery,
|
||||
component: EntityList,
|
||||
props: {
|
||||
address,
|
||||
entities: entries.map((e) => e.entity),
|
||||
thumbnails: false,
|
||||
},
|
||||
|
@ -292,12 +311,13 @@
|
|||
],
|
||||
},
|
||||
{
|
||||
name: "Gallery",
|
||||
name: "EntityList",
|
||||
icon: "image",
|
||||
components: (entries) => [
|
||||
components: ({ entries, address }) => [
|
||||
{
|
||||
component: Gallery,
|
||||
component: EntityList,
|
||||
props: {
|
||||
address,
|
||||
entities: entries.map((e) => e.entity),
|
||||
thumbnails: true,
|
||||
},
|
||||
|
@ -347,56 +367,50 @@
|
|||
<div class="blob-viewer">
|
||||
<BlobViewer
|
||||
{address}
|
||||
{editable}
|
||||
{detail}
|
||||
on:handled={(ev) => (blobHandled = ev.detail)}
|
||||
/>
|
||||
</div>
|
||||
<NotesEditor {address} {editable} on:change={onChange} />
|
||||
<NotesEditor {address} on:change={onChange} />
|
||||
{#if !$error}
|
||||
<InspectGroups
|
||||
{entity}
|
||||
{editable}
|
||||
on:highlighted={(ev) => (highlightedType = ev.detail)}
|
||||
on:change={() => revalidate()}
|
||||
/>
|
||||
<div class="attributes">
|
||||
<InspectTypeEditor
|
||||
{entity}
|
||||
{editable}
|
||||
on:change={() => revalidate()}
|
||||
/>
|
||||
<div class="properties">
|
||||
<InspectTypeEditor {entity} on:change={() => revalidate()} />
|
||||
{#each Object.entries($allTypes) as [typeAddr, { labels, attributes }]}
|
||||
<EntryView
|
||||
entries={($entity?.attributes || []).filter((e) =>
|
||||
attributes.includes(e.attribute),
|
||||
)}
|
||||
{editable}
|
||||
widgets={linkWidgets}
|
||||
on:change={onChange}
|
||||
highlighted={highlightedType == typeAddr}
|
||||
title={labels.join(" | ")}
|
||||
group={typeAddr}
|
||||
{address}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#if currentUntypedAttributes.length > 0 || editable}
|
||||
{#if currentUntypedProperties.length > 0}
|
||||
<EntryView
|
||||
title={$i18n.t("Other Attributes")}
|
||||
{editable}
|
||||
title={$i18n.t("Other Properties")}
|
||||
widgets={attributeWidgets}
|
||||
entries={currentUntypedAttributes}
|
||||
entries={currentUntypedProperties}
|
||||
on:change={onChange}
|
||||
{address}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if currentUntypedLinks.length > 0 || editable}
|
||||
{#if currentUntypedLinks.length > 0}
|
||||
<EntryView
|
||||
title={$i18n.t("Links")}
|
||||
{editable}
|
||||
widgets={linkWidgets}
|
||||
entries={currentUntypedLinks}
|
||||
on:change={onChange}
|
||||
{address}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
|
@ -406,6 +420,7 @@
|
|||
widgets={taggedWidgets}
|
||||
entries={tagged}
|
||||
on:change={onChange}
|
||||
{address}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
|
@ -414,6 +429,7 @@
|
|||
title={`${$i18n.t("Referred to")} (${currentBacklinks.length})`}
|
||||
entries={currentBacklinks}
|
||||
on:change={onChange}
|
||||
{address}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
|
@ -426,28 +442,19 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<section class="labelborder">
|
||||
<header>
|
||||
<h3>{$i18n.t("Used")} ({attributesUsed.length})</h3>
|
||||
</header>
|
||||
<div class="content">
|
||||
<EntryList
|
||||
columns="entity,value"
|
||||
columnWidths={["auto", "33%"]}
|
||||
entries={attributesUsed}
|
||||
orderByValue
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<LabelBorder>
|
||||
<span slot="header"
|
||||
>{$i18n.t("Used")} ({attributesUsed.length})</span
|
||||
>
|
||||
<EntryList
|
||||
columns="entity,value"
|
||||
columnWidths={["auto", "33%"]}
|
||||
entries={attributesUsed}
|
||||
orderByValue
|
||||
/>
|
||||
</LabelBorder>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if editable}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div class="button" on:click={deleteObject}>
|
||||
<Icon name="trash" />
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="error">
|
||||
{$error}
|
||||
|
@ -459,7 +466,6 @@
|
|||
<div class="entries">
|
||||
<h2>{$i18n.t("Attributes")}</h2>
|
||||
<EntryList
|
||||
{editable}
|
||||
entries={$entity.attributes}
|
||||
columns={detail
|
||||
? "timestamp, provenance, attribute, value"
|
||||
|
@ -477,16 +483,23 @@
|
|||
<div class="footer">
|
||||
<IconButton
|
||||
name="detail"
|
||||
title="Show as entries"
|
||||
title={$i18n.t("Show as entries")}
|
||||
active={showAsEntries}
|
||||
on:click={() => (showAsEntries = !showAsEntries)}
|
||||
/>
|
||||
</div>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<IconButton
|
||||
name="trash"
|
||||
outline
|
||||
subdued
|
||||
color="#dc322f"
|
||||
on:click={deleteObject}
|
||||
title={$i18n.t("Delete object")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "./util";
|
||||
|
||||
header h2 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
@ -500,7 +513,7 @@
|
|||
min-height: 0;
|
||||
}
|
||||
|
||||
.attributes {
|
||||
.properties {
|
||||
flex: auto;
|
||||
height: 0; // https://stackoverflow.com/a/14964944
|
||||
min-height: 12em;
|
||||
|
@ -513,7 +526,7 @@
|
|||
// min-height: 0;
|
||||
}
|
||||
|
||||
.attributes {
|
||||
.properties {
|
||||
height: unset;
|
||||
min-height: unset;
|
||||
overflow-y: unset;
|
||||
|
@ -556,6 +569,7 @@
|
|||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
}
|
||||
|
|
|
@ -9,13 +9,18 @@
|
|||
import { i18n } from "../i18n";
|
||||
import type { UpObject } from "upend";
|
||||
import type { Readable } from "svelte/store";
|
||||
import LabelBorder from "./utils/LabelBorder.svelte";
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let entity: Readable<UpObject>;
|
||||
export let editable = false;
|
||||
|
||||
let adding = false;
|
||||
let groupSelector: Selector;
|
||||
|
||||
$: if (adding && groupSelector) groupSelector.focus();
|
||||
|
||||
$: groups = ($entity?.attr[ATTR_IN] || []).map(
|
||||
(e) => [e.value.c as string, e.address] as [string, string]
|
||||
(e) => [e.value.c as string, e.address] as [string, string],
|
||||
);
|
||||
|
||||
let groupToAdd: IValue | undefined;
|
||||
|
@ -38,64 +43,94 @@
|
|||
}
|
||||
|
||||
async function removeGroup(groupAddress: string) {
|
||||
await api.deleteEntry(groupAddress);
|
||||
dispatch("change");
|
||||
if (confirm($i18n.t("Are you sure you want to remove this group?"))) {
|
||||
await api.deleteEntry(groupAddress);
|
||||
dispatch("change");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if groups.length || editable}
|
||||
<section class="groups labelborder">
|
||||
<header><h3>{$i18n.t("Groups")}</h3></header>
|
||||
<div class="content">
|
||||
<LabelBorder hide={groups.length === 0}>
|
||||
<span slot="header">{$i18n.t("Groups")}</span>
|
||||
|
||||
{#if adding}
|
||||
<div class="selector">
|
||||
<Selector
|
||||
bind:this={groupSelector}
|
||||
type="value"
|
||||
valueTypes={["Address"]}
|
||||
bind:value={groupToAdd}
|
||||
on:input={addGroup}
|
||||
on:focus={(ev) => {
|
||||
if (!ev.detail) adding = false;
|
||||
}}
|
||||
placeholder={$i18n.t("Choose an entity...")}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="body">
|
||||
<div class="group-list">
|
||||
{#each groups as [groupAddress, groupEntryAddress]}
|
||||
<div
|
||||
class="tag"
|
||||
class="group"
|
||||
on:mouseenter={() => dispatch("highlighted", groupAddress)}
|
||||
on:mouseleave={() => dispatch("highlighted", undefined)}
|
||||
>
|
||||
<UpObjectDisplay address={groupAddress} link />
|
||||
{#if editable}
|
||||
<IconButton
|
||||
name="x-circle"
|
||||
on:click={() => removeGroup(groupEntryAddress)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{#if editable}
|
||||
<div class="selector">
|
||||
<Selector
|
||||
type="value"
|
||||
valueTypes={["Address"]}
|
||||
bind:value={groupToAdd}
|
||||
on:input={addGroup}
|
||||
placeholder="Choose an entity..."
|
||||
<IconButton
|
||||
subdued
|
||||
name="x-circle"
|
||||
on:click={() => removeGroup(groupEntryAddress)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="no-groups">
|
||||
{$i18n.t("Object is not in any groups.")}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{#if !adding}
|
||||
<div class="add-button">
|
||||
<IconButton
|
||||
outline
|
||||
small
|
||||
name="folder-plus"
|
||||
on:click={() => (adding = true)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</LabelBorder>
|
||||
|
||||
<style lang="scss">
|
||||
@use "./util";
|
||||
.group-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem 0.2rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.body {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
|
||||
.selector {
|
||||
width: 100%;
|
||||
.group-list {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.selector {
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.no-groups {
|
||||
opacity: 0.66;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -8,10 +8,15 @@
|
|||
import type { Readable } from "svelte/store";
|
||||
import { ATTR_OF } from "upend/constants";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import LabelBorder from "./utils/LabelBorder.svelte";
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let entity: Readable<UpObject>;
|
||||
export let editable = false;
|
||||
|
||||
let adding = false;
|
||||
let typeSelector: Selector;
|
||||
|
||||
$: if (adding && typeSelector) typeSelector.focus();
|
||||
|
||||
$: typeEntries = $entity?.attr[`~${ATTR_OF}`] || [];
|
||||
|
||||
|
@ -45,10 +50,24 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
{#if typeEntries.length || (editable && $entity?.attr["~IN"]?.length)}
|
||||
<section class="labelborder">
|
||||
<header><h3>{$i18n.t("Type Attributes")}</h3></header>
|
||||
<div class="content">
|
||||
{#if typeEntries.length || $entity?.attr["~IN"]?.length}
|
||||
<LabelBorder hide={typeEntries.length === 0}>
|
||||
<span slot="header">{$i18n.t("Type Attributes")}</span>
|
||||
{#if adding}
|
||||
<div class="selector">
|
||||
<Selector
|
||||
bind:this={typeSelector}
|
||||
type="attribute"
|
||||
bind:attribute={attributeToAdd}
|
||||
on:input={add}
|
||||
placeholder={$i18n.t("Assign an attribute to this type...")}
|
||||
on:focus={(ev) => {
|
||||
if (!ev.detail) adding = false;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="body">
|
||||
<ul class="attributes">
|
||||
{#each typeEntries as typeEntry}
|
||||
<li class="attribute">
|
||||
|
@ -56,31 +75,28 @@
|
|||
<UpObjectDisplay address={typeEntry.entity} link />
|
||||
</div>
|
||||
<div class="controls">
|
||||
{#if editable}
|
||||
<IconButton
|
||||
name="x-circle"
|
||||
on:click={() => remove(typeEntry)}
|
||||
/>
|
||||
{/if}
|
||||
<IconButton name="x-circle" on:click={() => remove(typeEntry)} />
|
||||
</div>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="no-attributes">
|
||||
{$i18n.t("No attributes assigned to this type.")}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if editable}
|
||||
<Selector
|
||||
type="attribute"
|
||||
bind:attribute={attributeToAdd}
|
||||
on:input={add}
|
||||
placeholder={$i18n.t("Assign an attribute to this type...")}
|
||||
<div class="add-button">
|
||||
<IconButton
|
||||
outline
|
||||
small
|
||||
name="plus-circle"
|
||||
on:click={() => (adding = true)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</LabelBorder>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
@use "./util";
|
||||
|
||||
.attributes {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
@ -92,6 +108,24 @@
|
|||
display: flex;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
|
||||
.attributes {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.selector {
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.no-attributes {
|
||||
opacity: 0.66;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
|
|
|
@ -15,7 +15,6 @@
|
|||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let address: string;
|
||||
export let editable: boolean;
|
||||
export let detail: boolean;
|
||||
|
||||
let handled = false;
|
||||
|
@ -47,13 +46,13 @@
|
|||
</div>
|
||||
{/if}
|
||||
{#if types.audio}
|
||||
<AudioViewer {address} {detail} {editable} />
|
||||
<AudioViewer {address} {detail} />
|
||||
{/if}
|
||||
{#if types.video}
|
||||
<VideoViewer detail {address} />
|
||||
{/if}
|
||||
{#if types.image}
|
||||
<ImageViewer {address} {editable} {detail} />
|
||||
<ImageViewer {address} {detail} />
|
||||
{/if}
|
||||
{#if types.pdf}
|
||||
<iframe
|
||||
|
|
|
@ -35,13 +35,16 @@
|
|||
if ($location.pathname.startsWith("/browse")) {
|
||||
let newAddresses = $addresses.concat();
|
||||
|
||||
const routerTo =
|
||||
"/browse/" +
|
||||
(ev.shiftKey ? newAddresses : newAddresses.slice(0, $index + 1))
|
||||
.concat([targetHref])
|
||||
.join(",");
|
||||
// Shift to append to the end instead of replacing
|
||||
if (ev.shiftKey) {
|
||||
newAddresses = newAddresses.concat([targetHref]);
|
||||
} else {
|
||||
if ($addresses[$index] !== targetHref) {
|
||||
newAddresses = newAddresses.slice(0, $index + 1).concat([targetHref]);
|
||||
}
|
||||
}
|
||||
|
||||
navigate(routerTo);
|
||||
navigate("/browse/" + newAddresses.join(","));
|
||||
return true;
|
||||
} else {
|
||||
navigate(`/browse/${targetHref}`);
|
||||
|
|
|
@ -10,12 +10,17 @@
|
|||
import Selector from "../../utils/Selector.svelte";
|
||||
import UpObject from "../../display/UpObject.svelte";
|
||||
import Spinner from "../../utils/Spinner.svelte";
|
||||
import IconButton from "../../../components/utils/IconButton.svelte";
|
||||
import LabelBorder from "../../../components/utils/LabelBorder.svelte";
|
||||
import { i18n } from "../../../i18n";
|
||||
import { ATTR_LABEL } from "upend/constants";
|
||||
import debug from "debug";
|
||||
const dbg = debug("kestrel:AudioViewer");
|
||||
|
||||
export let address: string;
|
||||
export let detail: boolean;
|
||||
export let editable: boolean;
|
||||
|
||||
let editable = false;
|
||||
|
||||
let containerEl: HTMLDivElement;
|
||||
let timelineEl: HTMLDivElement;
|
||||
|
@ -24,7 +29,7 @@
|
|||
let wavesurfer: WaveSurfer;
|
||||
|
||||
// Zoom handling
|
||||
$: zoom = detail ? 1 : undefined;
|
||||
let zoom = 1;
|
||||
const setZoom = throttle((level: number) => {
|
||||
wavesurfer.zoom(level);
|
||||
}, 250);
|
||||
|
@ -167,14 +172,14 @@
|
|||
});
|
||||
|
||||
wavesurfer.on("ready", () => {
|
||||
console.debug("wavesurfer ready");
|
||||
dbg("wavesurfer ready");
|
||||
|
||||
loaded = true;
|
||||
loadAnnotations();
|
||||
});
|
||||
|
||||
wavesurfer.on("region-created", async (region: UpRegion) => {
|
||||
console.debug("wavesurfer region-created", region);
|
||||
dbg("wavesurfer region-created", region);
|
||||
|
||||
// Updating here, because if `drag` and `resize` are passed during adding,
|
||||
// updating no longer works.
|
||||
|
@ -188,33 +193,33 @@
|
|||
});
|
||||
|
||||
wavesurfer.on("region-updated", (region: UpRegion) => {
|
||||
// console.debug("wavesurfer region-updated", region);
|
||||
// dbg("wavesurfer region-updated", region);
|
||||
|
||||
currentAnnotation = region;
|
||||
});
|
||||
|
||||
wavesurfer.on("region-update-end", (region: UpRegion) => {
|
||||
console.debug("wavesurfer region-update-end", region);
|
||||
dbg("wavesurfer region-update-end", region);
|
||||
|
||||
updateAnnotation(region);
|
||||
currentAnnotation = region;
|
||||
});
|
||||
|
||||
wavesurfer.on("region-removed", (region: UpRegion) => {
|
||||
console.debug("wavesurfer region-removed", region);
|
||||
dbg("wavesurfer region-removed", region);
|
||||
|
||||
currentAnnotation = null;
|
||||
deleteAnnotation(region);
|
||||
});
|
||||
|
||||
// wavesurfer.on("region-in", (region: UpRegion) => {
|
||||
// console.debug("wavesurfer region-in", region);
|
||||
// dbg("wavesurfer region-in", region);
|
||||
|
||||
// currentAnnotation = region;
|
||||
// });
|
||||
|
||||
// wavesurfer.on("region-out", (region: UpRegion) => {
|
||||
// console.debug("wavesurfer region-out", region);
|
||||
// dbg("wavesurfer region-out", region);
|
||||
|
||||
// if (currentAnnotation.id === region.id) {
|
||||
// currentAnnotation = undefined;
|
||||
|
@ -222,13 +227,13 @@
|
|||
// });
|
||||
|
||||
wavesurfer.on("region-click", (region: UpRegion, _ev: MouseEvent) => {
|
||||
console.debug("wavesurfer region-click", region);
|
||||
dbg("wavesurfer region-click", region);
|
||||
|
||||
currentAnnotation = region;
|
||||
});
|
||||
|
||||
wavesurfer.on("region-dblclick", (region: UpRegion, _ev: MouseEvent) => {
|
||||
console.debug("wavesurfer region-dblclick", region);
|
||||
dbg("wavesurfer region-dblclick", region);
|
||||
|
||||
currentAnnotation = region;
|
||||
setTimeout(() => wavesurfer.setCurrentTime(region.start));
|
||||
|
@ -273,13 +278,19 @@
|
|||
{/if}
|
||||
{#if loaded}
|
||||
<header>
|
||||
{#if zoom}
|
||||
<div class="zoom">
|
||||
<Icon name="zoom-out" />
|
||||
<input type="range" min="1" max="50" bind:value={zoom} />
|
||||
<Icon name="zoom-in" />
|
||||
</div>
|
||||
{/if}
|
||||
<IconButton
|
||||
name="edit"
|
||||
title={$i18n.t("Toggle Edit Mode")}
|
||||
on:click={() => (editable = !editable)}
|
||||
active={editable}
|
||||
>
|
||||
{$i18n.t("Annotate")}
|
||||
</IconButton>
|
||||
<div class="zoom">
|
||||
<Icon name="zoom-out" />
|
||||
<input type="range" min="1" max="50" bind:value={zoom} />
|
||||
<Icon name="zoom-in" />
|
||||
</div>
|
||||
</header>
|
||||
{/if}
|
||||
<div
|
||||
|
@ -289,10 +300,8 @@
|
|||
/>
|
||||
<div class="wavesurfer" bind:this={containerEl} />
|
||||
{#if currentAnnotation}
|
||||
<section class="annotationEditor labelborder">
|
||||
<header>
|
||||
<h3>{$i18n.t("Annotation")}</h3>
|
||||
</header>
|
||||
<LabelBorder>
|
||||
<span slot="header">{$i18n.t("Annotation")}</span>
|
||||
{#if currentAnnotation.attributes["upend-address"]}
|
||||
<UpObject
|
||||
link
|
||||
|
@ -364,13 +373,12 @@
|
|||
/>
|
||||
{/key}
|
||||
</div>
|
||||
</section>
|
||||
</LabelBorder>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@use "../../../styles/colors";
|
||||
@use "../../util";
|
||||
|
||||
.audio {
|
||||
width: 100%;
|
||||
|
@ -378,7 +386,7 @@
|
|||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
justify-content: space-between;
|
||||
& > * {
|
||||
flex-basis: 50%;
|
||||
}
|
||||
|
@ -392,31 +400,30 @@
|
|||
}
|
||||
}
|
||||
|
||||
.annotationEditor {
|
||||
& > * {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
.baseControls,
|
||||
.content {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.baseControls,
|
||||
.regionControls,
|
||||
.existControls {
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
}
|
||||
.baseControls,
|
||||
.regionControls,
|
||||
.existControls {
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.baseControls {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.baseControls {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.regionControls div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25em;
|
||||
}
|
||||
.regionControls div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25em;
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
width: 6em;
|
||||
}
|
||||
input[type="number"] {
|
||||
width: 6em;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
|
|
|
@ -7,11 +7,13 @@
|
|||
import Spinner from "../../utils/Spinner.svelte";
|
||||
import UpObject from "../UpObject.svelte";
|
||||
import { ATTR_LABEL } from "upend/constants";
|
||||
import { i18n } from "../../../i18n";
|
||||
|
||||
export let address: string;
|
||||
export let editable: boolean;
|
||||
export let detail: boolean;
|
||||
|
||||
let editable = false;
|
||||
|
||||
const { entity } = useEntity(address);
|
||||
|
||||
let imageLoaded = false;
|
||||
|
@ -23,11 +25,11 @@
|
|||
addAnnotation: (a: W3cAnnotation) => void;
|
||||
on: ((
|
||||
e: "createAnnotation" | "deleteAnnotation",
|
||||
c: (a: W3cAnnotation) => void
|
||||
c: (a: W3cAnnotation) => void,
|
||||
) => void) &
|
||||
((
|
||||
e: "updateAnnotation",
|
||||
c: (a: W3cAnnotation, b: W3cAnnotation) => void
|
||||
c: (a: W3cAnnotation, b: W3cAnnotation) => void,
|
||||
) => void);
|
||||
clearAnnotations: () => void;
|
||||
readOnly: boolean;
|
||||
|
@ -80,7 +82,7 @@
|
|||
});
|
||||
}
|
||||
$: hasAnnotations = $entity?.backlinks.some(
|
||||
(e) => e.attribute === "ANNOTATES"
|
||||
(e) => e.attribute === "ANNOTATES",
|
||||
);
|
||||
|
||||
let a8sLinkTarget: HTMLDivElement;
|
||||
|
@ -148,8 +150,8 @@
|
|||
const annotationObject = await api.fetchEntity(annotation.id);
|
||||
await Promise.all(
|
||||
annotationObject.attr[ATTR_LABEL].concat(
|
||||
annotationObject.attr["W3C_FRAGMENT_SELECTOR"]
|
||||
).map(async (e) => api.deleteEntry(e.address))
|
||||
annotationObject.attr["W3C_FRAGMENT_SELECTOR"],
|
||||
).map(async (e) => api.deleteEntry(e.address)),
|
||||
);
|
||||
await api.putEntry([
|
||||
{
|
||||
|
@ -218,8 +220,21 @@
|
|||
{/if}
|
||||
{#if imageLoaded}
|
||||
<div class="toolbar">
|
||||
<IconButton name="brightness-half" on:click={cycleBrightness} />
|
||||
<IconButton name="tone" on:click={cycleContrast} />
|
||||
<IconButton
|
||||
name="edit"
|
||||
on:click={() => (editable = !editable)}
|
||||
active={editable}
|
||||
>
|
||||
{$i18n.t("Annotate")}
|
||||
</IconButton>
|
||||
<div class="image-controls">
|
||||
<IconButton name="brightness-half" on:click={cycleBrightness}>
|
||||
{$i18n.t("Brightness")}
|
||||
</IconButton>
|
||||
<IconButton name="tone" on:click={cycleContrast}>
|
||||
{$i18n.t("Contrast")}
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
|
@ -273,8 +288,12 @@
|
|||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5em;
|
||||
|
||||
.image-controls {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.zoomable {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import debug from "debug";
|
||||
import { DEBUG } from "../lib/debug";
|
||||
const dbg = debug("upend:imageQueue");
|
||||
const dbg = debug("kestrel:imageQueue");
|
||||
|
||||
class ImageQueue {
|
||||
concurrency: number;
|
||||
|
|
|
@ -1,50 +1,80 @@
|
|||
<script lang="ts">
|
||||
import Selector from "./Selector.svelte";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import type { IValue, VALUE_TYPE } from "upend/types";
|
||||
import type { IValue } from "upend/types";
|
||||
import IconButton from "./IconButton.svelte";
|
||||
import { isEqual } from "lodash";
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let editable: boolean;
|
||||
export let attribute: string | undefined = undefined;
|
||||
export let value: IValue;
|
||||
export let value: IValue | undefined = undefined;
|
||||
let newValue: IValue = value;
|
||||
|
||||
// todo - grab from db
|
||||
const TYPES_FOR_ATTR: { [key: string]: VALUE_TYPE[] } = {
|
||||
LBL: ["String"],
|
||||
FILE_SIZE: ["Number"],
|
||||
FILE_MIME: ["String"],
|
||||
ADDED: ["Number"],
|
||||
LAST_VISITED: ["Number"]
|
||||
};
|
||||
let editing = false;
|
||||
|
||||
let selector: Selector;
|
||||
let hover = false;
|
||||
let focus = false;
|
||||
|
||||
$: if (editing && selector) selector.focus();
|
||||
$: if (!focus && !hover) editing = false;
|
||||
</script>
|
||||
|
||||
<div class="editable">
|
||||
{#if editable}
|
||||
<div class="inner">
|
||||
<div class="selector">
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
class="editable"
|
||||
class:editing
|
||||
on:mouseenter={() => (hover = true)}
|
||||
on:mouseleave={() => (hover = false)}
|
||||
>
|
||||
<div class="inner">
|
||||
{#if editing}
|
||||
<div
|
||||
class="selector"
|
||||
on:keydown={(ev) => {
|
||||
if (ev.key === "Escape") {
|
||||
editing = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Selector
|
||||
type="value"
|
||||
valueTypes={TYPES_FOR_ATTR[attribute]}
|
||||
bind:value={newValue}
|
||||
bind:this={selector}
|
||||
on:focus={(ev) => (focus = ev.detail)}
|
||||
on:input={() => selector.focus()}
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
name="check-circle"
|
||||
disabled={isEqual(value, newValue)}
|
||||
on:click={() => dispatch("edit", newValue)}
|
||||
name="save"
|
||||
on:click={() => {
|
||||
dispatch("edit", newValue);
|
||||
editing = false;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<slot />
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="content">
|
||||
<slot />
|
||||
</div>
|
||||
<div class="edit-icon">
|
||||
<IconButton name="edit" on:click={() => (editing = true)} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
<style lang="scss">
|
||||
.edit-icon {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.editable:hover .edit-icon {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.inner {
|
||||
display: flex;
|
||||
gap: 0.25em;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.selector {
|
||||
|
|
|
@ -5,9 +5,22 @@
|
|||
export let active = false;
|
||||
export let disabled = false;
|
||||
export let title: string | undefined = undefined;
|
||||
export let outline = false;
|
||||
export let subdued = false;
|
||||
export let small = false;
|
||||
export let color: string | undefined = "var(--active-color, var(--primary))";
|
||||
</script>
|
||||
|
||||
<button on:click class:active {disabled} {title}>
|
||||
<button
|
||||
on:click
|
||||
class:active
|
||||
class:outline
|
||||
class:subdued
|
||||
class:small
|
||||
{disabled}
|
||||
{title}
|
||||
style="--color: {color}"
|
||||
>
|
||||
<Icon {name} />
|
||||
<div class="text">
|
||||
<slot />
|
||||
|
@ -29,13 +42,31 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
transition: opacity 0.2s, color 0.2s;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
color 0.2s,
|
||||
border-color 0.2s;
|
||||
}
|
||||
|
||||
button.subdued {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.outline {
|
||||
border: 1px solid var(--foreground);
|
||||
border-radius: 4px;
|
||||
|
||||
padding: 0.25em 1em;
|
||||
&.small {
|
||||
padding: 0.1em 0.8em;
|
||||
}
|
||||
}
|
||||
|
||||
.active,
|
||||
button:hover {
|
||||
opacity: 1;
|
||||
color: var(--active-color, var(--primary));
|
||||
color: var(--color);
|
||||
border-color: var(--color);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
<script lang="ts">
|
||||
export let hide = false;
|
||||
let hidden = true;
|
||||
</script>
|
||||
|
||||
<section class="labelborder" class:hide class:hidden>
|
||||
<header
|
||||
on:click={() => {
|
||||
if (hide) {
|
||||
hidden = !hidden;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<slot name="header-full">
|
||||
<h3><slot name="header" /></h3>
|
||||
</slot>
|
||||
</header>
|
||||
<div class="content">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style lang="scss">
|
||||
section.labelborder {
|
||||
margin-top: 0.66rem;
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
|
||||
border-bottom: 1px solid var(--foreground);
|
||||
padding-bottom: 0.33rem;
|
||||
margin-bottom: 0.33rem;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.hide {
|
||||
header {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
&.hidden {
|
||||
opacity: 0.66;
|
||||
|
||||
header {
|
||||
border-bottom-width: 0.5px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -4,10 +4,10 @@
|
|||
import { useEntity } from "../../lib/entity";
|
||||
import type { AttributeCreate, AttributeUpdate } from "../../types/base";
|
||||
import type { UpEntry } from "upend";
|
||||
import LabelBorder from "./LabelBorder.svelte";
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let address: string;
|
||||
export let editable: boolean;
|
||||
|
||||
$: ({ entity } = useEntity(address));
|
||||
|
||||
|
@ -44,26 +44,15 @@
|
|||
}, 500);
|
||||
</script>
|
||||
|
||||
{#if notes || editable}
|
||||
<section class="notes labelborder">
|
||||
<header>
|
||||
<h3>Notes</h3>
|
||||
</header>
|
||||
<div
|
||||
class="content"
|
||||
contenteditable={editable ? "true" : "false"}
|
||||
on:input={update}
|
||||
bind:this={contentEl}
|
||||
>
|
||||
{notes ? notes : ""}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
<LabelBorder hide={!notes?.length}>
|
||||
<span slot="header">Notes</span>
|
||||
<div class="notes" contenteditable on:input={update} bind:this={contentEl}>
|
||||
{notes ? notes : ""}
|
||||
</div>
|
||||
</LabelBorder>
|
||||
|
||||
<style lang="scss">
|
||||
@use "../util";
|
||||
|
||||
.content {
|
||||
.notes {
|
||||
background: var(--background);
|
||||
border-radius: 4px;
|
||||
padding: 0.5em !important;
|
||||
|
|
|
@ -11,6 +11,8 @@
|
|||
import { matchSorter } from "match-sorter";
|
||||
import api from "../../lib/api";
|
||||
import { ATTR_LABEL } from "upend/constants";
|
||||
import debug from "debug";
|
||||
const dbg = debug("kestrel:Selector");
|
||||
|
||||
const MAX_OPTIONS = 25;
|
||||
|
||||
|
@ -66,15 +68,14 @@
|
|||
(attr) =>
|
||||
attr.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
attr.labels.some((label) =>
|
||||
label.toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
label.toLowerCase().includes(query.toLowerCase()),
|
||||
),
|
||||
)
|
||||
.map((attribute) => {
|
||||
return {
|
||||
attribute,
|
||||
};
|
||||
});
|
||||
|
||||
const attributeToCreate = inputValue
|
||||
.toUpperCase()
|
||||
.replaceAll(/[^A-Z0-9]/g, "_");
|
||||
|
@ -131,7 +132,7 @@
|
|||
.filter(([_, labels]) =>
|
||||
labels
|
||||
.map((l) => l.toLowerCase())
|
||||
.includes(inputValue.toLowerCase())
|
||||
.includes(inputValue.toLowerCase()),
|
||||
)
|
||||
.map(([addr, _]) => addr);
|
||||
|
||||
|
@ -142,7 +143,7 @@
|
|||
t: "Address",
|
||||
c: addr,
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
options.push({
|
||||
|
@ -166,7 +167,7 @@
|
|||
c: e.entity,
|
||||
},
|
||||
} as SelectorOption;
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -176,6 +177,8 @@
|
|||
}
|
||||
}, 200);
|
||||
|
||||
$: dbg("Options: %O", options);
|
||||
|
||||
$: {
|
||||
if (inputFocused) {
|
||||
updateOptions(inputValue, true);
|
||||
|
@ -220,6 +223,7 @@
|
|||
}
|
||||
break;
|
||||
}
|
||||
dbg("Setting value to %O", value);
|
||||
dispatch("input", value);
|
||||
options = [];
|
||||
optionFocusIndex = -1;
|
||||
|
@ -235,7 +239,7 @@
|
|||
|
||||
const optionEls = Array.from(listEl.children) as HTMLLIElement[];
|
||||
const currentIndex = optionEls.findIndex(
|
||||
(el) => document.activeElement === el
|
||||
(el) => document.activeElement === el,
|
||||
);
|
||||
|
||||
let targetIndex = currentIndex;
|
||||
|
@ -273,6 +277,7 @@
|
|||
|
||||
let input: Input;
|
||||
export function focus() {
|
||||
dbg("Focusing input");
|
||||
input.focus();
|
||||
}
|
||||
|
||||
|
@ -281,6 +286,8 @@
|
|||
$: visible =
|
||||
(inputFocused || hover || optionFocusIndex > -1) && Boolean(options.length);
|
||||
$: dispatch("focus", inputFocused || hover || optionFocusIndex > -1);
|
||||
|
||||
$: dbg("focus = %s, hover = %s, visible = %s", inputFocused, hover, visible);
|
||||
</script>
|
||||
|
||||
<div class="selector">
|
||||
|
|
|
@ -1,19 +1,27 @@
|
|||
<script lang="ts">
|
||||
import { readable, type Readable } from "svelte/store";
|
||||
import type { UpListing } from "upend";
|
||||
import type { Address } from "upend/types";
|
||||
import type { Address, IValue } from "upend/types";
|
||||
import { query } from "../../lib/entity";
|
||||
import UpObject from "../display/UpObject.svelte";
|
||||
import UpObjectCard from "../display/UpObjectCard.svelte";
|
||||
import { ATTR_LABEL } from "upend/constants";
|
||||
import { ATTR_IN, ATTR_LABEL } from "upend/constants";
|
||||
import { i18n } from "../../i18n";
|
||||
import Icon from "../utils/Icon.svelte";
|
||||
import IconButton from "../utils/IconButton.svelte";
|
||||
import Selector from "../utils/Selector.svelte";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import type { WidgetChange } from "src/types/base";
|
||||
import debug from "debug";
|
||||
const dispatch = createEventDispatcher();
|
||||
const dbg = debug(`kestrel:EntityList`);
|
||||
|
||||
export let entities: Address[];
|
||||
export let thumbnails = true;
|
||||
export let sort = true;
|
||||
export let address: Address | undefined = undefined;
|
||||
|
||||
const deduplicatedEntities = Array.from(new Set(entities));
|
||||
$: deduplicatedEntities = Array.from(new Set(entities));
|
||||
|
||||
let style: "list" | "grid" | "flex" = "grid";
|
||||
|
||||
|
@ -21,7 +29,7 @@
|
|||
$: style = !thumbnails || clientWidth < 600 ? "list" : "grid";
|
||||
|
||||
// Sorting
|
||||
let sortedEntities = [];
|
||||
let sortedEntities: Address[] = [];
|
||||
|
||||
let sortKeys: { [key: string]: string[] } = {};
|
||||
function addSortKeys(key: string, vals: string[], resort: boolean) {
|
||||
|
@ -116,10 +124,41 @@
|
|||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Adding
|
||||
let addSelector: Selector | undefined;
|
||||
let adding = false;
|
||||
|
||||
$: if (adding && addSelector) addSelector.focus();
|
||||
|
||||
function addEntity(ev: CustomEvent<IValue>) {
|
||||
dbg("Adding entity", ev.detail);
|
||||
const addAddress = ev.detail?.t == "Address" ? ev.detail.c : undefined;
|
||||
if (!addAddress) return;
|
||||
|
||||
dispatch("change", {
|
||||
type: "entry-add",
|
||||
address: addAddress,
|
||||
} as WidgetChange);
|
||||
}
|
||||
|
||||
function removeEntity(address: string) {
|
||||
if (
|
||||
confirm(
|
||||
$i18n.t("Are you sure you want to remove this entry from members?"),
|
||||
)
|
||||
) {
|
||||
dbg("Removing entity", address);
|
||||
dispatch("change", {
|
||||
type: "entry-delete",
|
||||
address,
|
||||
} as WidgetChange);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="gallery style-{style}"
|
||||
class="entitylist style-{style}"
|
||||
class:has-thumbnails={thumbnails}
|
||||
bind:clientWidth
|
||||
>
|
||||
|
@ -138,21 +177,63 @@
|
|||
addSortKeys(entity, event.detail, true);
|
||||
}}
|
||||
/>
|
||||
<div class="icon">
|
||||
<IconButton
|
||||
name="trash"
|
||||
color="#dc322f"
|
||||
on:click={() => removeEntity(entity)}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<UpObject
|
||||
link
|
||||
address={entity}
|
||||
labels={sortKeys[entity]}
|
||||
on:resolved={(event) => {
|
||||
addSortKeys(entity, event.detail, true);
|
||||
}}
|
||||
/>
|
||||
<div class="object">
|
||||
<UpObject
|
||||
link
|
||||
address={entity}
|
||||
labels={sortKeys[entity]}
|
||||
on:resolved={(event) => {
|
||||
addSortKeys(entity, event.detail, true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="icon">
|
||||
<IconButton
|
||||
name="trash"
|
||||
color="#dc322f"
|
||||
on:click={() => removeEntity(entity)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="skeleton" style="text-align: center">...</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{#if address}
|
||||
<div class="add item">
|
||||
{#if adding}
|
||||
<Selector
|
||||
bind:this={addSelector}
|
||||
type="value"
|
||||
valueTypes={["Address"]}
|
||||
on:input={addEntity}
|
||||
on:focus={(ev) => {
|
||||
if (!ev.detail) {
|
||||
adding = false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<IconButton
|
||||
name="plus-circle"
|
||||
outline
|
||||
subdued
|
||||
on:click={() => {
|
||||
adding = true;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="message">
|
||||
|
@ -167,22 +248,23 @@
|
|||
gap: 4px;
|
||||
}
|
||||
|
||||
.gallery.has-thumbnails .items {
|
||||
.entitylist.has-thumbnails .items {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
:global(.gallery.style-grid .items) {
|
||||
:global(.entitylist.style-grid .items) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
:global(.gallery.style-flex .items) {
|
||||
:global(.entitylist.style-flex .items) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
:global(.gallery.style-list .items) {
|
||||
:global(.entitylist.style-list .items) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
@ -190,10 +272,60 @@
|
|||
|
||||
.item {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.message {
|
||||
text-align: center;
|
||||
margin: 0.5em;
|
||||
}
|
||||
|
||||
.entitylist:not(.has-thumbnails) {
|
||||
.item {
|
||||
display: flex;
|
||||
.object {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 0;
|
||||
transition: width 0.3s ease;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.icon {
|
||||
width: 1.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.entitylist.has-thumbnails {
|
||||
.item {
|
||||
position: relative;
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
top: 0.5em;
|
||||
right: 0.5em;
|
||||
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover .icon {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.add {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.entitylist.style-grid .add {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
</style>
|
|
@ -4,7 +4,7 @@
|
|||
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 { WidgetChange, AttributeUpdate } from "../../types/base";
|
||||
import type { UpEntry, UpListing } from "upend";
|
||||
import IconButton from "../utils/IconButton.svelte";
|
||||
import Selector from "../utils/Selector.svelte";
|
||||
|
@ -27,7 +27,7 @@
|
|||
export let columnWidths: string[] | undefined = undefined;
|
||||
|
||||
export let entries: UpEntry[];
|
||||
export let editable = false;
|
||||
export let attributes: string[] | undefined = undefined;
|
||||
export let attributeOptions: string[] | undefined = undefined;
|
||||
|
||||
// Display
|
||||
|
@ -42,21 +42,28 @@
|
|||
const VALUE_COL = "value";
|
||||
|
||||
// Editing
|
||||
let adding = false;
|
||||
let addHover = false;
|
||||
let addFocus = false;
|
||||
let newAttrSelector: Selector;
|
||||
let newEntryAttribute = "";
|
||||
let newEntryValue: IValue | undefined;
|
||||
|
||||
async function addEntry() {
|
||||
$: if (adding && newAttrSelector) newAttrSelector.focus();
|
||||
$: if (!addFocus && !addHover) adding = false;
|
||||
|
||||
async function addEntry(attribute: string, value: IValue) {
|
||||
dispatch("change", {
|
||||
type: "create",
|
||||
attribute: newEntryAttribute,
|
||||
value: newEntryValue,
|
||||
} as AttributeChange);
|
||||
attribute,
|
||||
value,
|
||||
} as WidgetChange);
|
||||
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);
|
||||
if (confirm($i18n.t("Are you sure you want to remove the property?"))) {
|
||||
dispatch("change", { type: "delete", address } as WidgetChange);
|
||||
}
|
||||
}
|
||||
async function updateEntry(
|
||||
|
@ -209,14 +216,22 @@
|
|||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Unused attributes
|
||||
let unusedAttributes = [];
|
||||
|
||||
$: (async () => {
|
||||
unusedAttributes = await Promise.all(
|
||||
(attributes || []).filter(
|
||||
(attr) => !entries.some((entry) => entry.attribute === attr),
|
||||
),
|
||||
);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<table>
|
||||
<colgroup>
|
||||
{#if editable}
|
||||
<col class="action-col" />
|
||||
{/if}
|
||||
{#each displayColumns as column, idx}
|
||||
{#if columnWidths?.length}
|
||||
<col
|
||||
|
@ -227,30 +242,21 @@
|
|||
<col class="{column}-col" />
|
||||
{/if}
|
||||
{/each}
|
||||
<col class="action-col" />
|
||||
</colgroup>
|
||||
|
||||
{#if header}
|
||||
<tr>
|
||||
{#if editable}
|
||||
<th />
|
||||
{/if}
|
||||
{#each displayColumns as column}
|
||||
<th>{COLUMN_LABELS[column] || $attributeLabels[column] || column}</th>
|
||||
{/each}
|
||||
<th />
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
{#each sortedEntries as entry (entry.address)}
|
||||
<tr data-address={entry.address} use:observe>
|
||||
{#if visible.has(entry.address)}
|
||||
{#if editable}
|
||||
<td class="attr-action">
|
||||
<IconButton
|
||||
name="x-circle"
|
||||
on:click={() => removeEntry(entry.address)}
|
||||
/>
|
||||
</td>
|
||||
{/if}
|
||||
{#each displayColumns as column}
|
||||
{#if column == TIMESTAMP_COL}
|
||||
<td title={entry.timestamp}
|
||||
|
@ -292,8 +298,6 @@
|
|||
{:else if column == VALUE_COL}
|
||||
<td class="value mark-value">
|
||||
<Editable
|
||||
{editable}
|
||||
attribute={entry.attribute}
|
||||
value={entry.value}
|
||||
on:edit={(ev) =>
|
||||
updateEntry(entry.address, entry.attribute, ev.detail)}
|
||||
|
@ -327,6 +331,13 @@
|
|||
<td>?</td>
|
||||
{/if}
|
||||
{/each}
|
||||
<td class="attr-action">
|
||||
<IconButton
|
||||
subdued
|
||||
name="x-circle"
|
||||
on:click={() => removeEntry(entry.address)}
|
||||
/>
|
||||
</td>
|
||||
{:else}
|
||||
<tr>
|
||||
<td colspan="99">
|
||||
|
@ -337,33 +348,95 @@
|
|||
</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}
|
||||
attributeOptions={attributeOptions || []}
|
||||
{#each unusedAttributes as attribute}
|
||||
<tr>
|
||||
{#each displayColumns as column}
|
||||
{#if column == ATTR_COL}
|
||||
<td
|
||||
class:formatted={Boolean(
|
||||
Object.keys($attributeLabels).includes(attribute),
|
||||
)}
|
||||
class="mark-attribute"
|
||||
>
|
||||
<UpLink to={{ attribute }}>
|
||||
<Ellipsis
|
||||
value={$attributeLabels[attribute] || attribute}
|
||||
title={$attributeLabels[attribute]
|
||||
? `${$attributeLabels[attribute]} (${attribute})`
|
||||
: attribute}
|
||||
/>
|
||||
</UpLink>
|
||||
</td>
|
||||
{:else if column == VALUE_COL}
|
||||
<td>
|
||||
<Editable on:edit={(ev) => addEntry(attribute, ev.detail)}>
|
||||
<span class="unset">{$i18n.t("(unset)")}</span>
|
||||
</Editable>
|
||||
</td>
|
||||
{:else}
|
||||
<td></td>
|
||||
{/if}
|
||||
{/each}
|
||||
<td class="attr-action"></td>
|
||||
</tr>
|
||||
{/each}
|
||||
|
||||
{#if !attributes?.length}
|
||||
{#if adding}
|
||||
<tr
|
||||
class="add-row"
|
||||
on:mouseenter={() => (addHover = true)}
|
||||
on:mouseleave={() => (addHover = false)}
|
||||
>
|
||||
{#each displayColumns as column}
|
||||
{#if column == ATTR_COL}
|
||||
<td>
|
||||
<Selector
|
||||
type="attribute"
|
||||
bind:attribute={newEntryAttribute}
|
||||
{attributeOptions}
|
||||
on:focus={(ev) => (addFocus = ev.detail)}
|
||||
bind:this={newAttrSelector}
|
||||
/>
|
||||
</td>
|
||||
{:else if column === VALUE_COL}
|
||||
<td>
|
||||
<Selector
|
||||
type="value"
|
||||
bind:value={newEntryValue}
|
||||
on:focus={(ev) => (addFocus = ev.detail)}
|
||||
/>
|
||||
</td>
|
||||
{:else}
|
||||
<td></td>
|
||||
{/if}
|
||||
{/each}
|
||||
<td class="attr-action">
|
||||
<IconButton
|
||||
name="save"
|
||||
on:click={() => addEntry(newEntryAttribute, newEntryValue)}
|
||||
/>
|
||||
</td>
|
||||
{/if}
|
||||
{#if displayColumns.includes(VALUE_COL)}
|
||||
<td>
|
||||
<Selector type="value" bind:value={newEntryValue} />
|
||||
</tr>
|
||||
{:else}
|
||||
<tr>
|
||||
<td class="fullwidth" colspan={displayColumns.length + 1}>
|
||||
<IconButton
|
||||
outline
|
||||
subdued
|
||||
name="plus-circle"
|
||||
on:click={() => (adding = true)}
|
||||
/>
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
</tr>
|
||||
{/if}
|
||||
{/if}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container {
|
||||
overflow: hidden;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
table {
|
||||
|
@ -402,5 +475,14 @@
|
|||
.attribute-col {
|
||||
width: 33%;
|
||||
}
|
||||
|
||||
:global(td.fullwidth > *) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.unset {
|
||||
opacity: 0.66;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -5,7 +5,7 @@ import type { ListingResult, EntityListing, EntityInfo } from "upend/types";
|
|||
import { useSWR } from "../util/fetch";
|
||||
import api from "./api";
|
||||
import debug from "debug";
|
||||
const dbg = debug("upend:lib");
|
||||
const dbg = debug("kestrel:lib");
|
||||
|
||||
export function useEntity(address: string) {
|
||||
const { data, error, revalidate } = useSWR<EntityListing, unknown>(
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import type { Meta, StoryObj } from "@storybook/svelte";
|
||||
import Gallery from "../components/widgets/Gallery.svelte";
|
||||
import EntityList from "../components/widgets/EntityList.svelte";
|
||||
import {
|
||||
imageAddress,
|
||||
imageVerticalAddress,
|
||||
|
@ -8,9 +8,9 @@ import {
|
|||
} from "./common";
|
||||
import RouterDecorator from "./RouterDecorator.svelte";
|
||||
|
||||
const meta: Meta<Gallery> = {
|
||||
title: "Widgets/Gallery",
|
||||
component: Gallery,
|
||||
const meta: Meta<EntityList> = {
|
||||
title: "Widgets/EntityList",
|
||||
component: EntityList,
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
entities: [
|
||||
|
@ -25,7 +25,7 @@ const meta: Meta<Gallery> = {
|
|||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<Gallery>;
|
||||
type Story = StoryObj<EntityList>;
|
||||
|
||||
export const Thumbnails: Story = {
|
||||
args: {
|
|
@ -1,9 +1,11 @@
|
|||
import type { IValue } from "upend/types";
|
||||
|
||||
export type AttributeChange =
|
||||
export type WidgetChange =
|
||||
| AttributeCreate
|
||||
| AttributeUpdate
|
||||
| AttributeDelete;
|
||||
| AttributeDelete
|
||||
| EntryInAdd
|
||||
| EntryInDelete;
|
||||
|
||||
export interface AttributeCreate {
|
||||
type: "create";
|
||||
|
@ -21,3 +23,13 @@ export interface AttributeDelete {
|
|||
type: "delete";
|
||||
address: string;
|
||||
}
|
||||
|
||||
export interface EntryInAdd {
|
||||
type: "entry-add";
|
||||
address: string;
|
||||
}
|
||||
|
||||
export interface EntryInDelete {
|
||||
type: "entry-delete";
|
||||
address: string;
|
||||
}
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import { writable } from "svelte/store";
|
||||
import { debug } from "debug";
|
||||
const dbg = debug("kestrel:swrshim");
|
||||
|
||||
// stale shim until https://github.com/ConsoleTVs/sswr/issues/24 is resolved
|
||||
export type SWRKey = string;
|
||||
|
@ -10,6 +12,7 @@ export function useSWR<D = unknown, E = Error>(
|
|||
const error = writable<E | undefined>();
|
||||
|
||||
async function doFetch() {
|
||||
dbg("Fetching: %s", key);
|
||||
try {
|
||||
const response = await fetch(key, options);
|
||||
if (response.ok) {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
import EntryList from "../components/widgets/EntryList.svelte";
|
||||
import Gallery from "../components/widgets/Gallery.svelte";
|
||||
import EntityList from "../components/widgets/EntityList.svelte";
|
||||
import type { Widget } from "../components/EntryView.svelte";
|
||||
import { Link } from "svelte-navigator";
|
||||
import { UpListing } from "upend";
|
||||
|
@ -92,7 +92,7 @@
|
|||
{
|
||||
name: "List",
|
||||
icon: "list-ul",
|
||||
components: (entries) => [
|
||||
components: ({ entries }) => [
|
||||
{
|
||||
component: EntryList,
|
||||
props: {
|
||||
|
@ -106,11 +106,11 @@
|
|||
],
|
||||
},
|
||||
{
|
||||
name: "Gallery",
|
||||
name: "EntityList",
|
||||
icon: "image",
|
||||
components: (entries) => [
|
||||
components: ({ entries }) => [
|
||||
{
|
||||
component: Gallery,
|
||||
component: EntityList,
|
||||
props: {
|
||||
entities: entries.map((e) => e.entity),
|
||||
sort: false,
|
||||
|
@ -124,7 +124,7 @@
|
|||
{
|
||||
name: "List",
|
||||
icon: "list-ul",
|
||||
components: (entries) => [
|
||||
components: ({ entries }) => [
|
||||
{
|
||||
component: EntryList,
|
||||
props: {
|
||||
|
@ -138,11 +138,11 @@
|
|||
],
|
||||
},
|
||||
{
|
||||
name: "Gallery",
|
||||
name: "EntityList",
|
||||
icon: "image",
|
||||
components: (entries) => [
|
||||
components: ({ entries }) => [
|
||||
{
|
||||
component: Gallery,
|
||||
component: EntityList,
|
||||
props: {
|
||||
entities: entries.map((e) => e.entity),
|
||||
sort: false,
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
import { query as queryFn } from "../lib/entity";
|
||||
import EntryView, { type Widget } from "../components/EntryView.svelte";
|
||||
import api from "../lib/api";
|
||||
import Gallery from "../components/widgets/Gallery.svelte";
|
||||
import EntityList from "../components/widgets/EntityList.svelte";
|
||||
import { matchSorter } from "match-sorter";
|
||||
import { ATTR_LABEL } from "upend/constants";
|
||||
const navigate = useNavigate();
|
||||
|
@ -37,7 +37,7 @@
|
|||
}
|
||||
|
||||
$: objects = ($result?.entries || []).filter(
|
||||
(e) => e.attribute === ATTR_LABEL
|
||||
(e) => e.attribute === ATTR_LABEL,
|
||||
);
|
||||
$: sortedObjects = matchSorter(objects, debouncedQuery, {
|
||||
keys: ["value.c"],
|
||||
|
@ -51,7 +51,7 @@
|
|||
.then((labelListing) => {
|
||||
exactHits = labelListing.entries
|
||||
.filter(
|
||||
(e) => String(e.value.c).toLowerCase() === query.toLowerCase()
|
||||
(e) => String(e.value.c).toLowerCase() === query.toLowerCase(),
|
||||
)
|
||||
.map((e) => e.entity);
|
||||
});
|
||||
|
@ -68,9 +68,9 @@
|
|||
{
|
||||
name: "List",
|
||||
icon: "list-ul",
|
||||
components: (entries) => [
|
||||
components: ({ entries }) => [
|
||||
{
|
||||
component: Gallery,
|
||||
component: EntityList,
|
||||
props: {
|
||||
entities: entries.map((e) => e.entity),
|
||||
sort: false,
|
||||
|
@ -80,11 +80,11 @@
|
|||
],
|
||||
},
|
||||
{
|
||||
name: "Gallery",
|
||||
name: "EntityList",
|
||||
icon: "image",
|
||||
components: (entries) => [
|
||||
components: ({ entries }) => [
|
||||
{
|
||||
component: Gallery,
|
||||
component: EntityList,
|
||||
props: {
|
||||
entities: entries.map((e) => e.entity),
|
||||
sort: false,
|
||||
|
|
Loading…
Reference in New Issue