Merge branch 'feat/modeless-webui'
ci/woodpecker/push/woodpecker Pipeline failed Details

feat/axum
Tomáš Mládek 2023-09-07 19:07:25 +02:00
commit 769b62d02e
24 changed files with 840 additions and 397 deletions

View File

@ -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;

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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>

View File

@ -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;

View File

@ -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

View File

@ -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}`);

View File

@ -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 {

View File

@ -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 {

View File

@ -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;

View File

@ -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 {

View File

@ -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 {

View File

@ -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>

View File

@ -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;

View File

@ -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">

View File

@ -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>

View File

@ -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>

View File

@ -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>(

View File

@ -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: {

View File

@ -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;
}

View File

@ -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) {

View File

@ -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,

View File

@ -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,