refactor(webui): switch to SvelteKit | touchdown

develop
Tomáš Mládek 2024-01-22 13:12:21 +01:00
parent bbcaa58dd1
commit 0353e43dcf
147 changed files with 7791 additions and 15101 deletions

13
webui/.eslintignore Normal file
View File

@ -0,0 +1,13 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

View File

@ -1,20 +1,35 @@
/** @type { import("eslint").Linter.Config } */
module.exports = {
env: {
browser: true,
es2021: true
},
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:storybook/recommended"],
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaVersion: "latest",
sourceType: "module"
},
plugins: ["svelte3", "@typescript-eslint"],
overrides: [{
files: ["*.svelte"],
processor: "svelte3/svelte3"
}],
root: true,
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:svelte/recommended',
'prettier'
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020,
extraFileExtensions: ['.svelte']
},
env: {
browser: true,
es2017: true,
node: true
},
overrides: [
{
files: ['*.svelte'],
parser: 'svelte-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser'
}
}
],
rules: {
"svelte/valid-compile": ["error", { "ignoreWarnings": false }],
"@typescript-eslint/no-unused-vars": ["warn", {
argsIgnorePattern: "^_",
varsIgnorePattern: "^_"
@ -23,12 +38,4 @@ module.exports = {
allow: ["debug", "warn", "error"]
}]
},
settings: {
"svelte3/typescript": true,
// load TypeScript as peer dependency
"svelte3/ignore-warnings": w => w.code == "unused-export-let"
},
globals: {
NodeJS: true
}
};
};

39
webui/.gitattributes vendored
View File

@ -1,39 +0,0 @@
public/assets/fonts/Inter-ExtraBoldItalic.woff filter=lfs diff=lfs merge=lfs -text
public/assets/fonts/Inter-ExtraLight.woff2 filter=lfs diff=lfs merge=lfs -text
public/assets/fonts/Inter-italic.var.woff2 filter=lfs diff=lfs merge=lfs -text
public/assets/fonts/Inter-Light.woff2 filter=lfs diff=lfs merge=lfs -text
public/assets/fonts/Inter-Bold.woff filter=lfs diff=lfs merge=lfs -text
public/assets/fonts/Inter-BoldItalic.woff2 filter=lfs diff=lfs merge=lfs -text
public/assets/fonts/Inter-Italic.woff2 filter=lfs diff=lfs merge=lfs -text
public/assets/fonts/Inter-Medium.woff2 filter=lfs diff=lfs merge=lfs -text
public/assets/fonts/Inter-MediumItalic.woff2 filter=lfs diff=lfs merge=lfs -text
public/assets/fonts/Inter-SemiBold.woff filter=lfs diff=lfs merge=lfs -text
public/assets/fonts/Inter-BlackItalic.woff2 filter=lfs diff=lfs merge=lfs -text
public/assets/fonts/Inter-ExtraBold.woff filter=lfs diff=lfs merge=lfs -text
public/assets/fonts/Inter-Regular.woff filter=lfs diff=lfs merge=lfs -text
public/assets/fonts/Inter-SemiBoldItalic.woff filter=lfs diff=lfs merge=lfs -text
public/assets/fonts/Inter-Thin.woff filter=lfs diff=lfs merge=lfs -text
public/assets/fonts/Inter-Black.woff filter=lfs diff=lfs merge=lfs -text
public/assets/fonts/Inter-Black.woff2 filter=lfs diff=lfs merge=lfs -text
public/assets/fonts/Inter-BlackItalic.woff filter=lfs diff=lfs merge=lfs -text
public/assets/fonts/Inter-ExtraBoldItalic.woff2 filter=lfs diff=lfs merge=lfs -text
public/assets/fonts/Inter-ExtraLightItalic.woff filter=lfs diff=lfs merge=lfs -text
public/assets/fonts/Inter-Light.woff filter=lfs diff=lfs merge=lfs -text
public/assets/fonts/Inter-Bold.woff2 filter=lfs diff=lfs merge=lfs -text
public/assets/fonts/Inter-ExtraBold.woff2 filter=lfs diff=lfs merge=lfs -text
public/assets/fonts/Inter-ExtraLightItalic.woff2 filter=lfs diff=lfs merge=lfs -text
public/assets/fonts/Inter-LightItalic.woff2 filter=lfs diff=lfs merge=lfs -text
public/assets/fonts/Inter-MediumItalic.woff filter=lfs diff=lfs merge=lfs -text
public/assets/fonts/Inter-ThinItalic.woff2 filter=lfs diff=lfs merge=lfs -text
public/assets/fonts/Inter.var.woff2 filter=lfs diff=lfs merge=lfs -text
public/assets/fonts/Inter-BoldItalic.woff filter=lfs diff=lfs merge=lfs -text
public/assets/fonts/Inter-ExtraLight.woff filter=lfs diff=lfs merge=lfs -text
public/assets/fonts/Inter-Medium.woff filter=lfs diff=lfs merge=lfs -text
public/assets/fonts/Inter-Regular.woff2 filter=lfs diff=lfs merge=lfs -text
public/assets/fonts/Inter-SemiBold.woff2 filter=lfs diff=lfs merge=lfs -text
public/assets/fonts/Inter-SemiBoldItalic.woff2 filter=lfs diff=lfs merge=lfs -text
public/assets/fonts/Inter-Thin.woff2 filter=lfs diff=lfs merge=lfs -text
public/assets/fonts/Inter-Italic.woff filter=lfs diff=lfs merge=lfs -text
public/assets/fonts/Inter-LightItalic.woff filter=lfs diff=lfs merge=lfs -text
public/assets/fonts/Inter-roman.var.woff2 filter=lfs diff=lfs merge=lfs -text
public/assets/fonts/Inter-ThinItalic.woff filter=lfs diff=lfs merge=lfs -text

12
webui/.gitignore vendored
View File

@ -1,4 +1,12 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
/dist
/public/vendor/
/static/vendor

1
webui/.npmrc Normal file
View File

@ -0,0 +1 @@
engine-strict=true

4
webui/.prettierignore Normal file
View File

@ -0,0 +1,4 @@
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

View File

@ -1,3 +1,8 @@
{
"plugins": ["prettier-plugin-svelte"]
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

View File

@ -1,27 +0,0 @@
import type { StorybookConfig } from "@storybook/svelte-vite";
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx|svelte)"],
addons: [
"@storybook/addon-links",
"@storybook/addon-essentials",
"@storybook/addon-interactions",
],
framework: {
name: "@storybook/svelte-vite",
options: {},
},
docs: {
autodocs: "tag",
},
viteFinal: (config) => {
config.server!.proxy = {
"/api": {
target: "http://localhost:8099/",
},
};
return config;
},
};
module.exports = config;

View File

@ -1,3 +0,0 @@
<script>
window.global = window;
</script>

View File

@ -1,11 +0,0 @@
export const parameters = {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
};
import "../src/styles/main.scss";

View File

@ -1,20 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="application-name" content="UpEnd" />
<title>UpEnd</title>
<link rel="icon" type="image/png" href="assets/upend.svg" />
<meta
http-equiv="Cache-control"
content="no-cache, no-store, must-revalidate"
/>
</head>
<body>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@ -1,55 +1,34 @@
{
"name": "upend-kestrel",
"version": "1.0.0",
"version": "2.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --clearScreen=false",
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json",
"lint": "eslint src",
"clean": "rm -frv dist public/vendor",
"storybook": "npm-run-all -p -r storybook:serve storybook:upend",
"storybook:serve": "storybook dev -p 6006",
"storybook:upend": "cargo run --release -- serve ../example_vault --bind 127.0.0.1:8099 --no-browser --reinitialize --rescan-mode mirror",
"build-storybook": "storybook build"
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
},
"devDependencies": {
"@storybook/addon-essentials": "^7.5.3",
"@storybook/addon-interactions": "^7.5.3",
"@storybook/addon-links": "^7.5.3",
"@storybook/blocks": "^7.5.3",
"@storybook/svelte": "^7.5.3",
"@storybook/svelte-vite": "^7.5.3",
"@storybook/testing-library": "^0.0.13",
"@sveltejs/vite-plugin-svelte": "^1.4.0",
"@tsconfig/svelte": "^3.0.0",
"@types/d3": "^7.4.0",
"@types/debug": "^4.1.8",
"@types/dompurify": "^2.4.0",
"@types/lodash": "^4.14.197",
"@types/marked": "^4.3.1",
"@types/three": "^0.143.2",
"@types/wavesurfer.js": "^6.0.6",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"eslint": "^8.48.0",
"eslint-plugin-storybook": "^0.6.13",
"eslint-plugin-svelte3": "^4.0.0",
"npm-run-all": "^4.1.5",
"prettier": "^3.0.2",
"prettier-plugin-svelte": "^3.0.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"storybook": "^7.5.3",
"svelte": "^3.59.2",
"svelte-check": "^2.10.3",
"svelte-preprocess": "^5.0.4",
"tslib": "^2.6.2",
"typescript": "^4.9.5",
"vite": "^4.4.9",
"vite-plugin-static-copy": "^0.13.1"
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@types/eslint": "8.56.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.35.1",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
"svelte": "^4.2.7",
"svelte-check": "^3.6.0",
"tslib": "^2.4.1",
"typescript": "^5.0.0",
"vite": "^5.0.3"
},
"dependencies": {
"@ibm/plex": "^6.3.0",
@ -74,8 +53,10 @@
"sirv-cli": "^2.0.2",
"sswr": "^1.11.0",
"svelte-i18next": "^1.2.2",
"svelte-navigator": "^3.2.2",
"three": "^0.147.0",
"wavesurfer.js": "^6.6.4"
"wavesurfer.js": "^6.6.4",
"vite-plugin-static-copy": "^0.13.1",
"@types/node": "^18.19.8",
"@types/lodash": "^4.14"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,48 +0,0 @@
<script lang="ts">
import { Router, Route, createHistory } from "svelte-navigator";
import createHashSource from "./util/history";
import Header from "./components/layout/Header.svelte";
import Footer from "./components/layout/Footer.svelte";
import Home from "./views/Home.svelte";
import Browse from "./views/Browse.svelte";
import Search from "./views/Search.svelte";
import DropPasteHandler from "./components/DropPasteHandler.svelte";
import AddModal from "./components/AddModal.svelte";
import Store from "./views/Store.svelte";
import Setup from "./views/Setup.svelte";
import "./styles/main.scss";
const history = createHistory(createHashSource());
</script>
<Router {history} primary={false}>
<Header />
<main>
<Route path="/">
<Home />
</Route>
<Route path="/browse/*addresses">
<Browse />
</Route>
<Route path="/search/:query" let:params>
<Search query={decodeURIComponent(params.query)} />
</Route>
<Route path="/store">
<Store />
</Route>
<Route path="/setup">
<Setup />
</Route>
<Footer />
<AddModal />
</main>
</Router>
<DropPasteHandler />

13
webui/src/app.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

12
webui/src/app.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@ -1,167 +0,0 @@
<script context="module" lang="ts">
import mitt from "mitt";
export type AddEvents = {
files: File[];
urls: string[];
};
export const addEmitter = mitt<AddEvents>();
</script>
<script lang="ts">
import { useNavigate } from "svelte-navigator";
import Icon from "./utils/Icon.svelte";
import IconButton from "./utils/IconButton.svelte";
import api from "../lib/api";
const navigate = useNavigate();
let files: File[] = [];
let URLs: string[] = [];
let uploading = false;
$: visible = files.length + URLs.length > 0;
addEmitter.on("files", (ev) => {
ev.forEach((file) => {
if (
!files
.map((f) => `${f.name}${f.size}`)
.includes(`${file.name}${file.size}`)
) {
files.push(file);
}
files = files;
});
});
async function upload() {
uploading = true;
try {
const addresses = await Promise.all(
files.map(async (file) => api.putBlob(file)),
);
navigate(`/browse/${addresses.join(",")}`);
} catch (error) {
alert(error);
}
uploading = false;
reset();
}
function reset() {
if (!uploading) {
files = [];
URLs = [];
}
}
</script>
<svelte:body on:keydown={(ev) => ev.key === "Escape" && reset()} />
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="addmodal-container" class:visible class:uploading on:click={reset}>
<div class="addmodal" on:click|stopPropagation>
<div class="files">
{#each files as file}
<div class="file">
{#if file.type.startsWith("image")}
<img src={URL.createObjectURL(file)} alt="To be uploaded." />
{:else}
<div class="icon">
<Icon name="file" />
</div>
{/if}
<div class="label">{file.name}</div>
</div>
{/each}
</div>
<div class="controls">
<IconButton name="upload" on:click={upload} />
</div>
</div>
</div>
<style lang="scss">
.addmodal-container {
position: absolute;
left: 0;
top: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.5);
color: var(--foreground);
display: none;
&.visible {
display: unset;
}
&.uploading {
cursor: progress;
.addmodal {
filter: brightness(0.5);
}
}
}
.addmodal {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
background: var(--background);
color: var(--foreground);
border: solid 2px var(--foreground);
border-radius: 8px;
padding: 1rem;
}
.files {
display: flex;
flex-direction: column;
gap: 1em;
padding: 0.5em;
overflow-y: auto;
max-height: 66vh;
}
.file {
display: flex;
align-items: center;
flex-direction: column;
border: 1px solid var(--foreground);
border-radius: 4px;
background: var(--background-lighter);
padding: 0.5em;
img {
max-height: 12em;
max-width: 12em;
}
.icon {
font-size: 24px;
}
.label {
flex-grow: 1;
text-align: center;
}
}
.controls {
display: flex;
justify-content: center;
font-size: 48px;
margin-top: 0.5rem;
}
</style>

View File

@ -1,185 +0,0 @@
<script lang="ts">
import { createEventDispatcher, onMount, setContext, tick } from "svelte";
import { normUrl } from "../util/history";
import IconButton from "./utils/IconButton.svelte";
import { selected } from "./EntitySelect.svelte";
import type { BrowseContext } from "../util/browse";
import { writable } from "svelte/store";
import { useParams } from "svelte-navigator";
import { i18n } from "../i18n";
const dispatch = createEventDispatcher();
const params = useParams();
export let address: string | undefined = undefined;
export let index: number;
export let only: boolean;
export let background = "var(--background-lighter)";
export let forceDetail = false;
let shifted = false;
let key = Math.random();
let detail = only || forceDetail;
let detailChanged = false;
$: if (!detailChanged) detail = only || forceDetail;
$: if (detailChanged) tick().then(() => dispatch("detail", detail));
let indexStore = writable(index);
$: $indexStore = index;
let addressesStore = writable([]);
$: $addressesStore = $params.addresses?.split(",") || [];
setContext("browse", {
index: indexStore,
addresses: addressesStore,
} as BrowseContext);
onMount(() => {
// Required to make detail mode detection work in Browse
dispatch("detail", detail);
});
$: if ($selected.length) {
detail = false;
}
function visit() {
window.open(normUrl(`/browse/${address}`), "_blank");
}
let width = 460;
if (window.innerWidth < 600) {
width = window.innerWidth - 6;
}
function drag(ev: MouseEvent) {
const startWidth = width;
const startX = ev.screenX;
function onMouseMove(ev: MouseEvent) {
width = startWidth + (ev.screenX - startX);
width = width < 300 ? 300 : width;
}
function onMouseUp(_: MouseEvent) {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
}
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
}
function reload() {
key = Math.random();
}
</script>
<div
class="browse-column"
class:detail
style="--background-color: {background}"
on:mousemove={(ev) => (shifted = ev.shiftKey)}
>
<div class="view" style="--width: {width}px">
<header>
{#if address}
<IconButton name="link" on:click={() => visit()} disabled={only}>
{$i18n.t("Detach")}
</IconButton>
{/if}
{#if !forceDetail}
<IconButton
name={detail ? "zoom-out" : "zoom-in"}
on:click={() => {
detail = !detail;
detailChanged = true;
}}
active={detail}
>
{$i18n.t("Detail")}
</IconButton>
{:else}
<div class="noop"></div>
{/if}
{#if address}
<IconButton
name="intersect"
on:click={() => dispatch("combine", address)}
>
{$i18n.t("Combine")}
</IconButton>
{/if}
{#if !shifted}
<IconButton
name="x-circle"
on:click={() => dispatch("close")}
disabled={only}
>
{$i18n.t("Close")}
</IconButton>
{:else}
<IconButton name="refresh" on:click={() => reload()}>
{$i18n.t("Reload")}
</IconButton>
{/if}
</header>
{#key key}
<slot {detail} />
{/key}
</div>
<div class="resizeHandle" on:mousedown|preventDefault={drag} />
</div>
<style lang="scss">
.browse-column {
display: flex;
}
.browse-column.detail {
width: 100%;
.view {
@media screen and (min-width: 600px) {
min-width: 85vw;
max-width: min(85vw, 1920px);
margin-left: auto;
margin-right: auto;
}
}
}
.view {
min-width: var(--width);
max-width: var(--width);
display: flex;
flex-direction: column;
background: var(--background-color);
color: var(--foreground-lighter);
border: 1px solid var(--foreground-lightest);
border-radius: 0.5em;
padding: 1rem;
// transition: min-width 0.2s, max-width 0.2s;
// TODO - container has nowhere to scroll, breaking `detail` scroll
header {
font-size: 20px;
position: relative;
min-height: 1em;
display: flex;
justify-content: space-between;
flex: none;
}
}
.resizeHandle {
cursor: ew-resize;
height: 100%;
width: 0.5rem;
@media screen and (max-width: 600px) {
display: none;
}
}
</style>

View File

@ -1,178 +0,0 @@
<script lang="ts">
import { i18n } from "../i18n";
import EntitySetEditor from "./EntitySetEditor.svelte";
import EntryView from "./EntryView.svelte";
import Icon from "./utils/Icon.svelte";
import EntityList from "./widgets/EntityList.svelte";
import api from "../lib/api";
import { Query } from "@upnd/upend";
import { ATTR_IN } from "@upnd/upend/constants";
import { createEventDispatcher } from "svelte";
import { Any } from "@upnd/upend/query";
const dispatch = createEventDispatcher();
export let spec: string;
const individualSpecs = spec.split(/(?=[+=-])/);
let includedGroups = individualSpecs
.filter((s) => s.startsWith("+"))
.map((s) => s.slice(1));
let requiredGroups = individualSpecs
.filter((s) => s.startsWith("="))
.map((s) => s.slice(1));
let excludedGroups = individualSpecs
.filter((s) => s.startsWith("-"))
.map((s) => s.slice(1));
$: if (
includedGroups.length === 0 &&
requiredGroups.length === 0 &&
excludedGroups.length === 0
) {
dispatch("close");
}
const combinedWidgets = [
{
name: "List",
icon: "list-check",
components: ({ entities }) => [
{
component: EntityList,
props: {
entities,
thumbnails: false,
},
},
],
},
{
name: "EntityList",
icon: "image",
components: ({ entities }) => [
{
component: EntityList,
props: {
entities,
thumbnails: true,
},
},
],
},
];
let resultEntities = [];
async function updateResultEntities(
includedGroups: string[],
requiredGroups: string[],
excludedGroups: string[],
) {
const included = includedGroups.length
? (
await api.query(
Query.matches(
Any,
ATTR_IN,
includedGroups.map((g) => `@${g}`),
),
)
).objects
: [];
const required = requiredGroups.length
? (
await api.query(
Query.matches(
Any,
ATTR_IN,
requiredGroups.map((g) => `@${g}`),
),
)
).objects
: [];
const excluded = excludedGroups.length
? (
await api.query(
Query.matches(
Any,
ATTR_IN,
excludedGroups.map((g) => `@${g}`),
),
)
).objects
: [];
resultEntities = (
Object.keys(included).length
? Object.keys(included)
: Object.keys(required)
)
.filter(
(e) => !requiredGroups.length || Object.keys(required).includes(e),
)
.filter((e) => !Object.keys(excluded).includes(e));
}
$: updateResultEntities(includedGroups, requiredGroups, excludedGroups);
</script>
<div class="view" data-address-multi={resultEntities}>
<h2>
<Icon plain name="intersect" />
{$i18n.t("Combine")}
</h2>
<div class="controls">
<EntitySetEditor
entities={includedGroups}
header={$i18n.t("Include")}
confirmRemoveMessage={null}
on:add={(ev) => (includedGroups = [...includedGroups, ev.detail])}
on:remove={(ev) =>
(includedGroups = includedGroups.filter((e) => e !== ev.detail))}
/>
<EntitySetEditor
entities={requiredGroups}
header={$i18n.t("Require")}
confirmRemoveMessage={null}
on:add={(ev) => (requiredGroups = [...requiredGroups, ev.detail])}
on:remove={(ev) =>
(requiredGroups = requiredGroups.filter((e) => e !== ev.detail))}
/>
<EntitySetEditor
entities={excludedGroups}
header={$i18n.t("Exclude")}
confirmRemoveMessage={null}
on:add={(ev) => (excludedGroups = [...excludedGroups, ev.detail])}
on:remove={(ev) =>
(excludedGroups = excludedGroups.filter((e) => e !== ev.detail))}
/>
</div>
<div class="entities">
<EntryView
title={$i18n.t("Matching entities")}
entities={resultEntities}
widgets={combinedWidgets}
/>
</div>
</div>
<style lang="scss">
.view {
display: flex;
flex-direction: column;
height: 100%;
}
h2 {
text-align: center;
margin: 0;
margin-top: -0.66em;
}
.controls {
margin-bottom: 1rem;
}
.entities {
flex-grow: 1;
overflow-y: auto;
height: 0;
}
</style>

View File

@ -1,176 +0,0 @@
<script lang="ts">
import { ATTR_IN, ATTR_LABEL } from "@upnd/upend/constants";
import api from "../lib/api";
import { i18n } from "../i18n";
import Spinner from "./utils/Spinner.svelte";
import UpObject from "./display/UpObject.svelte";
const groups = (async () => {
const data = await api.query(`(matches ? "${ATTR_IN}" ?)`);
const addresses = data.entries
.filter((e) => e.value.t === "Address")
.map((e) => e.value.c) as string[];
const sortedAddresses = [...new Set(addresses)]
.map((address) => ({
address,
count: addresses.filter((a) => a === address).length,
}))
.sort((a, b) => b.count - a.count);
const addressesString = sortedAddresses
.map(({ address }) => `@${address}`)
.join(" ");
const labels = (
await api.query(`(matches (in ${addressesString}) "${ATTR_LABEL}" ? )`)
).entries.filter((e) => e.value.t === "String");
const display = sortedAddresses.map(({ address, count }) => ({
address,
labels: labels
.filter((e) => e.entity === address)
.map((e) => e.value.c)
.sort() as string[],
count,
}));
display
.sort((a, b) => (a.labels[0] || "").localeCompare(b.labels[0] || ""))
.sort((a, b) => b.count - a.count);
const labelsToGroups = new Map<string, string[]>();
labels.forEach((e) => {
const groups = labelsToGroups.get(e.value.c as string) || [];
if (!groups.includes(e.entity)) {
groups.push(e.entity);
}
labelsToGroups.set(e.value.c as string, groups);
});
const duplicates = [...labelsToGroups.entries()]
.filter(([_, groups]) => groups.length > 1)
.map(([label, groups]) => ({ label, groups }));
return {
groups: display,
total: sortedAddresses.length,
duplicateGroups: duplicates,
};
})();
let clientWidth: number;
</script>
<div class="groups" bind:clientWidth class:small={clientWidth < 600}>
<h2>{$i18n.t("Groups")}</h2>
<div class="main">
{#await groups}
<Spinner centered />
{:then data}
<ul>
{#each data.groups as group}
<li class="group" data-address={group.address}>
<UpObject link address={group.address} labels={group.labels} />
<div class="count">{group.count}</div>
</li>
{:else}
<li>No groups?</li>
{/each}
{#if data.groups && data.total > data.groups.length}
<li>+ {data.total - data.groups.length}...</li>
{/if}
</ul>
{#if data.duplicateGroups.length > 0}
<h3>{$i18n.t("Duplicate groups")}</h3>
<ul class="duplicate">
{#each data.duplicateGroups as { label, groups }}
<li class="duplicate-group">
<div class="label">{label}</div>
<ul>
{#each groups as group}
<li>
<UpObject link address={group} backpath={2} />
</li>
{/each}
</ul>
</li>
{/each}
</ul>
{/if}
{/await}
</div>
</div>
<style lang="scss">
@use "../styles/colors";
.groups {
text-align: center;
flex-grow: 1;
height: 0;
display: flex;
flex-direction: column;
}
.main {
overflow: hidden auto;
}
h2 {
margin-top: -0.66em;
}
ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 0.5em;
justify-content: space-between;
}
.group {
display: flex;
}
.count {
display: inline-block;
font-size: 0.66em;
margin-left: 0.25em;
}
.label {
font-weight: bold;
margin-bottom: 1em;
}
.duplicate {
display: flex;
gap: 1rem;
flex-wrap: wrap;
justify-content: center;
}
.duplicate-group {
flex-basis: 49%;
border-radius: 4px;
border: 1px solid var(--foreground);
padding: .5rem;
overflow-x: auto;
max-width: 100%;
ul {
flex-direction: column;
}
}
.groups.small {
ul {
flex-direction: column;
}
}
</style>

View File

@ -1,633 +0,0 @@
<script lang="ts">
import EntryView, { type Widget } from "./EntryView.svelte";
import { useEntity } from "../lib/entity";
import UpObject from "./display/UpObject.svelte";
import { createEventDispatcher } from "svelte";
import { derived, type Readable } from "svelte/store";
import { Query, type UpEntry } from "@upnd/upend";
import Spinner from "./utils/Spinner.svelte";
import NotesEditor from "./utils/NotesEditor.svelte";
import type { WidgetChange } from "../types/base";
import type { Address, EntityInfo } from "@upnd/upend/types";
import IconButton from "./utils/IconButton.svelte";
import BlobViewer from "./display/BlobViewer.svelte";
import { i18n } from "../i18n";
import EntryList from "./widgets/EntryList.svelte";
import api from "../lib/api";
import EntityList from "./widgets/EntityList.svelte";
import {
ATTR_IN,
ATTR_KEY,
ATTR_LABEL,
ATTR_OF,
} from "@upnd/upend/constants";
import InspectGroups from "./InspectGroups.svelte";
import InspectTypeEditor from "./InspectTypeEditor.svelte";
import LabelBorder from "./utils/LabelBorder.svelte";
import { debug } from "debug";
import { Any } from "@upnd/upend/query";
const dbg = debug("kestrel:Inspect");
const dispatch = createEventDispatcher();
export let address: string;
export let detail: boolean;
let showAsEntries = false;
let highlightedType: string | undefined;
let blobHandled = false;
$: ({ entity, entityInfo, error, revalidate } = useEntity(address));
$: allTypes = derived(
entityInfo,
($entityInfo, set) => {
getAllTypes($entityInfo).then((allTypes) => {
set(allTypes);
});
},
{},
) as Readable<{
[key: string]: {
labels: string[];
attributes: string[];
};
}>;
$: sortedTypes = Object.entries($allTypes)
.sort(([a, _], [b, __]) => a.localeCompare(b))
.sort(([_, a], [__, b]) => a.attributes.length - b.attributes.length);
async function getAllTypes(entityInfo: EntityInfo) {
const allTypes = {};
if (!entityInfo) {
return {};
}
const typeAddresses: string[] = [
await api.getAddress(entityInfo.t),
...($entity?.attr[ATTR_IN] || []).map((e) => e.value.c as string),
];
const typeAddressesIn = typeAddresses.map((addr) => `@${addr}`).join(" ");
const labelsQuery = await api.query(
`(matches (in ${typeAddressesIn}) "${ATTR_LABEL}" ?)`,
);
typeAddresses.forEach((address) => {
let labels = labelsQuery.getObject(address).identify();
let typeLabel: string | undefined;
if (typeLabel) {
labels.unshift(typeLabel);
}
allTypes[address] = {
labels,
attributes: [],
};
});
const attributes = await api.query(
`(matches ? "${ATTR_OF}" (in ${typeAddressesIn}))`,
);
await Promise.all(
typeAddresses.map(async (address) => {
allTypes[address].attributes = (
await Promise.all(
(attributes.getObject(address).attr[`~${ATTR_OF}`] || []).map(
async (e) => {
try {
const { t, c } = await api.addressToComponents(e.entity);
if (t == "Attribute") {
return c;
}
} catch (err) {
console.error(err);
return false;
}
},
),
)
).filter(Boolean);
}),
);
const result = {};
Object.keys(allTypes).forEach((addr) => {
if (allTypes[addr].attributes.length > 0) {
result[addr] = allTypes[addr];
}
});
return result;
}
let untypedProperties = [] as UpEntry[];
let untypedLinks = [] as UpEntry[];
$: {
untypedProperties = [];
untypedLinks = [];
($entity?.attributes || []).forEach((entry) => {
const entryTypes = Object.entries($allTypes || {}).filter(([_, t]) =>
t.attributes.includes(entry.attribute),
);
if (entryTypes.length === 0) {
if (entry.value.t === "Address") {
untypedLinks.push(entry);
} else {
untypedProperties.push(entry);
}
}
});
untypedProperties = untypedProperties;
untypedLinks = untypedLinks;
}
$: filteredUntypedProperties = untypedProperties.filter(
(entry) =>
![
ATTR_LABEL,
ATTR_IN,
ATTR_KEY,
"NOTE",
"LAST_VISITED",
"NUM_VISITED",
"LAST_ATTRIBUTE_WIDGET",
].includes(entry.attribute),
);
$: currentUntypedProperties = filteredUntypedProperties;
$: filteredUntypedLinks = untypedLinks.filter(
(entry) => ![ATTR_IN, ATTR_OF].includes(entry.attribute),
);
$: currentUntypedLinks = filteredUntypedLinks;
$: currentBacklinks =
$entity?.backlinks.filter(
(entry) => ![ATTR_IN, ATTR_OF].includes(entry.attribute),
) || [];
$: tagged = $entity?.attr[`~${ATTR_IN}`] || [];
let attributesUsed: UpEntry[] = [];
$: {
if ($entityInfo?.t === "Attribute") {
api
.query(`(matches ? "${$entityInfo.c}" ?)`)
.then((result) => (attributesUsed = result.entries));
}
}
let correctlyTagged: Address[] | undefined;
let incorrectlyTagged: Address[] | undefined;
$: {
if ($entity?.attr[`~${ATTR_OF}`]) {
fetchCorrectlyTagged();
}
}
async function fetchCorrectlyTagged() {
const attributes = (
await Promise.all(
$entity?.attr[`~${ATTR_OF}`].map((e) =>
api.addressToComponents(e.entity),
),
)
)
.filter((ac) => ac.t == "Attribute")
.map((ac) => ac.c);
const attributeQuery = await api.query(
Query.matches(
tagged.map((t) => `@${t.entity}`),
attributes,
Any,
),
);
correctlyTagged = [];
incorrectlyTagged = [];
for (const element of tagged) {
const entity = attributeQuery.getObject(element.entity);
if (attributes.every((attr) => entity.attr[attr])) {
correctlyTagged = [...correctlyTagged, element.entity];
} else {
incorrectlyTagged = [...incorrectlyTagged, element.entity];
}
}
}
async function onChange(ev: CustomEvent<WidgetChange>) {
dbg("onChange", ev.detail);
const change = ev.detail;
switch (change.type) {
case "create":
await api.putEntry({
entity: address,
attribute: change.attribute,
value: change.value,
});
break;
case "delete":
await api.deleteEntry(change.address);
break;
case "update":
await api.putEntityAttribute(address, change.attribute, change.value);
break;
case "entry-add":
await api.putEntry({
entity: change.address,
attribute: ATTR_IN,
value: { t: "Address", c: 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;
}
revalidate();
}
let identities = [address];
function onResolved(ev: CustomEvent<string[]>) {
identities = ev.detail;
dispatch("resolved", ev.detail);
}
async function deleteObject() {
if (confirm(`${$i18n.t("Really delete")} "${identities.join(" | ")}"?`)) {
await api.deleteEntry(address);
dispatch("close");
}
}
const attributeWidgets: Widget[] = [
{
name: "List",
icon: "list-check",
components: ({ entries }) => [
{
component: EntryList,
props: {
entries,
columns: "attribute, value",
},
},
],
},
];
const linkWidgets: Widget[] = [
{
name: "List",
icon: "list-check",
components: ({ entries, group }) => [
{
component: EntryList,
props: {
entries,
columns: "attribute, value",
attributes: $allTypes[group]?.attributes || [],
},
},
],
},
{
name: "Entity List",
icon: "image",
components: ({ entries, address }) => [
{
component: EntityList,
props: {
address,
entities: entries
.filter((e) => e.value.t == "Address")
.map((e) => e.value.c),
thumbnails: true,
},
},
],
},
];
const taggedWidgets: Widget[] = [
{
name: "List",
icon: "list-check",
components: ({ entries, address }) => [
{
component: EntityList,
props: {
address,
entities: entries.map((e) => e.entity),
thumbnails: false,
},
},
],
},
{
name: "EntityList",
icon: "image",
components: ({ entries, address }) => [
{
component: EntityList,
props: {
address,
entities: entries.map((e) => e.entity),
thumbnails: true,
},
},
],
},
];
$: entity.subscribe(async (object) => {
if (object && object.listing.entries.length) {
dbg("Updating visit stats for %o", object);
await api.putEntityAttribute(
object.address,
"LAST_VISITED",
{
t: "Number",
c: new Date().getTime() / 1000,
},
"IMPLICIT",
);
await api.putEntityAttribute(
object.address,
"NUM_VISITED",
{
t: "Number",
c: (parseInt(String(object.get("NUM_VISITED"))) || 0) + 1,
},
"IMPLICIT",
);
}
});
</script>
<div
class="inspect"
class:detail
class:blob={blobHandled}
data-address-multi={($entity?.attr["~IN"]?.map((e) => e.entity) || []).join(
",",
)}
>
<header>
<h2>
{#if $entity}
<UpObject banner {address} on:resolved={onResolved} />
{:else}
<Spinner centered />
{/if}
</h2>
</header>
{#if !showAsEntries}
<div class="main-content">
<div class="detail-col">
<div class="blob-viewer">
<BlobViewer
{address}
{detail}
on:handled={(ev) => (blobHandled = ev.detail)}
/>
</div>
{#if !$error}
<InspectGroups
{entity}
on:highlighted={(ev) => (highlightedType = ev.detail)}
on:change={() => revalidate()}
/>
<div class="properties">
<NotesEditor {address} on:change={onChange} />
<InspectTypeEditor {entity} on:change={() => revalidate()} />
{#each sortedTypes as [typeAddr, { labels, attributes }]}
<EntryView
entries={($entity?.attributes || []).filter((e) =>
attributes.includes(e.attribute),
)}
widgets={linkWidgets}
on:change={onChange}
highlighted={highlightedType == typeAddr}
title={labels.join(" | ")}
group={typeAddr}
{address}
/>
{/each}
{#if currentUntypedProperties.length > 0}
<EntryView
title={$i18n.t("Other Properties")}
widgets={attributeWidgets}
entries={currentUntypedProperties}
on:change={onChange}
{address}
/>
{/if}
{#if currentUntypedLinks.length > 0}
<EntryView
title={$i18n.t("Links")}
widgets={linkWidgets}
entries={currentUntypedLinks}
on:change={onChange}
{address}
/>
{/if}
{#if !correctlyTagged || !incorrectlyTagged}
<EntryView
title={`${$i18n.t("Members")}`}
widgets={taggedWidgets}
entries={tagged}
on:change={onChange}
{address}
/>
{:else}
<EntryView
title={`${$i18n.t("Typed Members")} (${
correctlyTagged.length
})`}
widgets={taggedWidgets}
entries={tagged.filter((e) =>
correctlyTagged.includes(e.entity),
)}
on:change={onChange}
{address}
/>
<EntryView
title={`${$i18n.t("Untyped members")} (${
incorrectlyTagged.length
})`}
widgets={taggedWidgets}
entries={tagged.filter((e) =>
incorrectlyTagged.includes(e.entity),
)}
on:change={onChange}
{address}
/>
{/if}
{#if currentBacklinks.length > 0}
<EntryView
title={`${$i18n.t("Referred to")} (${currentBacklinks.length})`}
entries={currentBacklinks}
on:change={onChange}
{address}
/>
{/if}
{#if $entityInfo?.t === "Attribute"}
<LabelBorder>
<span slot="header"
>{$i18n.t("Used")} ({attributesUsed.length})</span
>
<EntryList
columns="entity,value"
entries={attributesUsed}
orderByValue
/>
</LabelBorder>
{/if}
</div>
{:else}
<div class="error">
{$error}
</div>
{/if}
</div>
</div>
{:else}
<div class="entries">
<h2>{$i18n.t("Attributes")}</h2>
<EntryList
entries={$entity.attributes}
columns={detail
? "timestamp, provenance, attribute, value"
: "attribute, value"}
on:change={onChange}
/>
<h2>{$i18n.t("Backlinks")}</h2>
<EntryList
entries={$entity.backlinks}
columns={detail
? "timestamp, provenance, entity, attribute"
: "entity, attribute"}
on:change={onChange}
/>
</div>
{/if}
<div class="footer">
<IconButton
name="detail"
title={$i18n.t("Show as entries")}
active={showAsEntries}
on:click={() => (showAsEntries = !showAsEntries)}
/>
</div>
<IconButton
name="trash"
outline
subdued
color="#dc322f"
on:click={deleteObject}
title={$i18n.t("Delete object")}
/>
</div>
<style lang="scss">
header h2 {
margin-bottom: 0;
}
.inspect,
.main-content {
flex: auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
min-height: 0;
}
.properties {
flex: auto;
height: 0; // https://stackoverflow.com/a/14964944
min-height: 12em;
overflow-y: auto;
padding-right: 1rem;
}
@media screen and (min-width: 1600px) {
.inspect.detail {
.main-content {
position: relative;
flex-direction: row;
justify-content: end;
}
&.blob {
.detail-col {
width: 33%;
flex-grow: 0;
}
.blob-viewer {
width: 65%;
height: 100%;
position: absolute;
left: 1%;
top: 0;
}
}
}
}
.main-content .detail-col {
display: flex;
flex-direction: column;
flex-grow: 1;
}
.entries {
flex-grow: 1;
}
.footer {
margin-top: 2rem;
display: flex;
justify-content: end;
}
.buttons {
display: flex;
}
.error {
color: red;
}
</style>

View File

@ -1,44 +0,0 @@
<script lang="ts">
import api from "../lib/api";
import { ATTR_IN } from "@upnd/upend/constants";
import { createEventDispatcher } from "svelte";
import type { UpObject } from "@upnd/upend";
import type { Readable } from "svelte/store";
import EntitySetEditor from "./EntitySetEditor.svelte";
import { i18n } from "../i18n";
const dispatch = createEventDispatcher();
export let entity: Readable<UpObject>;
$: groups = Object.fromEntries(
($entity?.attr[ATTR_IN] || []).map((e) => [e.value.c as string, e.address]),
);
async function addGroup(address: string) {
await api.putEntry([
{
entity: $entity.address,
attribute: ATTR_IN,
value: {
t: "Address",
c: address,
},
},
]);
dispatch("change");
}
async function removeGroup(address: string) {
await api.deleteEntry(groups[address]);
dispatch("change");
}
</script>
<EntitySetEditor
entities={Object.keys(groups)}
header={$i18n.t("Groups")}
hide={Object.keys(groups).length === 0}
on:add={(e) => addGroup(e.detail)}
on:remove={(e) => removeGroup(e.detail)}
on:highlighted
/>

View File

@ -1,134 +0,0 @@
<script lang="ts">
import UpObjectDisplay from "./display/UpObject.svelte";
import Selector, { type SelectorValue } from "./utils/Selector.svelte";
import IconButton from "./utils/IconButton.svelte";
import api from "../lib/api";
import { i18n } from "../i18n";
import type { UpObject, UpEntry } from "@upnd/upend";
import type { Readable } from "svelte/store";
import { ATTR_OF } from "@upnd/upend/constants";
import { createEventDispatcher } from "svelte";
import LabelBorder from "./utils/LabelBorder.svelte";
const dispatch = createEventDispatcher();
export let entity: Readable<UpObject>;
let adding = false;
let typeSelector: Selector;
$: if (adding && typeSelector) typeSelector.focus();
$: typeEntries = $entity?.attr[`~${ATTR_OF}`] || [];
async function add(ev: CustomEvent<SelectorValue>) {
if (ev.detail.t !== "Attribute") {
return;
}
await api.putEntry({
entity: {
t: "Attribute",
c: ev.detail.name,
},
attribute: ATTR_OF,
value: { t: "Address", c: $entity.address },
});
dispatch("change");
}
async function remove(entry: UpEntry) {
let really = confirm(
$i18n.t('Really remove "{{attributeName}}" from "{{typeName}}"?', {
attributeName: (await api.addressToComponents(entry.entity)).c,
typeName: $entity.identify().join("/"),
}),
);
if (really) {
await api.deleteEntry(entry.address);
dispatch("change");
}
}
</script>
{#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}
types={["Attribute", "NewAttribute"]}
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">
<div class="label">
<UpObjectDisplay address={typeEntry.entity} link />
</div>
<div class="controls">
<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>
<div class="add-button">
<IconButton
outline
small
name="plus-circle"
on:click={() => (adding = true)}
/>
</div>
</div>
</LabelBorder>
{/if}
<style lang="scss">
.attributes {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0.25em;
}
.attribute {
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;
margin: 0;
}
</style>

View File

@ -1,74 +0,0 @@
<script lang="ts">
import api from "../lib/api";
import { ATTR_IN } from "@upnd/upend/constants";
import { i18n } from "../i18n";
import { Query, UpListing } from "@upnd/upend";
import EntitySetEditor from "./EntitySetEditor.svelte";
import { Any } from "@upnd/upend/query";
export let entities: string[];
let groups = [];
let groupListing: UpListing | undefined = undefined;
async function updateGroups() {
const currentEntities = entities.concat();
const allGroups = await api.query(
Query.matches(
currentEntities.map((e) => `@${e}`),
ATTR_IN,
Any,
),
);
const commonGroups = new Set(
allGroups.values
.filter((v) => v.t == "Address")
.map((v) => v.c)
.filter((groupAddr) => {
return Object.values(allGroups.objects).every((obj) => {
return obj.attr[ATTR_IN].some((v) => v.value.c === groupAddr);
});
}),
);
if (entities.toString() == currentEntities.toString()) {
groups = Array.from(commonGroups);
groupListing = allGroups;
}
}
$: entities && updateGroups();
async function addGroup(address: string) {
await api.putEntry(
entities.map((entity) => ({
entity,
attribute: ATTR_IN,
value: {
t: "Address",
c: address,
},
})),
);
await updateGroups();
}
async function removeGroup(address: string) {
await Promise.all(
entities.map((entity) =>
api.deleteEntry(
groupListing.objects[entity].attr[ATTR_IN].find(
(v) => v.value.c === address,
).address,
),
),
);
await updateGroups();
}
</script>
<EntitySetEditor
entities={groups}
header={$i18n.t("Common groups")}
on:add={(ev) => addGroup(ev.detail)}
on:remove={(ev) => removeGroup(ev.detail)}
/>

View File

@ -1,457 +0,0 @@
<script lang="ts">
import UpObject from "./display/UpObject.svelte";
import api from "../lib/api";
import Selector, { type SelectorValue } from "./utils/Selector.svelte";
import { createEventDispatcher, onMount, tick } from "svelte";
import type { ZoomBehavior, ZoomTransform, Selection } from "d3";
import Spinner from "./utils/Spinner.svelte";
import UpObjectCard from "./display/UpObjectCard.svelte";
import BlobPreview from "./display/BlobPreview.svelte";
import SurfacePoint from "./display/SurfacePoint.svelte";
import { i18n } from "../i18n";
import debug from "debug";
import { Query } from "@upnd/upend";
import { Any } from "@upnd/upend/query";
const dbg = debug("kestrel:surface");
const dispatch = createEventDispatcher();
export let x: string | undefined = undefined;
export let y: string | undefined = undefined;
$: dispatch("updateAddress", { x, y });
let loaded = false;
let viewMode = "point";
let currentX = NaN;
let currentY = NaN;
let zoom: ZoomBehavior<Element, unknown> | undefined;
let autofit: () => void | undefined;
let view: Selection<HTMLElement, unknown, null, undefined>;
let viewEl: HTMLElement | undefined;
let viewHeight = 0;
let viewWidth = 0;
let selector: Selector | undefined;
$: if (selector) selector.focus();
$: {
if ((x && !y) || (!x && y)) findPerpendicular();
}
async function findPerpendicular() {
const presentAxis = x || y;
const presentAxisAddress = await api.componentsToAddress({
t: "Attribute",
c: presentAxis,
});
const result = await api.query(
Query.or(
Query.matches(`@${presentAxisAddress}`, "PERPENDICULAR", Any),
Query.matches(Any, "PERPENDICULAR", `@${presentAxisAddress}`),
),
);
const perpendicular = [
...result.entries.map((e) => e.entity),
...result.values
.filter((v) => v.t === "Address")
.map((v) => v.c as string),
].find((address) => address !== presentAxisAddress);
if (perpendicular) {
const perpendicularComponents =
await api.addressToComponents(perpendicular);
if (perpendicularComponents.t !== "Attribute") return;
const perpendicularName = perpendicularComponents.c;
if (x) {
y = perpendicularName;
} else {
x = perpendicularName;
}
}
}
interface IPoint {
address: string;
x: number;
y: number;
}
let points: IPoint[] = [];
async function loadPoints() {
points = [];
const result = await api.query(`(matches ? (in "${x}" "${y}") ?)`);
points = Object.entries(result.objects)
.map(([address, obj]) => {
let objX = parseInt(String(obj.get(x)));
let objY = parseInt(String(obj.get(y)));
if (objX && objY) {
return {
address,
x: objX,
y: objY,
};
}
})
.filter(Boolean);
tick().then(() => {
autofit();
});
}
$: {
if (x && y) {
loadPoints();
}
}
let selectorCoords: [number, number] | null = null;
onMount(async () => {
const d3 = await import("d3");
function init() {
viewWidth = viewEl.clientWidth;
viewHeight = viewEl.clientHeight;
dbg("Initializing Surface view: %dx%d", viewWidth, viewHeight);
view = d3.select(viewEl);
const svg = view.select("svg");
if (svg.empty()) {
throw new Error(
"Failed initializing Surface - couldn't locate SVG element",
);
}
svg.selectAll("*").remove();
const xScale = d3
.scaleLinear()
.domain([0, viewWidth])
.range([0, viewWidth]);
const yScale = d3
.scaleLinear()
.domain([0, viewHeight])
.range([viewHeight, 0]);
let xTicks = 10;
let yTicks = viewHeight / (viewWidth / xTicks);
const xAxis = d3
.axisBottom(xScale)
.ticks(xTicks)
.tickSize(viewHeight)
.tickPadding(5 - viewHeight);
const yAxis = d3
.axisRight(yScale)
.ticks(yTicks)
.tickSize(viewWidth)
.tickPadding(5 - viewWidth);
const gX = svg.append("g").call(xAxis);
const gY = svg.append("g").call(yAxis);
zoom = d3.zoom().on("zoom", zoomed);
function zoomed({ transform }: { transform: ZoomTransform }) {
const points = view.select(".content");
points.style(
"transform",
`translate(${transform.x}px, ${transform.y}px) scale(${transform.k})`,
);
const allPoints = view.selectAll(".point");
allPoints.style("transform", `scale(${1 / transform.k})`);
gX.call(xAxis.scale(transform.rescaleX(xScale)));
gY.call(yAxis.scale(transform.rescaleY(yScale)));
updateStyles();
}
autofit = () => {
zoom.translateTo(view, 0, viewHeight);
if (points.length) {
zoom.scaleTo(
view,
Math.min(
viewWidth / 2 / Math.max(...points.map((p) => Math.abs(p.x))) -
0.3,
viewHeight / 2 / Math.max(...points.map((p) => Math.abs(p.y))) -
0.3,
),
);
}
};
function updateStyles() {
svg
.selectAll(".tick line")
.attr("stroke-width", (d: number) => {
return d === 0 ? 2 : 1;
})
.attr("stroke", function (d: number) {
return d === 0
? "var(--foreground-lightest)"
: "var(--foreground-lighter)";
});
}
// function reset() {
// svg.transition().duration(750).call(zoom.transform, d3.zoomIdentity);
// }
view.on("mousemove", (ev: MouseEvent) => {
// not using offsetXY because `translate` transforms on .inner mess it up
const viewBBox = (view.node() as HTMLElement).getBoundingClientRect();
const [x, y] = d3
.zoomTransform(view.select(".content").node() as HTMLElement)
.invert([ev.clientX - viewBBox.left, ev.clientY - viewBBox.top]);
currentX = xScale.invert(x);
currentY = yScale.invert(y);
});
d3.select(viewEl)
.call(zoom)
.on("dblclick.zoom", (_ev: MouseEvent) => {
selectorCoords = [currentX, currentY];
});
autofit();
loaded = true;
}
const resizeObserver = new ResizeObserver(() => {
tick().then(() => init());
});
resizeObserver.observe(viewEl);
});
async function onSelectorInput(ev: CustomEvent<SelectorValue>) {
const value = ev.detail;
if (value.t !== "Address") return;
const address = value.c;
const [xValue, yValue] = selectorCoords;
selectorCoords = null;
await Promise.all(
[
[x, xValue],
[y, yValue],
].map(([axis, value]: [string, number]) =>
api.putEntityAttribute(address, axis, {
t: "Number",
c: value,
}),
),
);
await loadPoints();
}
</script>
<div class="surface">
<div class="header ui">
<div class="axis-selector">
<div class="label">X</div>
<Selector
types={["Attribute", "NewAttribute"]}
initial={x ? { t: "Attribute", name: x } : undefined}
on:input={(ev) => {
if (ev.detail.t === "Attribute") x = ev.detail.name;
}}
/>
<div class="value">
{(Math.round(currentX * 100) / 100).toLocaleString("en", {
useGrouping: false,
minimumFractionDigits: 2,
})}
</div>
</div>
<div class="axis-selector">
<div class="label">Y</div>
<Selector
types={["Attribute", "NewAttribute"]}
initial={y ? { t: "Attribute", name: y } : undefined}
on:input={(ev) => {
if (ev.detail.t === "Attribute") y = ev.detail.name;
}}
/>
<div class="value">
{(Math.round(currentY * 100) / 100).toLocaleString("en", {
useGrouping: false,
minimumFractionDigits: 2,
})}
</div>
</div>
</div>
<div class="view" class:loaded bind:this={viewEl}>
<div class="ui view-mode-selector">
<div class="label">
{$i18n.t("View as")}
</div>
<select bind:value={viewMode}>
<option value="point">{$i18n.t("Point")}</option>
<option value="link">{$i18n.t("Link")}</option>
<option value="card">{$i18n.t("Card")}</option>
<!-- <option value="preview">{$i18n.t("Preview")}</option> -->
</select>
</div>
{#if !loaded}
<div class="loading">
<Spinner centered="absolute" />
</div>
{/if}
<div class="content">
{#if selectorCoords !== null}
<div
class="point selector"
style="
left: {selectorCoords[0]}px;
top: {viewHeight - selectorCoords[1]}px"
>
<Selector
types={["Address", "NewAddress"]}
on:input={onSelectorInput}
on:focus={(ev) => {
if (!ev.detail) selectorCoords = null;
}}
bind:this={selector}
/>
</div>
{/if}
{#each points as point}
<div
class="point"
style="left: {point.x}px; top: {viewHeight - point.y}px"
>
<div class="inner">
{#if viewMode == "link"}
<UpObject link address={point.address} />
{:else if viewMode == "card"}
<UpObjectCard address={point.address} />
{:else if viewMode == "preview"}
<BlobPreview address={point.address} />
{:else if viewMode == "point"}
<SurfacePoint address={point.address} />
{/if}
</div>
</div>
{/each}
</div>
<svg />
</div>
</div>
<style lang="scss">
.surface {
display: flex;
flex-direction: column;
height: 100%;
}
.header {
display: flex;
flex-wrap: wrap;
gap: 1em;
align-items: center;
justify-content: space-between;
margin: 0.5em 0;
.axis-selector {
display: flex;
gap: 1em;
align-items: center;
.label {
font-size: 1rem;
&::after {
content: ":";
}
}
}
}
.view {
flex-grow: 1;
position: relative;
overflow: hidden;
:global(svg) {
width: 100%;
height: 100%;
}
:global(.tick text) {
color: var(--foreground-lightest);
font-size: 1rem;
text-shadow: 0 0 0.25em var(--background);
}
.content {
transform-origin: 0 0;
}
.point {
position: absolute;
transform-origin: 0 0;
.inner {
transform: translate(-50%, -50%);
}
&:hover {
z-index: 99;
}
}
.view-mode-selector {
position: absolute;
top: 2rem;
right: 1.5em;
padding: 0.66em;
border-radius: 4px;
border: 1px solid var(--foreground-lighter);
background: var(--background);
opacity: 0.66;
transition: opacity 0.25s;
&:hover {
opacity: 1;
}
}
&:not(.loaded) {
pointer-events: none;
}
}
.view-mode-selector {
display: flex;
flex-direction: column;
gap: 0.5em;
align-items: center;
}
.ui {
font-size: 0.8rem;
}
.loading {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
z-index: 99;
transform: scale(3);
}
</style>

View File

@ -1,208 +0,0 @@
<script lang="ts">
import { useEntity } from "../../lib/entity";
import Spinner from "../utils/Spinner.svelte";
import FragmentViewer from "./blobs/FragmentViewer.svelte";
import ModelViewer from "./blobs/ModelViewer.svelte";
import VideoViewer from "./blobs/VideoViewer.svelte";
import HashBadge from "./HashBadge.svelte";
import api from "../../lib/api";
import { createEventDispatcher } from "svelte";
import { getTypes } from "../../util/mediatypes";
import { concurrentImage } from "../imageQueue";
import { ATTR_IN } from "@upnd/upend/constants";
import AudioPreview from "./blobs/AudioPreview.svelte";
const dispatch = createEventDispatcher();
export let address: string;
export let recurse = 3;
$: ({ entity, entityInfo } = useEntity(address));
$: types = $entity && getTypes($entity, $entityInfo);
$: handled =
types &&
(!$entity ||
types.audio ||
types.video ||
types.image ||
types.text ||
types.model ||
types.web ||
types.fragment ||
(types.group && recurse > 0));
$: dispatch("handled", handled);
let loaded = null;
$: dispatch("loaded", Boolean(loaded));
let failedChildren: string[] = [];
let loadedChildren: string[] = [];
$: groupChildren = $entity?.backlinks
.filter((e) => e.attribute === ATTR_IN)
.map((e) => String(e.entity))
.filter(
(addr) =>
!failedChildren
.slice(
0,
$entity?.backlinks.filter((e) => e.attribute === ATTR_IN).length -
4,
)
.includes(addr),
)
.slice(0, 4);
$: if (groupChildren)
loaded = groupChildren.every(
(addr) => loadedChildren.includes(addr) || failedChildren.includes(addr),
);
</script>
<div class="preview">
{#if handled}
<div class="inner">
{#if !loaded}
<Spinner centered="absolute" />
{/if}
{#if types.group}
<ul class="group">
{#each groupChildren as address (address)}
<li>
<svelte:self
{address}
recurse={recurse - 1}
on:handled={(ev) => {
if (!ev.detail && !failedChildren.includes(address))
failedChildren = [...failedChildren, address];
}}
on:loaded={(ev) => {
if (ev.detail && !loadedChildren.includes(address))
loadedChildren = [...loadedChildren, address];
}}
/>
</li>
{/each}
</ul>
{:else if types.model}
<ModelViewer
lookonly
src="{api.apiUrl}/raw/{address}"
on:loaded={() => (loaded = address)}
/>
{:else if types.web}
<img
alt="OpenGraph image for {$entityInfo?.t == 'Url' && $entityInfo?.c}"
use:concurrentImage={String($entity?.get("OG_IMAGE"))}
on:load={() => (loaded = address)}
on:error={() => (handled = false)}
/>
{:else if types.fragment}
<FragmentViewer
{address}
detail={false}
on:loaded={() => (loaded = address)}
/>
{:else if types.audio}
<AudioPreview
{address}
on:loaded={() => (loaded = address)}
on:error={() => (handled = false)}
/>
{:else if types.video}
<VideoViewer
{address}
detail={false}
on:loaded={() => (loaded = address)}
/>
{:else}
<div class="image" class:loaded={loaded == address || !handled}>
<img
class:loaded={loaded == address}
alt="Thumbnail for {address}..."
use:concurrentImage={`${api.apiUrl}/${
types.mimeType?.includes("svg+xml") ? "raw" : "thumb"
}/${address}?size=512&quality=75`}
on:load={() => (loaded = address)}
on:error={() => (handled = false)}
/>
</div>
{/if}
</div>
{:else}
<div class="hashbadge">
<HashBadge {address} />
</div>
{/if}
</div>
<style lang="scss">
.preview {
flex-grow: 1;
min-height: 0;
display: flex;
flex-direction: column;
.inner {
display: flex;
min-height: 0;
flex-grow: 1;
justify-content: center;
}
}
.hashbadge {
font-size: 48px;
opacity: 0.25;
text-align: center;
line-height: 1;
}
.image {
display: flex;
min-height: 0;
min-width: 0;
justify-content: center;
img {
max-width: 100%;
object-fit: contain;
&:not(.loaded) {
flex-grow: 1;
height: 6rem;
max-height: 100%;
width: 100%;
min-width: 0;
}
}
}
.group {
padding: 0;
flex-grow: 1;
min-height: 0;
width: 100%;
min-width: 0;
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
padding: 0.25rem;
gap: 0.25rem;
border: 1px solid var(--foreground);
border-radius: 4px;
li {
display: flex;
flex-direction: column;
justify-content: end;
list-style: none;
min-height: 0;
min-width: 0;
}
}
</style>

View File

@ -1,121 +0,0 @@
<script lang="ts">
import { useEntity } from "../../lib/entity";
import Spinner from "../utils/Spinner.svelte";
import AudioViewer from "./blobs/AudioViewer.svelte";
import FragmentViewer from "./blobs/FragmentViewer.svelte";
import ImageViewer from "./blobs/ImageViewer.svelte";
import ModelViewer from "./blobs/ModelViewer.svelte";
import TextViewer from "./blobs/TextViewer.svelte";
import VideoViewer from "./blobs/VideoViewer.svelte";
import UpLink from "./UpLink.svelte";
import api from "../../lib/api";
import { createEventDispatcher } from "svelte";
import { getTypes } from "../../util/mediatypes";
const dispatch = createEventDispatcher();
export let address: string;
export let detail: boolean;
let handled = false;
$: ({ entity, entityInfo } = useEntity(address));
$: types = $entity && getTypes($entity, $entityInfo);
$: handled =
types &&
(types.audio ||
types.video ||
types.image ||
types.text ||
types.pdf ||
types.model ||
types.web ||
types.fragment);
$: dispatch("handled", handled);
let imageLoaded = null;
</script>
{#if handled}
<div class="preview" class:detail>
{#if types.text}
<div class="text-viewer">
<TextViewer {address} />
</div>
{/if}
{#if types.audio}
<AudioViewer {address} {detail} />
{/if}
{#if types.video}
<VideoViewer detail {address} />
{/if}
{#if types.image}
<ImageViewer {address} {detail} />
{/if}
{#if types.pdf}
<iframe
src="{api.apiUrl}/raw/{address}?inline"
title="PDF document of {address}"
/>
{/if}
{#if types.model}
<ModelViewer src="{api.apiUrl}/raw/{address}" />
{/if}
{#if types.web}
{#if imageLoaded != address}
<Spinner />
{/if}
<img
src={String($entity?.get("OG_IMAGE"))}
alt="OpenGraph image for {$entityInfo?.t == 'Url' && $entityInfo?.c}"
on:load={() => (imageLoaded = address)}
on:error={() => (handled = false)}
/>
{/if}
{#if types.fragment}
<UpLink passthrough to={{ entity: String($entity.get("ANNOTATES")) }}>
<FragmentViewer {address} {detail} />
</UpLink>
{/if}
</div>
{/if}
<style lang="scss">
.preview {
display: flex;
align-items: center;
flex-direction: column;
// min-height: 33vh;
max-height: 50vh;
&.detail {
height: 100%;
max-height: 100%;
flex-grow: 1;
// min-height: 0;
}
}
img,
.text-viewer {
width: 100%;
max-height: 100%;
}
iframe {
width: 99%;
flex-grow: 1;
}
.text-viewer {
display: flex;
margin-bottom: 2rem;
min-height: 0;
}
img {
object-fit: contain;
}
</style>

View File

@ -1,88 +0,0 @@
<script lang="ts">
import { getContext } from "svelte";
import { useNavigate, useLocation } from "svelte-navigator";
import { readable } from "svelte/store";
import type { Address, VALUE_TYPE } from "@upnd/upend/types";
import type { BrowseContext } from "../../util/browse";
import api from "../../lib/api";
const location = useLocation();
const navigate = useNavigate();
export let passthrough = false;
export let title: string | undefined = undefined;
export let text = false;
export let to: {
entity?: Address;
attribute?: string;
surfaceAttribute?: string;
value?: { t: VALUE_TYPE; c: string };
};
const NOOP = "#";
let targetHref = NOOP;
$: {
if (to.entity) {
targetHref = to.entity;
} else if (to.attribute) {
api
.componentsToAddress({ t: "Attribute", c: to.attribute })
.then((address) => {
targetHref = address;
});
} else if (to.surfaceAttribute) {
targetHref = `surface:${to.surfaceAttribute}`;
}
}
const context = getContext("browse") as BrowseContext | undefined;
const index = context ? context.index : readable(0);
const addresses = context ? context.addresses : readable([]);
function onClick(ev: MouseEvent) {
if ($location.pathname.startsWith("/browse")) {
let newAddresses = $addresses.concat();
// 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("/browse/" + newAddresses.join(","));
return true;
} else {
navigate(`/browse/${targetHref}`);
}
}
</script>
<a
class="uplink"
class:text
class:passthrough
class:unresolved={targetHref === NOOP}
href="/#/browse/{targetHref}"
on:click|preventDefault={onClick}
{title}
>
<slot />
</a>
<style lang="scss">
:global(.uplink) {
text-decoration: none;
max-width: 100%;
}
:global(.uplink.text) {
text-decoration: underline;
}
:global(.uplink.passthrough) {
display: contents;
}
:global(.uplink.unresolved) {
pointer-events: none;
}
</style>

View File

@ -1,396 +0,0 @@
<script lang="ts">
import { createEventDispatcher, getContext } from "svelte";
import HashBadge from "./HashBadge.svelte";
import UpObjectLabel from "./UpObjectLabel.svelte";
import UpLink from "./UpLink.svelte";
import Icon from "../utils/Icon.svelte";
import { readable, type Readable, writable } from "svelte/store";
import { notify, UpNotification } from "../../notifications";
import IconButton from "../utils/IconButton.svelte";
import { vaultInfo } from "../../util/info";
import type { BrowseContext } from "../../util/browse";
import { Query, type UpObject } from "@upnd/upend";
import type { ADDRESS_TYPE, EntityInfo } from "@upnd/upend/types";
import { useEntity } from "../../lib/entity";
import { i18n } from "../../i18n";
import api from "../../lib/api";
import {
ATTR_IN,
ATTR_KEY,
ATTR_LABEL,
HIER_ROOT_ADDR,
} from "@upnd/upend/constants";
import { selected } from "../EntitySelect.svelte";
import { Any } from "@upnd/upend/query";
const dispatch = createEventDispatcher();
export let address: string;
export let labels: string[] | undefined = undefined;
export let link = false;
export let banner = false;
export let resolve = !(labels || []).length || banner;
export let backpath = 0;
export let select = true;
export let plain = false;
let entity: Readable<UpObject> = readable(undefined);
let entityInfo: Readable<EntityInfo> = writable(undefined);
$: if (resolve) ({ entity, entityInfo } = useEntity(address));
$: if (!resolve)
entityInfo = readable(undefined, (set) => {
api.addressToComponents(address).then((info) => {
set(info);
});
});
let hasFile = false;
$: {
if ($entityInfo?.t == "Hash" && banner) {
fetch(api.getRaw(address), {
method: "HEAD",
}).then((response) => {
hasFile = response.ok;
});
}
}
// Identification
let inferredIds: string[] = [];
$: inferredIds = $entity?.identify() || [];
let addressIds: string[] = [];
$: resolving = inferredIds.concat(labels || []).length == 0 && !$entity;
$: fetchAddressLabels(address);
async function fetchAddressLabels(address: string) {
addressIds = [];
await Promise.all(
(["Hash", "Uuid", "Attribute", "Url"] as ADDRESS_TYPE[]).map(
async (t) => {
if ((await api.getAddress(t)) == address) {
addressIds.push(`∈ ${t}`);
}
},
),
);
addressIds = addressIds;
}
let displayLabel = address;
$: {
const allLabels = []
.concat(inferredIds)
.concat(addressIds)
.concat(labels || []);
displayLabel = Array.from(new Set(allLabels)).join(" | ");
if (!displayLabel && $entityInfo?.t === "Attribute") {
displayLabel = `${$entityInfo.c}`;
}
displayLabel = displayLabel || address;
}
$: dispatch("resolved", inferredIds);
// Resolved backpath
let resolvedBackpath: string[] = [];
$: if (backpath) resolveBackpath();
async function resolveBackpath() {
resolvedBackpath = [];
let levels = 0;
let current = address;
while (levels < backpath && current !== HIER_ROOT_ADDR) {
const parent = await api.query(
Query.matches(`@${current}`, ATTR_IN, Any),
);
if (parent.entries.length) {
current = parent.entries[0].value.c as string;
const label = await api.query(
Query.matches(`@${current}`, ATTR_LABEL, Any),
);
if (label.entries.length) {
resolvedBackpath = [
label.entries[0].value.c as string,
...resolvedBackpath,
];
}
}
levels++;
}
}
// Navigation highlights
const context = getContext("browse") as BrowseContext | undefined;
const index = context?.index || undefined;
const addresses = context?.addresses || readable([]);
// Native open
function nativeOpen() {
notify.emit(
"notification",
new UpNotification(
$i18n.t("Opening {{identity}} in a default native application...", {
identity: inferredIds[0] || address,
}),
),
);
api
.nativeOpen(address)
.then(async (response) => {
if (!response.ok) {
throw new Error(`${response.statusText} - ${await response.text()}`);
}
if (response.headers.has("warning")) {
const warningText = response.headers
.get("warning")
.split(" ")
.slice(2)
.join(" ");
notify.emit(
"notification",
new UpNotification(warningText, "warning"),
);
}
})
.catch((err) => {
notify.emit(
"notification",
new UpNotification(
$i18n.t("Failed to open in native application! ({{err}})", { err }),
"error",
),
);
});
}
</script>
<div
class="upobject"
class:left-active={address == $addresses[$index - 1]}
class:right-active={address == $addresses[$index + 1]}
class:selected={select && $selected.includes(address)}
class:plain
>
<div
class="address"
class:identified={inferredIds.length || addressIds.length || labels?.length}
class:banner
class:show-type={$entityInfo?.t === "Url" && !addressIds.length}
>
<HashBadge {address} />
<div class="separator" />
<div class="label" class:resolving title={displayLabel}>
<div class="label-inner">
{#if banner && hasFile}
<UpObjectLabel label={displayLabel} backpath={resolvedBackpath} />
{:else if link}
<UpLink to={{ entity: address }}>
<UpObjectLabel label={displayLabel} backpath={resolvedBackpath} />
</UpLink>
{:else}
<UpObjectLabel label={displayLabel} backpath={resolvedBackpath} />
{/if}
</div>
{#if $entity?.get(ATTR_KEY) && !$entity
?.get(ATTR_KEY)
?.toString()
?.startsWith("TYPE_")}
<div class="key">{$entity.get(ATTR_KEY)}</div>
{/if}
<div class="secondary">
<div class="type">
{$entityInfo?.t}
{#if $entityInfo?.t === "Url" || $entityInfo?.t === "Attribute"}
&mdash; {$entityInfo.c}
{/if}
</div>
</div>
</div>
{#if banner}
{#if $entityInfo?.t === "Attribute"}
<div class="icon">
<UpLink
to={{ surfaceAttribute: $entityInfo.c }}
title={$i18n.t("Open on surface")}
>
<Icon name="cross" />
</UpLink>
</div>
{/if}
{#if $entityInfo?.t == "Hash"}
<div
class="icon"
title={hasFile
? $i18n.t("Download as file")
: $i18n.t("File not present in vault")}
>
<a
class="link-button"
class:disabled={!hasFile}
href="{api.apiUrl}/raw/{address}"
download={inferredIds[0]}
>
<Icon name="download" />
</a>
</div>
{#if $vaultInfo?.desktop && hasFile}
<div class="icon">
<IconButton
name="window-alt"
on:click={nativeOpen}
title={$i18n.t("Open in default application...")}
/>
</div>
{/if}
{/if}
{/if}
</div>
</div>
<style lang="scss">
@use "../../styles/colors";
.upobject {
border-radius: 4px;
&.left-active {
background: linear-gradient(90deg, colors.$orange 0%, transparent 100%);
padding: 2px 0 2px 2px;
}
&.right-active {
background: linear-gradient(90deg, transparent 0%, colors.$orange 100%);
padding: 2px 2px 2px 0;
}
&.plain .address {
border: none;
background: none;
padding: 0;
}
}
.address {
display: flex;
align-items: center;
padding: 0.1em 0.25em;
font-family: var(--monospace-font);
line-break: anywhere;
background: var(--background-lighter);
border: 0.1em solid var(--foreground-lighter);
border-radius: 0.2em;
&.banner {
border: 0.12em solid var(--foreground);
padding: 0.5em 0.25em;
}
&.identified {
font-family: var(--default-font);
font-size: 0.95em;
line-break: auto;
}
.label {
display: flex;
flex-wrap: wrap;
align-items: baseline;
}
.label-inner {
max-width: 100%;
margin-right: 0.25em;
}
&.banner .label {
flex-direction: column;
gap: 0.1em;
}
.secondary {
font-size: 0.66em;
display: none;
opacity: 0.8;
}
.key {
font-family: var(--monospace-font);
color: colors.$yellow;
opacity: 0.8;
&:before {
content: "⌘";
margin-right: 0.1em;
}
}
&.banner .key {
font-size: 0.66em;
}
&:not(.banner) .key {
flex-grow: 1;
text-align: right;
}
&.show-type .secondary,
&.banner .secondary {
display: unset;
}
}
.label {
flex-grow: 1;
min-width: 0;
:global(a) {
text-decoration: none;
}
}
.separator {
width: 0.5em;
}
.icon {
margin: 0 0.1em;
}
.resolving {
opacity: 0.7;
}
.link-button {
opacity: 0.66;
transition:
opacity 0.2s,
color 0.2s;
&:hover {
opacity: 1;
color: var(--active-color, var(--primary));
}
}
.upobject {
transition:
margin 0.2s ease,
box-shadow 0.2s ease;
}
.selected {
margin: 0.12rem;
box-shadow: 0 0 0.1rem 0.11rem colors.$red;
}
.disabled {
pointer-events: none;
opacity: 0.7;
}
</style>

View File

@ -1,75 +0,0 @@
<script lang="ts">
import { useEntity } from "../../../lib/entity";
import api from "../../../lib/api";
import { createEventDispatcher } from "svelte";
import { formatDuration } from "../../../util/fragments/time";
import { concurrentImage } from "../../imageQueue";
const dispatch = createEventDispatcher();
export let address: string;
$: ({ entity } = useEntity(address));
let loaded = null;
let handled = true;
$: dispatch("handled", handled);
$: dispatch("loaded", Boolean(loaded));
let clientHeight = 0;
let clientWidth = 0;
$: fontSize = Math.min(clientHeight, clientWidth) * 0.66;
let mediaDuration = "";
$: {
let duration = $entity?.get("MEDIA_DURATION") as number | undefined;
if (duration) {
mediaDuration = formatDuration(duration);
}
}
</script>
<div class="audiopreview" bind:clientWidth bind:clientHeight>
<img
class:loaded={loaded === address}
alt="Thumbnail for {address}"
use:concurrentImage={`${api.apiUrl}/thumb/${address}?mime=audio`}
on:load={() => (loaded = address)}
on:error
/>
{#if mediaDuration}
<div class="duration" style="--font-size: {fontSize}px">
{mediaDuration}
</div>
{/if}
</div>
<style lang="scss">
.audiopreview {
position: relative;
width: 100%;
}
img {
width: 100%;
height: 100%;
&:not(.loaded) {
flex-grow: 1;
height: 6rem;
max-height: 100%;
width: 100%;
min-width: 0;
}
}
.duration {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: var(--font-size);
font-weight: bold;
color: var(--foreground-lightest);
text-shadow: 0px 0px 0.2em var(--background-lighter);
}
</style>

View File

@ -1,448 +0,0 @@
<script lang="ts">
import { debounce, throttle } from "lodash";
import { onMount } from "svelte";
import type { IValue } from "@upnd/upend/types";
import type WaveSurfer from "wavesurfer.js";
import type { Region, RegionParams } from "wavesurfer.js/src/plugin/regions";
import api from "../../../lib/api";
import { TimeFragment } from "../../../util/fragments/time";
import Icon from "../../utils/Icon.svelte";
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 "@upnd/upend/constants";
import debug from "debug";
const dbg = debug("kestrel:AudioViewer");
export let address: string;
export let detail: boolean;
let editable = false;
let containerEl: HTMLDivElement;
let timelineEl: HTMLDivElement;
let loaded = false;
let wavesurfer: WaveSurfer;
// Zoom handling
let zoom = 1;
const setZoom = throttle((level: number) => {
wavesurfer.zoom(level);
}, 250);
$: if (zoom && wavesurfer) setZoom(zoom);
// Annotations
const DEFAULT_ANNOTATION_COLOR = "#cb4b16";
type UpRegion = Region & { data: IValue };
let currentAnnotation: UpRegion | undefined;
async function loadAnnotations() {
const entity = await api.fetchEntity(address);
entity.backlinks
.filter((e) => e.attribute == "ANNOTATES")
.forEach(async (e) => {
const annotation = await api.fetchEntity(e.entity);
if (annotation.get("W3C_FRAGMENT_SELECTOR")) {
const fragment = TimeFragment.parse(
String(annotation.get("W3C_FRAGMENT_SELECTOR")),
);
if (fragment) {
wavesurfer.addRegion({
id: `ws-region-${e.entity}`,
color: annotation.get("COLOR") || DEFAULT_ANNOTATION_COLOR,
attributes: {
"upend-address": annotation.address,
label: annotation.get(ATTR_LABEL),
},
data: (annotation.attr["NOTE"] || [])[0]?.value,
...fragment,
} as RegionParams);
}
}
});
}
$: if (wavesurfer) {
if (editable) {
wavesurfer.enableDragSelection({ color: DEFAULT_ANNOTATION_COLOR });
} else {
wavesurfer.disableDragSelection();
}
Object.values(wavesurfer.regions.list).forEach((region) => {
region.update({ drag: editable, resize: editable });
});
}
async function updateAnnotation(region: Region) {
dbg("Updating annotation %o", region);
let entity = region.attributes["upend-address"];
// Newly created
if (!entity) {
let [_, newEntity] = await api.putEntry({
entity: {
t: "Uuid",
},
});
entity = newEntity;
const nextAnnotationIndex = Object.values(wavesurfer.regions.list).length;
const label = `Annotation #${nextAnnotationIndex}`;
region.update({
attributes: { label },
// incorrect types, `update()` does take `attributes`
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
}
if (region.attributes["label"]) {
await api.putEntityAttribute(entity, ATTR_LABEL, {
t: "String",
c: region.attributes["label"],
});
}
await api.putEntityAttribute(entity, "ANNOTATES", {
t: "Address",
c: address,
});
await api.putEntityAttribute(entity, "W3C_FRAGMENT_SELECTOR", {
t: "String",
c: new TimeFragment(region.start, region.end).toString(),
});
if (region.color !== DEFAULT_ANNOTATION_COLOR) {
await api.putEntityAttribute(entity, "COLOR", {
t: "String",
c: region.color,
});
}
if (Object.values(region.data).length) {
await api.putEntityAttribute(entity, "NOTE", region.data as IValue);
}
region.update({
attributes: {
"upend-address": entity,
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
}
const updateAnnotationDebounced = debounce(updateAnnotation, 250);
async function deleteAnnotation(region: Region) {
if (region.attributes["upend-address"]) {
await api.deleteEntry(region.attributes["upend-address"]);
}
}
let rootEl: HTMLElement;
onMount(async () => {
const WaveSurfer = await import("wavesurfer.js");
const TimelinePlugin = await import("wavesurfer.js/src/plugin/timeline");
const RegionsPlugin = await import("wavesurfer.js/src/plugin/regions");
const timelineColor = getComputedStyle(
document.documentElement,
).getPropertyValue("--foreground");
wavesurfer = WaveSurfer.default.create({
container: containerEl,
waveColor: "#dc322f",
progressColor: "#991c1a",
responsive: true,
backend: "MediaElement",
mediaControls: true,
normalize: true,
xhr: { cache: "force-cache" },
plugins: [
TimelinePlugin.default.create({
container: timelineEl,
primaryColor: timelineColor,
primaryFontColor: timelineColor,
secondaryColor: timelineColor,
secondaryFontColor: timelineColor,
}),
RegionsPlugin.default.create({}),
],
});
wavesurfer.on("ready", () => {
dbg("wavesurfer ready");
loaded = true;
loadAnnotations();
});
wavesurfer.on("region-created", async (region: UpRegion) => {
dbg("wavesurfer region-created", region);
// Updating here, because if `drag` and `resize` are passed during adding,
// updating no longer works.
region.update({ drag: editable, resize: editable });
// If the region was created from the UI
if (!region.attributes["upend-address"]) {
await updateAnnotation(region);
// currentAnnotation = region;
}
});
wavesurfer.on("region-updated", (region: UpRegion) => {
// dbg("wavesurfer region-updated", region);
currentAnnotation = region;
});
wavesurfer.on("region-update-end", (region: UpRegion) => {
dbg("wavesurfer region-update-end", region);
updateAnnotation(region);
currentAnnotation = region;
});
wavesurfer.on("region-removed", (region: UpRegion) => {
dbg("wavesurfer region-removed", region);
currentAnnotation = null;
deleteAnnotation(region);
});
// wavesurfer.on("region-in", (region: UpRegion) => {
// dbg("wavesurfer region-in", region);
// currentAnnotation = region;
// });
// wavesurfer.on("region-out", (region: UpRegion) => {
// dbg("wavesurfer region-out", region);
// if (currentAnnotation.id === region.id) {
// currentAnnotation = undefined;
// }
// });
wavesurfer.on("region-click", (region: UpRegion, _ev: MouseEvent) => {
dbg("wavesurfer region-click", region);
currentAnnotation = region;
});
wavesurfer.on("region-dblclick", (region: UpRegion, _ev: MouseEvent) => {
dbg("wavesurfer region-dblclick", region);
currentAnnotation = region;
setTimeout(() => wavesurfer.setCurrentTime(region.start));
});
try {
const peaksReq = await fetch(
`${api.apiUrl}/thumb/${address}?mime=audio&type=json`,
);
const peaks = await peaksReq.json();
wavesurfer.load(`${api.apiUrl}/raw/${address}`, peaks.data);
} catch (e) {
console.warn(`Failed to load peaks: ${e}`);
const entity = await api.fetchEntity(address);
if (
(parseInt(String(entity.get("FILE_SIZE"))) || 0) < 20_000_000 ||
confirm(
$i18n.t(
"File is large (>20 MiB) and UpEnd failed to load waveform from server. Generating the waveform locally may slow down your browser. Do you wish to proceed anyway?",
),
)
) {
console.warn(
`Failed to load peaks, falling back to client-side render...`,
);
wavesurfer.load(`${api.apiUrl}/raw/${address}`);
}
}
const drawBufferThrottled = throttle(() => wavesurfer.drawBuffer(), 200);
const resizeObserver = new ResizeObserver((_entries) => {
drawBufferThrottled();
});
resizeObserver.observe(rootEl);
});
</script>
<div class="audio" class:editable bind:this={rootEl}>
{#if !loaded}
<Spinner centered />
{/if}
{#if loaded}
<header>
<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
class="wavesurfer-timeline"
bind:this={timelineEl}
class:hidden={!detail}
/>
<div class="wavesurfer" bind:this={containerEl} />
{#if currentAnnotation}
<LabelBorder>
<span slot="header">{$i18n.t("Annotation")}</span>
{#if currentAnnotation.attributes["upend-address"]}
<UpObject
link
address={currentAnnotation.attributes["upend-address"]}
/>
{/if}
<div class="baseControls">
<div class="regionControls">
<div class="start">
Start: <input
type="number"
value={Math.round(currentAnnotation.start * 100) / 100}
disabled={!editable}
on:input={(ev) => {
currentAnnotation.update({
start: parseInt(ev.currentTarget.value),
});
updateAnnotationDebounced(currentAnnotation);
}}
/>
</div>
<div class="end">
End: <input
type="number"
value={Math.round(currentAnnotation.end * 100) / 100}
disabled={!editable}
on:input={(ev) => {
currentAnnotation.update({
end: parseInt(ev.currentTarget.value),
});
updateAnnotationDebounced(currentAnnotation);
}}
/>
</div>
<div class="color">
Color: <input
type="color"
value={currentAnnotation.color || DEFAULT_ANNOTATION_COLOR}
disabled={!editable}
on:input={(ev) => {
currentAnnotation.update({ color: ev.currentTarget.value });
updateAnnotation(currentAnnotation);
}}
/>
</div>
</div>
{#if editable}
<div class="existControls">
<IconButton
outline
name="trash"
on:click={() => currentAnnotation.remove()}
/>
<!-- <div class="button">
<Icon name="check" />
</div> -->
</div>
{/if}
</div>
<div class="content">
{#key currentAnnotation}
<Selector
types={["String", "Address"]}
initial={currentAnnotation.data}
disabled={!editable}
on:input={(ev) => {
currentAnnotation.update({ data: ev.detail });
updateAnnotation(currentAnnotation);
}}
/>
{/key}
</div>
</LabelBorder>
{/if}
</div>
<style lang="scss">
@use "../../../styles/colors";
.audio {
width: 100%;
}
header {
display: flex;
justify-content: space-between;
& > * {
flex-basis: 50%;
}
.zoom {
display: flex;
align-items: baseline;
input {
flex-grow: 1;
margin: 0 0.5em 1em 0.5em;
}
}
}
.baseControls,
.content {
margin: 0.5em 0;
}
.baseControls,
.regionControls,
.existControls {
display: flex;
gap: 0.5em;
}
.baseControls {
justify-content: space-between;
}
.regionControls div {
display: flex;
align-items: center;
gap: 0.25em;
}
input[type="number"] {
width: 6em;
}
.hidden {
display: none;
}
:global(.audio:not(.editable) .wavesurfer-handle) {
display: none;
}
:global(.wavesurfer-handle) {
background: var(--foreground-lightest) !important;
}
:global(.wavesurfer-region) {
opacity: 0.5;
}
</style>

View File

@ -1,63 +0,0 @@
<script lang="ts">
import { useEntity } from "../../../lib/entity";
import Spinner from "../../utils/Spinner.svelte";
export let address: string;
export let detail: boolean;
import { xywh } from "../../../util/fragments/xywh";
import { createEventDispatcher } from "svelte";
import api from "../../../lib/api";
const dispatch = createEventDispatcher();
const { entity } = useEntity(address);
$: objectAddress = String($entity?.get("ANNOTATES") || "");
$: imageFragment = String($entity?.get("W3C_FRAGMENT_SELECTOR")).includes(
"xywh="
);
let imageLoaded = false;
$: imageLoaded && dispatch("loaded");
$: if ($entity && !imageFragment) imageLoaded = true;
</script>
<div class="fragment-viewer">
{#if !imageLoaded}
<Spinner />
{/if}
{#if $entity}
{#if imageFragment}
<img
class="preview-image"
class:imageLoaded
src="{api.apiUrl}/{detail ? 'raw' : 'thumb'}/{objectAddress}#{$entity?.get(
'W3C_FRAGMENT_SELECTOR'
)}"
use:xywh
alt={address}
on:load={() => (imageLoaded = true)}
draggable="false"
/>
{/if}
{/if}
</div>
<style lang="scss">
@use "../../../styles/colors";
.fragment-viewer {
width: 100%;
display: flex;
justify-content: center;
min-height: 0;
}
img {
max-width: 100%;
box-sizing: border-box;
min-height: 0;
&.imageLoaded {
border: 2px dashed colors.$yellow;
}
}
</style>

View File

@ -1,322 +0,0 @@
<script lang="ts">
import type { IEntry } from "@upnd/upend/types";
import api from "../../../lib/api";
import { useEntity } from "../../../lib/entity";
import IconButton from "../../utils/IconButton.svelte";
import Spinner from "../../utils/Spinner.svelte";
import UpObject from "../UpObject.svelte";
import { ATTR_LABEL } from "@upnd/upend/constants";
import { i18n } from "../../../i18n";
export let address: string;
export let detail: boolean;
let editable = false;
const { entity } = useEntity(address);
let imageLoaded = false;
let imageEl: HTMLImageElement;
$: svg = Boolean($entity?.get("FILE_MIME")?.toString().includes("svg+xml"));
interface Annotorious {
addAnnotation: (a: W3cAnnotation) => void;
on: ((
e: "createAnnotation" | "deleteAnnotation",
c: (a: W3cAnnotation) => void,
) => void) &
((
e: "updateAnnotation",
c: (a: W3cAnnotation, b: W3cAnnotation) => void,
) => void);
clearAnnotations: () => void;
readOnly: boolean;
destroy: () => void;
}
interface W3cAnnotation {
type: "Annotation";
body: Array<{ type: "TextualBody"; value: string; purpose: "commenting" }>;
target: {
selector: {
type: "FragmentSelector";
conformsTo: "http://www.w3.org/TR/media-frags/";
value: string;
};
};
"@context": "http://www.w3.org/ns/anno.jsonld";
id: string;
}
let anno: Annotorious;
$: if (anno) anno.readOnly = !editable;
$: if (anno) {
anno.clearAnnotations();
$entity?.backlinks
.filter((e) => e.attribute == "ANNOTATES")
.forEach(async (e) => {
const annotation = await api.fetchEntity(e.entity);
if (annotation.get("W3C_FRAGMENT_SELECTOR")) {
anno.addAnnotation({
type: "Annotation",
body: annotation.attr[ATTR_LABEL].map((e) => {
return {
type: "TextualBody",
value: String(e.value.c),
purpose: "commenting",
};
}),
target: {
selector: {
type: "FragmentSelector",
conformsTo: "http://www.w3.org/TR/media-frags/",
value: String(annotation.get("W3C_FRAGMENT_SELECTOR")),
},
},
"@context": "http://www.w3.org/ns/anno.jsonld",
id: e.entity,
});
}
});
}
$: hasAnnotations = $entity?.backlinks.some(
(e) => e.attribute === "ANNOTATES",
);
let a8sLinkTarget: HTMLDivElement;
let a8sLinkAddress: string;
async function loaded() {
const { Annotorious } = await import("@recogito/annotorious");
if (anno) {
anno.destroy();
}
anno = new Annotorious({
image: imageEl,
drawOnSingleClick: true,
fragmentUnit: "percent",
widgets: [
"COMMENT",
(info: { annotation: W3cAnnotation }) => {
a8sLinkAddress = info.annotation?.id;
return a8sLinkTarget;
},
],
});
anno.on("createAnnotation", async (annotation) => {
const [_, uuid] = await api.putEntry({
entity: {
t: "Uuid",
},
});
annotation.id = uuid;
await api.putEntry([
{
entity: uuid,
attribute: "ANNOTATES",
value: {
t: "Address",
c: address,
},
},
{
entity: uuid,
attribute: "W3C_FRAGMENT_SELECTOR",
value: {
t: "String",
c: annotation.target.selector.value,
},
},
...annotation.body.map((body) => {
return {
entity: uuid,
attribute: ATTR_LABEL,
value: {
t: "String",
c: body.value,
},
} as IEntry;
}),
]);
});
anno.on("updateAnnotation", async (annotation) => {
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)),
);
await api.putEntry([
{
entity: annotation.id,
attribute: "W3C_FRAGMENT_SELECTOR",
value: {
t: "String",
c: annotation.target.selector.value,
},
},
...annotation.body.map((body) => {
return {
entity: annotation.id,
attribute: ATTR_LABEL,
value: {
t: "String",
c: body.value,
},
} as IEntry;
}),
]);
});
anno.on("deleteAnnotation", async (annotation) => {
await api.deleteEntry(annotation.id);
});
imageLoaded = true;
}
function clicked() {
if (!document.fullscreenElement) {
if (!editable && !hasAnnotations) {
imageEl.requestFullscreen();
}
} else {
document.exitFullscreen();
}
}
let brightnesses = [0.5, 0.75, 1, 1.25, 1.5, 2, 2.5];
let brightnessIdx = 2;
function cycleBrightness() {
brightnessIdx++;
brightnessIdx = brightnessIdx % brightnesses.length;
}
let contrasts = [0.5, 0.75, 1, 1.25, 1.5];
let contrastsIdx = 2;
function cycleContrast() {
contrastsIdx++;
contrastsIdx = contrastsIdx % contrasts.length;
}
$: {
if (imageEl) {
const brightness = brightnesses[brightnessIdx];
const contrast = contrasts[contrastsIdx];
imageEl.style.filter = `brightness(${brightness}) contrast(${contrast})`;
}
}
</script>
<div class="image-viewer">
{#if !imageLoaded}
<Spinner centered />
{/if}
{#if imageLoaded}
<div class="toolbar">
<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
class="image"
class:zoomable={!editable && !hasAnnotations}
on:click={clicked}
on:keydown={(ev) => {
if (ev.key === "Enter") clicked();
}}
>
<img
class="preview-image"
src="{api.apiUrl}/{detail || svg ? 'raw' : 'thumb'}/{address}"
alt={address}
on:load={loaded}
bind:this={imageEl}
draggable="false"
/>
</div>
<div class="a8sUpLink" bind:this={a8sLinkTarget}>
{#if a8sLinkAddress}
<div class="link">
<UpObject link address={a8sLinkAddress} />
</div>
{/if}
</div>
</div>
<style global lang="scss">
@use "@recogito/annotorious/dist/annotorious.min.css";
.image-viewer {
display: flex;
flex-direction: column;
min-height: 0;
.image {
display: flex;
justify-content: center;
min-height: 0;
& > *,
img {
min-width: 0;
max-width: 100%;
min-height: 0;
max-height: 100%;
}
img {
margin: auto;
}
}
.toolbar {
display: flex;
justify-content: space-between;
margin-bottom: 0.5em;
.image-controls {
display: flex;
}
}
.zoomable {
cursor: zoom-in;
}
img:fullscreen {
cursor: zoom-out;
}
}
.r6o-editor {
font-family: inherit;
}
.a8sUpLink {
text-align: initial;
.link {
margin: 0.5em 1em;
}
}
</style>

View File

@ -1,117 +0,0 @@
<script lang="ts">
import api from "../../../lib/api";
import IconButton from "../../utils/IconButton.svelte";
import Spinner from "../../utils/Spinner.svelte";
export let address: string;
let mode: "preview" | "full" | "markdown" = "preview";
$: textContent = (async () => {
const response = await api.fetchRaw(address, mode == "preview");
const text = await response.text();
if (mode === "markdown") {
const { marked } = await import("marked");
const DOMPurify = await import("dompurify");
return DOMPurify.default.sanitize(marked.parse(text));
} else {
return text;
}
})();
const tabs = [
["image", "preview", "Preview"],
["shape-circle", "full", "Full"],
["edit", "markdown", "Markdown"],
] as [string, typeof mode, string][];
</script>
<div class="text-preview">
<header class="text-header">
{#each tabs as [icon, targetMode, label]}
<div
class="tab"
class:active={mode == targetMode}
on:click={() => (mode = targetMode)}
on:keydown={(ev) => {
if (ev.key === "Enter") {
mode = targetMode;
}
}}
>
<IconButton
name={icon}
active={mode == targetMode}
on:click={() => (mode = targetMode)}
/>
<div class="label">{label}</div>
</div>
{/each}
</header>
<div class="text" class:markdown={mode === "markdown"}>
{#await textContent}
<Spinner centered />
{:then text}
{#if mode === "markdown"}
{@html text}
{:else}
{text}{#if mode === "preview"}{/if}
{/if}
{/await}
</div>
</div>
<style lang="scss">
.text-preview {
flex: 1;
min-width: 0;
}
.text {
background: var(--background);
padding: 0.5em;
height: 100%;
box-sizing: border-box;
overflow: auto;
border-radius: 4px;
border: 1px solid var(--foreground);
white-space: pre-wrap;
&.markdown {
white-space: unset;
:global(img) {
max-width: 75%;
}
}
}
header {
display: flex;
justify-content: flex-end;
.tab {
display: flex;
cursor: pointer;
border: 1px solid var(--foreground);
border-bottom: 0;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
padding: 0.15em;
margin: 0 0.1em;
&.active {
background: var(--background);
}
.label {
margin-right: 0.5em;
}
}
}
</style>

View File

@ -1,263 +0,0 @@
<script lang="ts">
import { throttle } from "lodash";
import Spinner from "../../utils/Spinner.svelte";
import Icon from "../../utils/Icon.svelte";
import { useEntity } from "../../../lib/entity";
import { i18n } from "../../../i18n";
import { createEventDispatcher } from "svelte";
import api from "../../../lib/api";
const dispatch = createEventDispatcher();
export let address: string;
export let detail: boolean;
const { entity } = useEntity(address);
enum State {
LOADING = "loading",
PREVIEW = "preview",
PREVIEWING = "previewing",
PLAYING = "playing",
ERRORED = "errored",
}
let state = State.LOADING;
let supported = true;
$: if (state == State.PREVIEW) dispatch("loaded");
$: {
if ($entity && videoEl) {
const mime = $entity.get("FILE_MIME");
if (mime) {
supported = Boolean(videoEl.canPlayType(mime as string));
}
}
}
let videoEl: HTMLVideoElement;
let currentTime: number;
let timeCodeWidth: number;
let timeCodeLeft: string;
let timeCodeSize: string;
const seek = throttle((progress: number) => {
if (state === State.PREVIEWING && videoEl.duration) {
currentTime = videoEl.duration * progress;
if (timeCodeWidth) {
let timeCodeLeftPx = Math.min(
Math.max(videoEl.clientWidth * progress, timeCodeWidth / 2),
videoEl.clientWidth - timeCodeWidth / 2
);
timeCodeLeft = `${timeCodeLeftPx}px`;
timeCodeSize = `${videoEl.clientHeight / 9}px`;
}
}
}, 100);
function updatePreviewPosition(ev: MouseEvent) {
if (state === State.PREVIEW || state === State.PREVIEWING) {
state = State.PREVIEWING;
const bcr = videoEl.getBoundingClientRect();
const progress = (ev.clientX - bcr.x) / bcr.width;
seek(progress);
}
}
function resetPreview() {
if (state === State.PREVIEWING) {
state = State.PREVIEW;
videoEl.load();
}
}
function startPlaying() {
if (detail) {
state = State.PLAYING;
videoEl.play();
}
}
</script>
<div class="video-viewer {state}" class:detail class:unsupported={!supported}>
<div class="player" style="--icon-size: {detail ? 100 : 32}px">
{#if state === State.LOADING}
<Spinner />
{/if}
{#if state === State.LOADING || (!detail && state === State.PREVIEW)}
<!-- svelte-ignore a11y-mouse-events-have-key-events -->
<img
class="thumb"
src="{api.apiUrl}/thumb/{address}?mime=video"
alt="Preview for {address}"
loading="lazy"
on:load={() => (state = State.PREVIEW)}
on:mouseover={() => (state = State.PREVIEWING)}
on:error={() => (state = State.ERRORED)}
/>
{:else}
<!-- svelte-ignore a11y-media-has-caption -->
<video
preload={detail ? "auto" : "metadata"}
src="{api.apiUrl}/raw/{address}"
poster="{api.apiUrl}/thumb/{address}?mime=video"
on:mousemove={updatePreviewPosition}
on:mouseleave={resetPreview}
on:click|preventDefault={startPlaying}
controls={state === State.PLAYING}
bind:this={videoEl}
bind:currentTime
/>
{#if !supported}
<div class="unsupported-message">
<div class="label">
{$i18n.t("UNSUPPORTED FORMAT")}
</div>
</div>
{/if}
{/if}
<div class="play-icon">
<Icon plain border name="play" />
</div>
<div
class="timecode"
bind:clientWidth={timeCodeWidth}
style:left={timeCodeLeft}
style:font-size={timeCodeSize}
>
{#if videoEl?.duration && currentTime}
{#if videoEl.duration > 3600}{String(
Math.floor(currentTime / 3600)
).padStart(2, "0")}:{/if}{String(
Math.floor((currentTime % 3600) / 60)
).padStart(2, "0")}:{String(
Math.floor((currentTime % 3600) % 60)
).padStart(2, "0")}
{:else if supported}
<Spinner />
{/if}
</div>
</div>
</div>
<style lang="scss">
.video-viewer {
min-width: 0;
min-height: 0;
&,
.player {
display: flex;
align-items: center;
min-height: 0;
flex-direction: column;
width: 100%;
}
img,
video {
width: 100%;
max-height: 100%;
min-height: 0;
object-fit: contain;
// background: rgba(128, 128, 128, 128);
transition: filter 0.2s;
}
.player {
position: relative;
}
.play-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: var(--icon-size);
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
}
.timecode {
display: none;
pointer-events: none;
position: absolute;
top: 50%;
left: var(--left);
transform: translate(-50%, -50%);
font-feature-settings: "tnum", "zero";
font-weight: bold;
color: white;
opacity: 0.66;
}
&.unsupported.detail {
.play-icon {
display: none;
}
}
.unsupported-message {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(1, 1, 1, 0.7);
pointer-events: none;
.label {
position: absolute;
top: 50%;
left: 0;
transform: translateY(-50%);
width: 100%;
text-align: center;
font-weight: bold;
color: darkred;
}
}
&.loading {
.player > * {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
}
&.standby,
&.preview {
img,
video {
filter: brightness(0.75);
}
.play-icon {
opacity: 0.8;
}
}
&.previewing {
.timecode {
display: block;
}
video {
cursor: pointer;
}
}
}
</style>

View File

@ -1,120 +0,0 @@
import debug from "debug";
import { DEBUG } from "../lib/debug";
const dbg = debug("kestrel:imageQueue");
class ImageQueue {
concurrency: number;
queue: {
element: HTMLElement;
id: string;
callback: () => Promise<void>;
check?: () => boolean;
}[] = [];
active = 0;
constructor(concurrency: number) {
this.concurrency = concurrency;
}
public add(
element: HTMLImageElement,
id: string,
callback: () => Promise<void>,
check?: () => boolean,
) {
this.queue = this.queue.filter((e) => e.element !== element);
this.queue.push({ element, id, callback, check });
this.update();
}
private update() {
this.queue.sort((a, b) => {
const aBox = a.element.getBoundingClientRect();
const bBox = b.element.getBoundingClientRect();
const topDifference = aBox.top - bBox.top;
if (topDifference !== 0) {
return topDifference;
} else {
return aBox.left - bBox.left;
}
});
while (this.active < this.concurrency && this.queue.length) {
const nextIdx = this.queue.findIndex((e) => e.check()) || 0;
const next = this.queue.splice(nextIdx, 1)[0];
dbg(`Getting ${next.id}...`);
this.active += 1;
next.element.classList.add("image-loading");
if (DEBUG.imageQueueHalt) {
return;
}
next
.callback()
.then(() => {
dbg(`Loaded ${next.id}`);
})
.catch(() => {
dbg(`Failed to load ${next.id}...`);
})
.finally(() => {
this.active -= 1;
next.element.classList.remove("image-loading");
this.update();
});
}
dbg(
"Active: %d, Queue: %O",
this.active,
this.queue.map((e) => [e.element, e.id]),
);
}
}
const imageQueue = new ImageQueue(2);
export function concurrentImage(element: HTMLImageElement, src: string) {
const bbox = element.getBoundingClientRect();
let visible =
bbox.top >= 0 &&
bbox.left >= 0 &&
bbox.bottom <= window.innerHeight &&
bbox.right <= window.innerWidth;
const observer = new IntersectionObserver((entries) => {
visible = entries.some((e) => e.isIntersecting);
});
observer.observe(element);
function queueSelf() {
element.classList.add("image-queued");
const loadSelf = () => {
element.classList.remove("image-queued");
return new Promise<void>((resolve, reject) => {
if (element.src === src) {
resolve();
return;
}
element.addEventListener("load", () => {
resolve();
});
element.addEventListener("error", () => {
reject();
});
element.src = src;
});
};
imageQueue.add(element, src, loadSelf, () => visible);
}
queueSelf();
return {
update(_src: string) {
queueSelf();
},
destroy() {
observer.disconnect();
},
};
}

View File

@ -1,163 +0,0 @@
<script lang="ts">
import { Link, useNavigate } from "svelte-navigator";
// import { useMatch } from "svelte-navigator";
import { addEmitter } from "../AddModal.svelte";
import Icon from "../utils/Icon.svelte";
import { jobsEmitter } from "./Jobs.svelte";
import api from "../../lib/api";
import Selector, { type SelectorValue } from "../utils/Selector.svelte";
import { i18n } from "../../i18n";
const navigate = useNavigate();
// const location = useLocation();
// const searchMatch = useMatch("/search/:query");
// let searchQuery = $searchMatch?.params.query
// ? decodeURIComponent($searchMatch?.params.query)
// : "";
// $: if (!$location.pathname.includes("search")) searchQuery = "";
let selector: Selector;
let lastSearched: SelectorValue[] = [];
function addLastSearched(value: SelectorValue) {
switch (value.t) {
case "Address":
lastSearched = lastSearched.filter(
(v) => v.t !== "Address" || v.c !== value.c,
);
break;
case "Attribute":
lastSearched = lastSearched.filter(
(v) => v.t !== "Attribute" || v.name !== value.name,
);
break;
}
lastSearched.unshift(value);
lastSearched = lastSearched.slice(0, 10);
}
async function onInput(event: CustomEvent<SelectorValue>) {
const value = event.detail;
if (!value) return;
switch (value.t) {
case "Address":
addLastSearched(value);
navigate(`/browse/${value.c}`);
break;
case "Attribute":
addLastSearched(value);
{
const attributeAddress = await api.componentsToAddress({
t: "Attribute",
c: value.name,
});
navigate(`/browse/${attributeAddress}`);
}
break;
}
selector.reset();
// searchQuery = event.detail;
// if (searchQuery.length > 0) {
// navigate(`/search/${encodeURIComponent(searchQuery)}`, {
// replace: $location.pathname.includes("search"),
// });
// }
}
let fileInput: HTMLInputElement;
function onFileChange() {
if (fileInput.files.length > 0) {
addEmitter.emit("files", Array.from(fileInput.files));
}
}
async function rescan() {
await api.refreshVault();
jobsEmitter.emit("reload");
}
</script>
<div class="header">
<h1>
<Link to="/">
<img class="logo" src="assets/upend.svg" alt="UpEnd logo" />
<div class="name">UpEnd</div>
</Link>
</h1>
<div class="input">
<Selector
types={["Address", "NewAddress", "Attribute"]}
placeholder={$i18n.t("Search or add")}
on:input={onInput}
bind:this={selector}
emptyOptions={lastSearched}
>
<Icon name="search" slot="prefix" />
</Selector>
</div>
<button class="button" on:click={() => fileInput.click()}>
<Icon name="upload" />
<input
type="file"
multiple
bind:this={fileInput}
on:change={onFileChange}
/>
</button>
<button class="button" on:click={() => rescan()} title="Rescan vault">
<Icon name="refresh" />
</button>
</div>
<style lang="scss">
.header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
height: 3.5rem;
border-bottom: 1px solid var(--foreground);
background: var(--background);
h1 {
font-size: 16pt;
font-weight: normal;
margin: 0;
:global(a) {
display: flex;
align-items: center;
color: var(--foreground-lightest);
text-decoration: none;
font-weight: normal;
}
img {
margin-right: 0.5em;
}
}
.logo {
display: inline-block;
height: 1.5em;
}
.input {
flex-grow: 1;
min-width: 3rem;
}
}
@media screen and (max-width: 600px) {
.name {
display: none;
}
}
</style>

View File

@ -1,81 +0,0 @@
<script lang="ts" context="module">
import mitt from "mitt";
export type JobsEvents = {
reload: undefined;
};
export const jobsEmitter = mitt<JobsEvents>();
</script>
<script lang="ts">
import type { IJob } from "@upnd/upend/types";
import { fade } from "svelte/transition";
import ProgessBar from "../utils/ProgessBar.svelte";
import api from "../../lib/api";
import { DEBUG } from "../../lib/debug";
interface JobWithId extends IJob {
id: string;
}
let jobs: IJob[] = [];
let activeJobs: JobWithId[] = [];
export let active = 0;
$: active = activeJobs.length;
let timeout: NodeJS.Timeout;
async function updateJobs() {
clearTimeout(timeout);
if (!DEBUG.mockJobs) {
jobs = await api.fetchJobs();
} else {
jobs = Array(DEBUG.mockJobs)
.fill(0)
.map((_, i) => ({
id: i.toString(),
title: `Job ${i}`,
job_type: `JobType ${i}`,
state: "InProgress",
progress: Math.floor(Math.random() * 100),
}));
}
activeJobs = Object.entries(jobs)
.filter(([_, job]) => job.state == "InProgress")
.map(([id, job]) => {
return { id, ...job };
})
.sort((j1, j2) => j1.id.localeCompare(j2.id))
.sort((j1, j2) => (j2.job_type || "").localeCompare(j1.job_type || ""));
if (activeJobs.length) {
timeout = setTimeout(updateJobs, 500);
} else {
timeout = setTimeout(updateJobs, 5000);
}
}
updateJobs();
jobsEmitter.on("reload", () => {
updateJobs();
});
</script>
{#each activeJobs as job (job.id)}
<div class="job" transition:fade>
<div class="job-label">{job.title}</div>
<ProgessBar value={job.progress} />
</div>
{/each}
<style lang="scss">
.job {
display: flex;
.job-label {
white-space: nowrap;
margin-right: 2em;
}
}
</style>

View File

@ -1,87 +0,0 @@
<script lang="ts">
import type {
UpNotification,
UpNotificationLevel,
} from "../../notifications";
import { notify } from "../../notifications";
import { fade } from "svelte/transition";
import Icon from "../utils/Icon.svelte";
import { DEBUG, lipsum } from "../../lib/debug";
let notifications: UpNotification[] = [];
if (DEBUG.mockNotifications) {
notifications = [
{
id: "1",
level: "error",
content: `This is an error notification, ${lipsum(5)}`,
},
{
id: "2",
level: "warning",
content: `This is a warning notification, ${lipsum(5)}`,
},
{
id: "3",
level: "info",
content: `This is an info notification, ${lipsum(5)}`,
},
];
notifications = notifications.slice(0, DEBUG.mockNotifications);
if (notifications.length < DEBUG.mockNotifications) {
notifications = [
...notifications,
...Array(DEBUG.mockNotifications - notifications.length)
.fill(0)
.map(() => ({
id: Math.random().toString(),
level: ["error", "warning", "info"][
Math.floor(Math.random() * 3)
] as UpNotificationLevel,
content: lipsum(12),
})),
];
}
notifications = notifications;
}
notify.on("notification", (notification) => {
notifications.push(notification);
notifications = notifications;
setTimeout(() => {
notifications.splice(
notifications.findIndex((n) => (n.id = notification.id)),
1,
);
notifications = notifications;
}, 5000);
});
const icons = {
error: "error-alt",
warning: "error",
};
</script>
{#each notifications as notification (notification.id)}
<div
class="notification notification-{notification.level || 'info'}"
transition:fade
>
<Icon name={icons[notification.level] || "bell"} />
{notification.content}
</div>
{/each}
<style lang="scss">
@use "../../styles/colors";
.notification-error {
color: colors.$red;
}
.notification-warning {
color: colors.$orange;
}
</style>

View File

@ -1,29 +0,0 @@
<script lang="ts" context="module">
let loaded = false;
</script>
<script lang="ts">
export let plain = false;
export let name: string;
export let border = false;
if (!loaded) {
document.head.innerHTML += `<link
rel="stylesheet"
href="vendor/boxicons/css/boxicons.min.css"
/>`;
loaded = true;
}
</script>
<i class="bx bx-{name}" class:plain class:bx-border={border} />
<style>
.bx:not(.plain) {
font-size: 115%;
}
.bx-border {
border-color: white;
}
</style>

View File

@ -1,60 +0,0 @@
<script lang="ts">
import { debounce } from "lodash";
import { createEventDispatcher } from "svelte";
import { useEntity } from "../../lib/entity";
import type { AttributeCreate, AttributeUpdate } from "../../types/base";
import type { UpEntry } from "@upnd/upend";
import LabelBorder from "./LabelBorder.svelte";
const dispatch = createEventDispatcher();
export let address: string;
$: ({ entity } = useEntity(address));
let noteEntry: UpEntry | undefined;
let notes: string | undefined = undefined;
$: {
if ($entity?.attr["NOTE"]?.length && $entity?.attr["NOTE"][0]?.value?.c) {
noteEntry = $entity?.attr["NOTE"][0];
notes = String(noteEntry.value.c);
} else {
noteEntry = undefined;
notes = undefined;
}
}
let contentEl: HTMLDivElement;
const update = debounce(() => {
if (noteEntry) {
dispatch("change", {
type: "update",
address: noteEntry.address,
attribute: "NOTE",
value: { t: "String", c: contentEl.innerText },
} as AttributeUpdate);
} else {
dispatch("change", {
type: "create",
address: address,
attribute: "NOTE",
value: { t: "String", c: contentEl.innerText },
} as AttributeCreate);
}
}, 500);
</script>
<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">
.notes {
background: var(--background);
border-radius: 4px;
padding: 0.5em !important;
}
</style>

View File

@ -1,585 +0,0 @@
<script lang="ts" context="module">
import type { IValue } from "@upnd/upend/types";
import type { UpEntry } from "@upnd/upend";
import UpEntryComponent from "../display/UpEntry.svelte";
export type SELECTOR_TYPE =
| "Address"
| "LabelledAddress"
| "NewAddress"
| "Attribute"
| "NewAttribute"
| "String"
| "Number"
| "Null";
export type SelectorValue = {
t: SELECTOR_TYPE;
} & (
| {
t: "Address";
c: Address;
entry?: UpEntry;
labels?: string[];
}
| {
t: "Attribute";
name: string;
labels?: string[];
}
| {
t: "String";
c: string;
}
| {
t: "Number";
c: number;
}
| {
t: "Null";
c: null;
}
);
export type SelectorOption =
| SelectorValue
| { t: "NewAddress"; c: string }
| { t: "NewAttribute"; name: string; label: string };
export async function selectorValueAsValue(
value: SelectorValue,
): Promise<IValue> {
switch (value.t) {
case "Address":
return {
t: "Address",
c: value.c,
};
case "Attribute":
return {
t: "Address",
c: await api.componentsToAddress({ t: "Attribute", c: value.name }),
};
case "String":
return {
t: "String",
c: value.c,
};
case "Number":
return {
t: "Number",
c: value.c,
};
case "Null":
return {
t: "Null",
c: null,
};
}
}
</script>
<script lang="ts">
import { debounce } from "lodash";
import { createEventDispatcher } from "svelte";
import type { UpListing } from "@upnd/upend";
import type { Address } from "@upnd/upend/types";
import { baseSearchOnce, createLabelled } from "../../util/search";
import UpObject from "../display/UpObject.svelte";
import IconButton from "./IconButton.svelte";
import Input from "./Input.svelte";
import { matchSorter } from "match-sorter";
import api from "../../lib/api";
import { ATTR_LABEL } from "@upnd/upend/constants";
import { i18n } from "../../i18n";
import debug from "debug";
import Spinner from "./Spinner.svelte";
const dispatch = createEventDispatcher();
const dbg = debug("kestrel:Selector");
let selectorEl: HTMLElement;
export let MAX_OPTIONS = 25;
export let types: SELECTOR_TYPE[] = [
"Address",
"NewAddress",
"Attribute",
"String",
"Number",
];
export let attributeOptions: string[] | undefined = undefined;
export let emptyOptions: SelectorOption[] | undefined = undefined;
export let placeholder = "";
export let disabled = false;
export let keepFocusOnSet = false;
export let initial: SelectorValue | undefined = undefined;
let inputValue = "";
let updating = false;
$: setInitial(initial);
function setInitial(initial: SelectorValue | undefined) {
if (initial) {
switch (initial.t) {
case "Address":
case "String":
inputValue = initial.c;
break;
case "Attribute":
inputValue = initial.name;
break;
case "Number":
inputValue = String(initial.c);
break;
}
}
}
let current:
| (SelectorOption & { t: "Address" | "Attribute" | "String" | "Number" })
| undefined = undefined;
export function reset() {
inputValue = "";
current = undefined;
dispatch("input", current);
}
let options: SelectorOption[] = [];
let searchResult: UpListing | undefined = undefined;
const updateOptions = debounce(async (query: string, doSearch: boolean) => {
updating = true;
let result: SelectorOption[] = [];
if (query.length === 0 && emptyOptions !== undefined) {
options = emptyOptions;
updating = false;
return;
}
if (types.includes("Number")) {
const number = parseFloat(query);
if (!Number.isNaN(number)) {
result.push({
t: "Number",
c: number,
});
}
}
if (types.includes("String") && query.length) {
result.push({
t: "String",
c: query,
});
}
options = result;
if (types.includes("Address") || types.includes("LabelledAddress")) {
if (doSearch) {
if (emptyOptions === undefined || query.length > 0) {
searchResult = await baseSearchOnce(query);
} else {
searchResult = undefined;
}
}
let exactHits = Object.entries(addressToLabels)
.filter(([_, labels]) =>
labels.map((l) => l.toLowerCase()).includes(query.toLowerCase()),
)
.map(([addr, _]) => addr);
if (exactHits.length) {
exactHits.forEach((addr) =>
result.push({
t: "Address",
c: addr,
labels: addressToLabels[addr],
entry: null,
}),
);
} else if (query.length && types.includes("NewAddress")) {
result.push({
t: "NewAddress",
c: query,
});
}
let validOptions = (searchResult?.entries || []).filter(
(e) => !exactHits.includes(e.entity),
);
// only includes LabelledAddress
if (!types.includes("Address")) {
validOptions = validOptions.filter((e) => e.attribute == ATTR_LABEL);
}
const sortedOptions = matchSorter(validOptions, inputValue, {
keys: ["value.c", (i) => addressToLabels[i.entity]?.join(" ")],
});
for (const entry of sortedOptions) {
const common = {
t: "Address" as const,
c: entry.entity,
};
if (entry.attribute == ATTR_LABEL) {
result.push({
...common,
labels: [entry.value.c.toString()],
});
} else {
result.push({ ...common, entry });
}
}
}
if (types.includes("Attribute")) {
const allAttributes = await api.fetchAllAttributes();
const attributes = attributeOptions
? allAttributes.filter((attr) => attributeOptions.includes(attr.name))
: allAttributes;
if (emptyOptions === undefined || query.length > 0) {
result.push(
...attributes
.filter(
(attr) =>
attr.name.toLowerCase().includes(query.toLowerCase()) ||
attr.labels.some((label) =>
label.toLowerCase().includes(query.toLowerCase()),
),
)
.map(
(attribute) =>
({
t: "Attribute",
...attribute,
}) as SelectorOption,
),
);
}
const attributeToCreate = query
.toUpperCase()
.replaceAll(/[^A-Z0-9]/g, "_");
if (
!attributeOptions &&
query &&
!allAttributes.map((attr) => attr.name).includes(attributeToCreate) &&
types.includes("NewAttribute")
) {
result.push({
t: "NewAttribute",
name: attributeToCreate,
label: query,
});
}
}
options = result;
updating = false;
}, 200);
$: dbg("%o Options: %O", selectorEl, options);
$: {
if (inputFocused) {
updateOptions.cancel();
updateOptions(inputValue, true);
addressToLabels = {};
}
}
let addressToLabels: { [key: string]: string[] } = {};
function onAddressResolved(address: string, ev: CustomEvent<string[]>) {
addressToLabels[address] = ev.detail;
updateOptions.cancel();
updateOptions(inputValue, false);
}
async function set(option: SelectorOption) {
dbg("%o Setting option %O", selectorEl, option);
switch (option.t) {
case "Address":
inputValue = option.c;
current = option;
break;
case "NewAddress":
{
const addr = await createLabelled(option.c);
inputValue = addr;
current = {
t: "Address",
c: addr,
labels: [option.c],
};
}
break;
case "Attribute":
inputValue = option.name;
current = option;
break;
case "NewAttribute":
inputValue = option.name;
{
const address = await api.componentsToAddress({
t: "Attribute",
c: option.name,
});
await api.putEntityAttribute(address, ATTR_LABEL, {
t: "String",
c: option.label,
});
current = {
t: "Attribute",
name: option.name,
labels: [option.label],
};
}
break;
case "String":
inputValue = option.c;
current = option;
break;
case "Number":
inputValue = String(option.c);
current = option;
break;
}
dbg("%o Result set value: %O", selectorEl, current);
dispatch("input", current);
options = [];
optionFocusIndex = -1;
hover = false;
if (keepFocusOnSet) {
focus();
}
}
let listEl: HTMLUListElement;
let optionFocusIndex = -1;
function handleArrowKeys(ev: KeyboardEvent) {
if (!options.length) {
return;
}
const optionEls = Array.from(listEl.children) as HTMLLIElement[];
let targetIndex = optionEls.findIndex(
(el) => document.activeElement === el,
);
switch (ev.key) {
case "ArrowDown":
targetIndex += 1;
// pressed down on last
if (targetIndex >= optionEls.length) {
targetIndex = 0;
}
break;
case "ArrowUp":
targetIndex -= 1;
// pressed up on input
if (targetIndex == -2) {
targetIndex = optionEls.length - 1;
}
// pressed up on first
if (targetIndex == -1) {
focus();
return;
}
break;
default:
return; // early return, stop processing
}
if (optionEls[targetIndex]) {
optionEls[targetIndex].focus();
}
}
let input: Input;
export function focus() {
// dbg("%o Focusing input", selectorEl);
input.focus();
}
let inputFocused = false;
let hover = false; // otherwise clicking makes options disappear faster than it can emit a set
$: visible =
(inputFocused || hover || optionFocusIndex > -1) &&
Boolean(options.length || updating);
$: dispatch("focus", inputFocused || hover || optionFocusIndex > -1);
$: dbg(
"%o focus = %s, hover = %s, visible = %s",
selectorEl,
inputFocused,
hover,
visible,
);
</script>
<div class="selector" bind:this={selectorEl}>
{#if current?.t === "Address" && inputValue.length > 0}
<div class="input">
<div class="label">
<UpObject link address={String(current.c)} />
</div>
<IconButton name="x" on:click={() => (inputValue = "")} />
</div>
{:else}
<Input
bind:this={input}
bind:value={inputValue}
on:focusChange={(ev) => (inputFocused = ev.detail)}
on:keydown={handleArrowKeys}
{disabled}
{placeholder}
>
<slot name="prefix" slot="prefix" />
</Input>
{/if}
<ul
class="options"
class:visible
on:mouseenter={() => (hover = true)}
on:mouseleave={() => (hover = false)}
bind:this={listEl}
>
{#if updating}
<li><Spinner centered /></li>
{/if}
{#each options.slice(0, MAX_OPTIONS) as option, idx}
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<li
tabindex="0"
on:click={() => set(option)}
on:mousemove={() => focus()}
on:focus={() => (optionFocusIndex = idx)}
on:blur={() => (optionFocusIndex = -1)}
on:keydown={(ev) => {
if (ev.key === "Enter") {
set(option);
} else {
handleArrowKeys(ev);
}
}}
>
{#if option.t === "Address"}
{@const address = option.c}
{#if option.entry}
<UpEntryComponent entry={option.entry} />
{:else}
<UpObject
{address}
labels={option.labels}
on:resolved={(ev) => onAddressResolved(address, ev)}
/>{/if}
{:else if option.t === "NewAddress"}
<div class="content new">{option.c}</div>
<div class="type">{$i18n.t("Create object")}</div>
{:else if option.t === "Attribute"}
{#if option.labels.length}
<div class="content">
{#each option.labels as label}
<div class="label">{label}</div>
{/each}
</div>
<div class="type">{option.name}</div>
{:else}
<div class="content">
{option.name}
</div>
{/if}
{:else if option.t === "NewAttribute"}
<div class="content">{option.label}</div>
<div class="type">{$i18n.t("Create attribute")} ({option.name})</div>
{:else}
<div class="type">{option.t}</div>
<div class="content">{option.c}</div>
{/if}
</li>
{/each}
</ul>
</div>
<style lang="scss">
.selector {
position: relative;
}
.input {
display: flex;
min-width: 0;
.label {
flex: 1;
min-width: 0;
}
}
.options {
position: absolute;
list-style: none;
margin: 2px 0 0;
padding: 0;
border: 1px solid var(--foreground-lighter);
width: 100%;
border-radius: 4px;
background: var(--background);
visibility: hidden;
opacity: 0;
transition: opacity 0.2s;
z-index: 99;
&.visible {
visibility: visible;
opacity: 1;
}
li {
cursor: pointer;
padding: 0.25em;
transition: background-color 0.1s;
&:hover {
background-color: var(--background-lighter);
}
&:focus {
background-color: var(--background-lighter);
outline: none;
}
.type,
.content {
display: inline-block;
}
.type {
opacity: 0.8;
font-size: smaller;
}
.label {
display: inline-block;
}
}
.content.new {
padding: 0.25em;
}
}
</style>

View File

@ -1,338 +0,0 @@
<script lang="ts">
import { readable, type Readable } from "svelte/store";
import type { UpListing } from "@upnd/upend";
import type { Address } from "@upnd/upend/types";
import { query } from "../../lib/entity";
import UpObject from "../display/UpObject.svelte";
import UpObjectCard from "../display/UpObjectCard.svelte";
import { ATTR_LABEL } from "@upnd/upend/constants";
import { i18n } from "../../i18n";
import IconButton from "../utils/IconButton.svelte";
import Selector, { type SelectorValue } 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 select: "add" | "remove" = "add";
export let sort = true;
export let address: Address | undefined = undefined;
$: deduplicatedEntities = Array.from(new Set(entities));
let style: "list" | "grid" | "flex" = "grid";
let clientWidth: number;
$: style = !thumbnails || clientWidth < 600 ? "list" : "grid";
// Sorting
let sortedEntities: Address[] = [];
let sortKeys: { [key: string]: string[] } = {};
function addSortKeys(key: string, vals: string[], resort: boolean) {
if (!sortKeys[key]) {
sortKeys[key] = [];
}
let changed = false;
vals.forEach((val) => {
if (!sortKeys[key].includes(val)) {
changed = true;
sortKeys[key].push(val);
}
});
if (resort && changed) sortEntities();
}
function sortEntities() {
if (!sort) return;
sortedEntities = deduplicatedEntities.concat();
sortedEntities.sort((a, b) => {
if (!sortKeys[a]?.length || !sortKeys[b]?.length) {
if (Boolean(sortKeys[a]?.length) && !sortKeys[b]?.length) {
return -1;
} else if (!sortKeys[a]?.length && Boolean(sortKeys[b]?.length)) {
return 1;
} else {
return a.localeCompare(b);
}
} else {
return sortKeys[a][0].localeCompare(sortKeys[b][0], undefined, {
numeric: true,
});
}
});
}
// Labelling
let labelListing: Readable<UpListing> = readable(undefined);
$: {
const addressesString = deduplicatedEntities
.map((addr) => `@${addr}`)
.join(" ");
labelListing = query(
`(matches (in ${addressesString}) "${ATTR_LABEL}" ? )`,
).result;
}
$: {
if ($labelListing) {
deduplicatedEntities.forEach((address) => {
addSortKeys(
address,
$labelListing.getObject(address).identify(),
false,
);
});
sortEntities();
}
}
if (!sort) {
sortedEntities = entities;
}
// Visibility
let visible: Set<string> = new Set();
let observer = new IntersectionObserver((intersections) => {
intersections.forEach((intersection) => {
const address = (intersection.target as HTMLElement).dataset["address"];
if (!address) {
console.warn("Intersected wrong element?");
return;
}
if (intersection.isIntersecting) {
visible.add(address);
}
visible = visible;
});
});
function observe(node: HTMLElement) {
observer.observe(node);
return {
destroy() {
observer.unobserve(node);
},
};
}
// Adding
let addSelector: Selector | undefined;
let adding = false;
$: if (adding && addSelector) addSelector.focus();
function addEntity(ev: CustomEvent<SelectorValue>) {
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="entitylist style-{style}"
class:has-thumbnails={thumbnails}
bind:clientWidth
>
{#if !sortedEntities.length}
<div class="message">
{$i18n.t("No entries.")}
</div>
{/if}
<div class="items">
{#each sortedEntities as entity (entity)}
<div
data-address={entity}
data-select-mode={select}
use:observe
class="item"
>
{#if visible.has(entity)}
{#if thumbnails}
<UpObjectCard
address={entity}
labels={sortKeys[entity]}
banner={false}
select={select === "add"}
on:resolved={(event) => {
addSortKeys(entity, event.detail, true);
}}
/>
<div class="icon">
<IconButton
name="trash"
color="#dc322f"
on:click={() => removeEntity(entity)}
/>
</div>
{:else}
<div class="object">
<UpObject
link
address={entity}
labels={sortKeys[entity]}
select={select === "add"}
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">
{#if adding}
<Selector
bind:this={addSelector}
placeholder={$i18n.t("Search database or paste an URL")}
types={["Address", "NewAddress"]}
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>
</div>
<style lang="scss">
@use "../../styles/colors";
.items {
gap: 4px;
}
.entitylist.has-thumbnails .items {
gap: 1rem;
}
:global(.entitylist.style-grid .items) {
display: grid;
grid-template-columns: repeat(4, 1fr);
align-items: end;
}
:global(.entitylist.style-flex .items) {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
}
:global(.entitylist.style-list .items) {
display: flex;
flex-direction: column;
align-items: stretch;
}
.item {
min-width: 0;
overflow: hidden;
}
.message {
text-align: center;
margin: 0.5em;
opacity: 0.66;
}
.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

@ -1,481 +0,0 @@
<script lang="ts">
import filesize from "filesize";
import { formatRelative, fromUnixTime, parseISO } from "date-fns";
import Ellipsis from "../utils/Ellipsis.svelte";
import UpObject from "../display/UpObject.svelte";
import { createEventDispatcher } from "svelte";
import type { AttributeUpdate, WidgetChange } from "../../types/base";
import type { UpEntry, UpListing } from "@upnd/upend";
import IconButton from "../utils/IconButton.svelte";
import Selector, {
type SelectorValue,
selectorValueAsValue,
} from "../utils/Selector.svelte";
import Editable from "../utils/Editable.svelte";
import { query } from "../../lib/entity";
import { type Readable, readable } from "svelte/store";
import { defaultEntitySort, entityValueSort } from "../../util/sort";
import { attributeLabels } from "../../util/labels";
import { formatDuration } from "../../util/fragments/time";
import { i18n } from "../../i18n";
import UpLink from "../display/UpLink.svelte";
import { ATTR_ADDED, ATTR_LABEL } from "@upnd/upend/constants";
const dispatch = createEventDispatcher();
export let columns: string | undefined = undefined;
export let header = true;
export let orderByValue = false;
export let columnWidths: string[] | undefined = undefined;
export let entries: UpEntry[];
export let attributes: string[] | undefined = undefined;
// Display
$: displayColumns = (columns || "entity, attribute, value")
.split(",")
.map((c) => c.trim());
const TIMESTAMP_COL = "timestamp";
const PROVENANCE_COL = "provenance";
const ENTITY_COL = "entity";
const ATTR_COL = "attribute";
const VALUE_COL = "value";
$: templateColumns = (
(displayColumns || []).map((column, idx) => {
if (columnWidths?.[idx]) return columnWidths[idx];
return "minmax(6em, auto)";
}) as string[]
)
.concat(["auto"])
.join(" ");
// Editing
let adding = false;
let addHover = false;
let addFocus = false;
let newAttrSelector: Selector;
let newEntryAttribute = "";
let newEntryValue: SelectorValue | undefined;
$: if (adding && newAttrSelector) newAttrSelector.focus();
$: if (!addFocus && !addHover) adding = false;
async function addEntry(attribute: string, value: SelectorValue) {
dispatch("change", {
type: "create",
attribute,
value: await selectorValueAsValue(value),
} as WidgetChange);
newEntryAttribute = "";
newEntryValue = undefined;
}
async function removeEntry(address: string) {
if (confirm($i18n.t("Are you sure you want to remove the property?"))) {
dispatch("change", { type: "delete", address } as WidgetChange);
}
}
async function updateEntry(
address: string,
attribute: string,
value: SelectorValue,
) {
dispatch("change", {
type: "update",
address,
attribute,
value: await selectorValueAsValue(value),
} as AttributeUpdate);
}
// Labelling
let labelListing: Readable<UpListing> = readable(undefined);
$: {
const addresses = [];
entries
.flatMap((e) =>
e.value.t === "Address" ? [e.entity, e.value.c] : [e.entity],
)
.forEach((addr) => {
if (!addresses.includes(addr)) {
addresses.push(addr);
}
});
const addressesString = addresses.map((addr) => `@${addr}`).join(" ");
labelListing = query(
`(matches (in ${addressesString}) "${ATTR_LABEL}" ? )`,
).result;
}
// Sorting
let sortedEntries = entries;
let sortKeys: { [key: string]: string[] } = {};
function addSortKeys(key: string, vals: string[], resort: boolean) {
if (!sortKeys[key]) {
sortKeys[key] = [];
}
let changed = false;
vals.forEach((val) => {
if (!sortKeys[key].includes(val)) {
changed = true;
sortKeys[key].push(val);
}
});
if (resort && changed) sortEntries();
}
function sortEntries() {
sortedEntries = orderByValue
? entityValueSort(entries, Object.assign(sortKeys, $attributeLabels))
: defaultEntitySort(entries, Object.assign(sortKeys, $attributeLabels));
}
$: {
if ($labelListing) {
entries.forEach((entry) => {
addSortKeys(
entry.entity,
$labelListing.getObject(entry.entity).identify(),
false,
);
if (entry.value.t === "Address") {
addSortKeys(
entry.value.c,
$labelListing.getObject(String(entry.value.c)).identify(),
false,
);
}
});
sortEntries();
}
}
entries.forEach((entry) => {
addSortKeys(
entry.entity,
entry.listing.getObject(entry.entity).identify(),
false,
);
if (entry.value.t === "Address") {
addSortKeys(
entry.value.c,
entry.listing.getObject(String(entry.value.c)).identify(),
false,
);
}
});
sortEntries();
// Visibility
let visible: Set<string> = new Set();
let observer = new IntersectionObserver((intersections) => {
intersections.forEach((intersection) => {
const address = (intersection.target as HTMLElement).dataset["address"];
if (!address) {
console.warn("Intersected wrong element?");
return;
}
if (intersection.isIntersecting) {
visible.add(address);
}
visible = visible;
});
});
function observe(node: HTMLElement) {
observer.observe(node);
return {
destroy() {
observer.unobserve(node);
},
};
}
// Formatting & Display
const COLUMN_LABELS: { [key: string]: string } = {
timestamp: $i18n.t("Added at"),
provenance: $i18n.t("Provenance"),
entity: $i18n.t("Entity"),
attribute: $i18n.t("Attribute"),
value: $i18n.t("Value"),
};
function formatValue(value: string | number, attribute: string): string {
try {
switch (attribute) {
case "FILE_SIZE":
return filesize(parseInt(String(value), 10), { base: 2 });
case ATTR_ADDED:
case "LAST_VISITED":
return formatRelative(
fromUnixTime(parseInt(String(value), 10)),
new Date(),
);
case "NUM_VISITED":
return `${value} times`;
case "MEDIA_DURATION":
return formatDuration(parseInt(String(value), 10));
}
} catch {
// noop.
}
return String(value);
}
// Unused attributes
let unusedAttributes = [];
$: (async () => {
unusedAttributes = await Promise.all(
(attributes || []).filter(
(attr) => !entries.some((entry) => entry.attribute === attr),
),
);
})();
</script>
<div class="entry-list" style:--template-columns={templateColumns}>
{#if header}
<header>
{#each displayColumns as column}
<div class="label">
{COLUMN_LABELS[column] || $attributeLabels[column] || column}
</div>
{/each}
<div class="attr-action"></div>
</header>
{/if}
{#each sortedEntries as entry (entry.address)}
{#if visible.has(entry.address)}
{#each displayColumns as column}
{#if column == TIMESTAMP_COL}
<div class="cell" title={entry.timestamp}>
{formatRelative(parseISO(entry.timestamp), new Date())}
</div>
{:else if column == PROVENANCE_COL}
<div class="cell">{entry.provenance}</div>
{:else if column == ENTITY_COL}
<div class="cell entity mark-entity" data-address={entry.entity}>
<UpObject
link
labels={$labelListing
?.getObject(String(entry.entity))
?.identify() || []}
address={entry.entity}
on:resolved={(event) => {
addSortKeys(entry.entity, event.detail, true);
}}
/>
</div>
{:else if column == ATTR_COL}
<div
class="cell mark-attribute"
class:formatted={Boolean(
Object.keys($attributeLabels).includes(entry.attribute),
)}
>
<UpLink to={{ attribute: entry.attribute }}>
<Ellipsis
value={$attributeLabels[entry.attribute] || entry.attribute}
title={$attributeLabels[entry.attribute]
? `${$attributeLabels[entry.attribute]} (${entry.attribute})`
: entry.attribute}
/>
</UpLink>
</div>
{:else if column == VALUE_COL}
<div
class="cell value mark-value"
data-address={entry.value.t === "Address"
? entry.value.c
: undefined}
>
<Editable
value={entry.value}
on:edit={(ev) =>
updateEntry(entry.address, entry.attribute, ev.detail)}
>
{#if entry.value.t === "Address"}
<UpObject
link
address={String(entry.value.c)}
labels={$labelListing
?.getObject(String(entry.value.c))
?.identify() || []}
on:resolved={(event) => {
addSortKeys(String(entry.value.c), event.detail, true);
}}
/>
{:else}
<div
class:formatted={Boolean(
formatValue(entry.value.c, entry.attribute),
)}
>
<Ellipsis
value={formatValue(entry.value.c, entry.attribute) ||
String(entry.value.c)}
/>
</div>
{/if}
</Editable>
</div>
{:else}
<div>?</div>
{/if}
{/each}
<div class="attr-action">
<IconButton
plain
subdued
name="x-circle"
color="#dc322f"
on:click={() => removeEntry(entry.address)}
/>
</div>
{:else}
<div class="skeleton" data-address={entry.address} use:observe>...</div>
{/if}
{/each}
{#each unusedAttributes as attribute}
{#each displayColumns as column}
{#if column == ATTR_COL}
<div
class="cell mark-attribute"
class:formatted={Boolean(
Object.keys($attributeLabels).includes(attribute),
)}
>
<UpLink to={{ attribute }}>
<Ellipsis
value={$attributeLabels[attribute] || attribute}
title={$attributeLabels[attribute]
? `${$attributeLabels[attribute]} (${attribute})`
: attribute}
/>
</UpLink>
</div>
{:else if column == VALUE_COL}
<div class="cell">
<Editable on:edit={(ev) => addEntry(attribute, ev.detail)}>
<span class="unset">{$i18n.t("(unset)")}</span>
</Editable>
</div>
{:else}
<div class="cell"></div>
{/if}
{/each}
<div class="attr-action"></div>
{/each}
{#if !attributes?.length}
{#if adding}
<div
class="add-row"
on:mouseenter={() => (addHover = true)}
on:mouseleave={() => (addHover = false)}
>
{#each displayColumns as column}
{#if column == ATTR_COL}
<div class="cell mark-attribute">
<Selector
types={["Attribute", "NewAttribute"]}
on:input={(ev) => (newEntryAttribute = ev.detail.name)}
on:focus={(ev) => (addFocus = ev.detail)}
keepFocusOnSet
bind:this={newAttrSelector}
/>
</div>
{:else if column === VALUE_COL}
<div class="cell mark-value">
<Selector
on:input={(ev) => (newEntryValue = ev.detail)}
on:focus={(ev) => (addFocus = ev.detail)}
keepFocusOnSet
/>
</div>
{:else}
<div class="cell"></div>
{/if}
{/each}
<div class="attr-action">
<IconButton
name="save"
on:click={() => addEntry(newEntryAttribute, newEntryValue)}
/>
</div>
</div>
{:else}
<div class="add-button">
<IconButton
outline
subdued
name="plus-circle"
on:click={() => (adding = true)}
/>
</div>
{/if}
{/if}
</div>
<style lang="scss">
.entry-list {
display: grid;
grid-template-columns: var(--template-columns);
gap: 0.05rem 0.5rem;
header {
display: contents;
.label {
font-weight: 600;
}
}
.cell {
font-family: var(--monospace-font);
line-break: anywhere;
min-width: 0;
border-radius: 4px;
padding: 2px;
&.formatted,
.formatted {
font-family: var(--default-font);
}
}
.attr-action {
display: flex;
justify-content: center;
align-items: center;
}
.add-row {
display: contents;
}
.add-button {
display: flex;
flex-direction: column;
grid-column: 1 / -1;
}
.unset {
opacity: 0.66;
pointer-events: none;
}
}
</style>

View File

@ -1 +0,0 @@
/// <reference types="svelte" />

View File

@ -0,0 +1,160 @@
<script context="module" lang="ts">
import mitt from 'mitt';
export type AddEvents = {
files: File[];
urls: string[];
};
export const addEmitter = mitt<AddEvents>();
</script>
<script lang="ts">
import Icon from './utils/Icon.svelte';
import IconButton from './utils/IconButton.svelte';
import api from '$lib/api';
import { goto } from '$app/navigation';
let files: File[] = [];
let URLs: string[] = [];
let uploading = false;
$: visible = files.length + URLs.length > 0;
addEmitter.on('files', (ev) => {
ev.forEach((file) => {
if (!files.map((f) => `${f.name}${f.size}`).includes(`${file.name}${file.size}`)) {
files.push(file);
}
files = files;
});
});
async function upload() {
uploading = true;
try {
const addresses = await Promise.all(files.map(async (file) => api.putBlob(file)));
goto(`/browse/${addresses.join(',')}`);
} catch (error) {
alert(error);
}
uploading = false;
reset();
}
function reset() {
if (!uploading) {
files = [];
URLs = [];
}
}
</script>
<svelte:body on:keydown={(ev) => ev.key === 'Escape' && reset()} />
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div class="addmodal-container" class:visible class:uploading on:click={reset}>
<div class="addmodal" on:click|stopPropagation>
<div class="files">
{#each files as file}
<div class="file">
{#if file.type.startsWith('image')}
<img src={URL.createObjectURL(file)} alt="To be uploaded." />
{:else}
<div class="icon">
<Icon name="file" />
</div>
{/if}
<div class="label">{file.name}</div>
</div>
{/each}
</div>
<div class="controls">
<IconButton name="upload" on:click={upload} />
</div>
</div>
</div>
<style lang="scss">
.addmodal-container {
position: absolute;
left: 0;
top: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.5);
color: var(--foreground);
display: none;
&.visible {
display: unset;
}
&.uploading {
cursor: progress;
.addmodal {
filter: brightness(0.5);
}
}
}
.addmodal {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
background: var(--background);
color: var(--foreground);
border: solid 2px var(--foreground);
border-radius: 8px;
padding: 1rem;
}
.files {
display: flex;
flex-direction: column;
gap: 1em;
padding: 0.5em;
overflow-y: auto;
max-height: 66vh;
}
.file {
display: flex;
align-items: center;
flex-direction: column;
border: 1px solid var(--foreground);
border-radius: 4px;
background: var(--background-lighter);
padding: 0.5em;
img {
max-height: 12em;
max-width: 12em;
}
.icon {
font-size: 24px;
}
.label {
flex-grow: 1;
text-align: center;
}
}
.controls {
display: flex;
justify-content: center;
font-size: 48px;
margin-top: 0.5rem;
}
</style>

View File

@ -0,0 +1,177 @@
<script lang="ts">
import { createEventDispatcher, onMount, setContext, tick } from 'svelte';
import IconButton from './utils/IconButton.svelte';
import { selected } from './EntitySelect.svelte';
import type { BrowseContext } from '../util/browse';
import { writable } from 'svelte/store';
import { i18n } from '../i18n';
import { page } from '$app/stores';
const dispatch = createEventDispatcher();
export let address: string | undefined = undefined;
export let index: number;
export let only: boolean;
export let background = 'var(--background-lighter)';
export let forceDetail = false;
let shifted = false;
let key = Math.random();
let detail = only || forceDetail;
let detailChanged = false;
$: if (!detailChanged) detail = only || forceDetail;
$: if (detailChanged) tick().then(() => dispatch('detail', detail));
let indexStore = writable(index);
$: $indexStore = index;
let addressesStore = writable<string[]>([]);
$: $addressesStore = $page.params.addresses?.split(',') || [];
setContext('browse', {
index: indexStore,
addresses: addressesStore
} as BrowseContext);
onMount(() => {
// Required to make detail mode detection work in Browse
dispatch('detail', detail);
});
$: if ($selected.length) {
detail = false;
}
function visit() {
window.open(`/browse/${address}`, '_blank');
}
let width = 460;
if (window.innerWidth < 600) {
width = window.innerWidth - 6;
}
function drag(ev: MouseEvent) {
const startWidth = width;
const startX = ev.screenX;
function onMouseMove(ev: MouseEvent) {
width = startWidth + (ev.screenX - startX);
width = width < 300 ? 300 : width;
}
function onMouseUp(_: MouseEvent) {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
}
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
}
function reload() {
key = Math.random();
}
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="browse-column"
class:detail
style="--background-color: {background}"
on:mousemove={(ev) => (shifted = ev.shiftKey)}
>
<div class="view" style="--width: {width}px">
<header>
{#if address}
<IconButton name="link" on:click={() => visit()} disabled={only}>
{$i18n.t('Detach')}
</IconButton>
{/if}
{#if !forceDetail}
<IconButton
name={detail ? 'zoom-out' : 'zoom-in'}
on:click={() => {
detail = !detail;
detailChanged = true;
}}
active={detail}
>
{$i18n.t('Detail')}
</IconButton>
{:else}
<div class="noop"></div>
{/if}
{#if address}
<IconButton name="intersect" on:click={() => dispatch('combine', address)}>
{$i18n.t('Combine')}
</IconButton>
{/if}
{#if !shifted}
<IconButton name="x-circle" on:click={() => dispatch('close')} disabled={only}>
{$i18n.t('Close')}
</IconButton>
{:else}
<IconButton name="refresh" on:click={() => reload()}>
{$i18n.t('Reload')}
</IconButton>
{/if}
</header>
{#key key}
<slot {detail} />
{/key}
</div>
<div class="resizeHandle" on:mousedown|preventDefault={drag} />
</div>
<style lang="scss">
.browse-column {
display: flex;
}
.browse-column.detail {
width: 100%;
.view {
@media screen and (min-width: 600px) {
min-width: 85vw;
max-width: min(85vw, 1920px);
margin-left: auto;
margin-right: auto;
}
}
}
.view {
min-width: var(--width);
max-width: var(--width);
display: flex;
flex-direction: column;
background: var(--background-color);
color: var(--foreground-lighter);
border: 1px solid var(--foreground-lightest);
border-radius: 0.5em;
padding: 1rem;
// transition: min-width 0.2s, max-width 0.2s;
// TODO - container has nowhere to scroll, breaking `detail` scroll
header {
font-size: 20px;
position: relative;
min-height: 1em;
display: flex;
justify-content: space-between;
flex: none;
}
}
.resizeHandle {
cursor: ew-resize;
height: 100%;
width: 0.5rem;
@media screen and (max-width: 600px) {
display: none;
}
}
</style>

View File

@ -0,0 +1,163 @@
<script lang="ts">
import { i18n } from '../i18n';
import EntitySetEditor from './EntitySetEditor.svelte';
import EntryView from './EntryView.svelte';
import Icon from './utils/Icon.svelte';
import EntityList from './widgets/EntityList.svelte';
import api from '$lib/api';
import { Query } from '@upnd/upend';
import { ATTR_IN } from '@upnd/upend/constants';
import { createEventDispatcher } from 'svelte';
import { Any } from '@upnd/upend/query';
const dispatch = createEventDispatcher();
export let spec: string;
const individualSpecs = spec.split(/(?=[+=-])/);
let includedGroups = individualSpecs.filter((s) => s.startsWith('+')).map((s) => s.slice(1));
let requiredGroups = individualSpecs.filter((s) => s.startsWith('=')).map((s) => s.slice(1));
let excludedGroups = individualSpecs.filter((s) => s.startsWith('-')).map((s) => s.slice(1));
$: if (
includedGroups.length === 0 &&
requiredGroups.length === 0 &&
excludedGroups.length === 0
) {
dispatch('close');
}
const combinedWidgets = [
{
name: 'List',
icon: 'list-check',
components: ({ entities }) => [
{
component: EntityList,
props: {
entities,
thumbnails: false
}
}
]
},
{
name: 'EntityList',
icon: 'image',
components: ({ entities }) => [
{
component: EntityList,
props: {
entities,
thumbnails: true
}
}
]
}
];
let resultEntities = [];
async function updateResultEntities(
includedGroups: string[],
requiredGroups: string[],
excludedGroups: string[]
) {
const included = includedGroups.length
? (
await api.query(
Query.matches(
Any,
ATTR_IN,
includedGroups.map((g) => `@${g}`)
)
)
).objects
: [];
const required = requiredGroups.length
? (
await api.query(
Query.matches(
Any,
ATTR_IN,
requiredGroups.map((g) => `@${g}`)
)
)
).objects
: [];
const excluded = excludedGroups.length
? (
await api.query(
Query.matches(
Any,
ATTR_IN,
excludedGroups.map((g) => `@${g}`)
)
)
).objects
: [];
resultEntities = (Object.keys(included).length ? Object.keys(included) : Object.keys(required))
.filter((e) => !requiredGroups.length || Object.keys(required).includes(e))
.filter((e) => !Object.keys(excluded).includes(e));
}
$: updateResultEntities(includedGroups, requiredGroups, excludedGroups);
</script>
<div class="view" data-address-multi={resultEntities}>
<h2>
<Icon plain name="intersect" />
{$i18n.t('Combine')}
</h2>
<div class="controls">
<EntitySetEditor
entities={includedGroups}
header={$i18n.t('Include')}
confirmRemoveMessage={null}
on:add={(ev) => (includedGroups = [...includedGroups, ev.detail])}
on:remove={(ev) => (includedGroups = includedGroups.filter((e) => e !== ev.detail))}
/>
<EntitySetEditor
entities={requiredGroups}
header={$i18n.t('Require')}
confirmRemoveMessage={null}
on:add={(ev) => (requiredGroups = [...requiredGroups, ev.detail])}
on:remove={(ev) => (requiredGroups = requiredGroups.filter((e) => e !== ev.detail))}
/>
<EntitySetEditor
entities={excludedGroups}
header={$i18n.t('Exclude')}
confirmRemoveMessage={null}
on:add={(ev) => (excludedGroups = [...excludedGroups, ev.detail])}
on:remove={(ev) => (excludedGroups = excludedGroups.filter((e) => e !== ev.detail))}
/>
</div>
<div class="entities">
<EntryView
title={$i18n.t('Matching entities')}
entities={resultEntities}
widgets={combinedWidgets}
/>
</div>
</div>
<style lang="scss">
.view {
display: flex;
flex-direction: column;
height: 100%;
}
h2 {
text-align: center;
margin: 0;
margin-top: -0.66em;
}
.controls {
margin-bottom: 1rem;
}
.entities {
flex-grow: 1;
overflow-y: auto;
height: 0;
}
</style>

View File

@ -0,0 +1,174 @@
<script lang="ts">
import { ATTR_IN, ATTR_LABEL } from '@upnd/upend/constants';
import api from '$lib/api';
import { i18n } from '../i18n';
import Spinner from './utils/Spinner.svelte';
import UpObject from './display/UpObject.svelte';
const groups = (async () => {
const data = await api.query(`(matches ? "${ATTR_IN}" ?)`);
const addresses = data.entries
.filter((e) => e.value.t === 'Address')
.map((e) => e.value.c) as string[];
const sortedAddresses = [...new Set(addresses)]
.map((address) => ({
address,
count: addresses.filter((a) => a === address).length
}))
.sort((a, b) => b.count - a.count);
const addressesString = sortedAddresses.map(({ address }) => `@${address}`).join(' ');
const labels = (
await api.query(`(matches (in ${addressesString}) "${ATTR_LABEL}" ? )`)
).entries.filter((e) => e.value.t === 'String');
const display = sortedAddresses.map(({ address, count }) => ({
address,
labels: labels
.filter((e) => e.entity === address)
.map((e) => e.value.c)
.sort() as string[],
count
}));
display
.sort((a, b) => (a.labels[0] || '').localeCompare(b.labels[0] || ''))
.sort((a, b) => b.count - a.count);
const labelsToGroups = new Map<string, string[]>();
labels.forEach((e) => {
const groups = labelsToGroups.get(e.value.c as string) || [];
if (!groups.includes(e.entity)) {
groups.push(e.entity);
}
labelsToGroups.set(e.value.c as string, groups);
});
const duplicates = [...labelsToGroups.entries()]
.filter(([_, groups]) => groups.length > 1)
.map(([label, groups]) => ({ label, groups }));
return {
groups: display,
total: sortedAddresses.length,
duplicateGroups: duplicates
};
})();
let clientWidth: number;
</script>
<div class="groups" bind:clientWidth class:small={clientWidth < 600}>
<h2>{$i18n.t('Groups')}</h2>
<div class="main">
{#await groups}
<Spinner centered />
{:then data}
<ul>
{#each data.groups as group}
<li class="group" data-address={group.address}>
<UpObject link address={group.address} labels={group.labels} />
<div class="count">{group.count}</div>
</li>
{:else}
<li>No groups?</li>
{/each}
{#if data.groups && data.total > data.groups.length}
<li>+ {data.total - data.groups.length}...</li>
{/if}
</ul>
{#if data.duplicateGroups.length > 0}
<h3>{$i18n.t('Duplicate groups')}</h3>
<ul class="duplicate">
{#each data.duplicateGroups as { label, groups }}
<li class="duplicate-group">
<div class="label">{label}</div>
<ul>
{#each groups as group}
<li>
<UpObject link address={group} backpath={2} />
</li>
{/each}
</ul>
</li>
{/each}
</ul>
{/if}
{/await}
</div>
</div>
<style lang="scss">
@use '../styles/colors';
.groups {
text-align: center;
flex-grow: 1;
height: 0;
display: flex;
flex-direction: column;
}
.main {
overflow: hidden auto;
}
h2 {
margin-top: -0.66em;
}
ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 0.5em;
justify-content: space-between;
}
.group {
display: flex;
}
.count {
display: inline-block;
font-size: 0.66em;
margin-left: 0.25em;
}
.label {
font-weight: bold;
margin-bottom: 1em;
}
.duplicate {
display: flex;
gap: 1rem;
flex-wrap: wrap;
justify-content: center;
}
.duplicate-group {
flex-basis: 49%;
border-radius: 4px;
border: 1px solid var(--foreground);
padding: 0.5rem;
overflow-x: auto;
max-width: 100%;
ul {
flex-direction: column;
}
}
.groups.small {
ul {
flex-direction: column;
}
}
</style>

View File

@ -0,0 +1,585 @@
<script lang="ts">
import EntryView, { type Widget } from './EntryView.svelte';
import { useEntity } from '$lib/entity';
import UpObject from './display/UpObject.svelte';
import { createEventDispatcher } from 'svelte';
import { derived, type Readable } from 'svelte/store';
import { Query, type UpEntry } from '@upnd/upend';
import Spinner from './utils/Spinner.svelte';
import NotesEditor from './utils/NotesEditor.svelte';
import type { WidgetChange } from '../types/base';
import type { Address, EntityInfo } from '@upnd/upend/types';
import IconButton from './utils/IconButton.svelte';
import BlobViewer from './display/BlobViewer.svelte';
import { i18n } from '../i18n';
import EntryList from './widgets/EntryList.svelte';
import api from '$lib/api';
import EntityList from './widgets/EntityList.svelte';
import { ATTR_IN, ATTR_KEY, ATTR_LABEL, ATTR_OF } from '@upnd/upend/constants';
import InspectGroups from './InspectGroups.svelte';
import InspectTypeEditor from './InspectTypeEditor.svelte';
import LabelBorder from './utils/LabelBorder.svelte';
import { debug } from 'debug';
import { Any } from '@upnd/upend/query';
const dbg = debug('kestrel:Inspect');
const dispatch = createEventDispatcher();
export let address: string;
export let detail: boolean;
let showAsEntries = false;
let highlightedType: string | undefined;
let blobHandled = false;
$: ({ entity, entityInfo, error, revalidate } = useEntity(address));
$: allTypes = derived(
entityInfo,
($entityInfo, set) => {
getAllTypes($entityInfo).then((allTypes) => {
set(allTypes);
});
},
{}
) as Readable<{
[key: string]: {
labels: string[];
attributes: string[];
};
}>;
$: sortedTypes = Object.entries($allTypes)
.sort(([a, _], [b, __]) => a.localeCompare(b))
.sort(([_, a], [__, b]) => a.attributes.length - b.attributes.length);
async function getAllTypes(entityInfo: EntityInfo) {
const allTypes = {};
if (!entityInfo) {
return {};
}
const typeAddresses: string[] = [
await api.getAddress(entityInfo.t),
...($entity?.attr[ATTR_IN] || []).map((e) => e.value.c as string)
];
const typeAddressesIn = typeAddresses.map((addr) => `@${addr}`).join(' ');
const labelsQuery = await api.query(`(matches (in ${typeAddressesIn}) "${ATTR_LABEL}" ?)`);
typeAddresses.forEach((address) => {
let labels = labelsQuery.getObject(address).identify();
let typeLabel: string | undefined;
if (typeLabel) {
labels.unshift(typeLabel);
}
allTypes[address] = {
labels,
attributes: []
};
});
const attributes = await api.query(`(matches ? "${ATTR_OF}" (in ${typeAddressesIn}))`);
await Promise.all(
typeAddresses.map(async (address) => {
allTypes[address].attributes = (
await Promise.all(
(attributes.getObject(address).attr[`~${ATTR_OF}`] || []).map(async (e) => {
try {
const { t, c } = await api.addressToComponents(e.entity);
if (t == 'Attribute') {
return c;
}
} catch (err) {
console.error(err);
return false;
}
})
)
).filter(Boolean);
})
);
const result = {};
Object.keys(allTypes).forEach((addr) => {
if (allTypes[addr].attributes.length > 0) {
result[addr] = allTypes[addr];
}
});
return result;
}
let untypedProperties = [] as UpEntry[];
let untypedLinks = [] as UpEntry[];
$: {
untypedProperties = [];
untypedLinks = [];
($entity?.attributes || []).forEach((entry) => {
const entryTypes = Object.entries($allTypes || {}).filter(([_, t]) =>
t.attributes.includes(entry.attribute)
);
if (entryTypes.length === 0) {
if (entry.value.t === 'Address') {
untypedLinks.push(entry);
} else {
untypedProperties.push(entry);
}
}
});
untypedProperties = untypedProperties;
untypedLinks = untypedLinks;
}
$: filteredUntypedProperties = untypedProperties.filter(
(entry) =>
![
ATTR_LABEL,
ATTR_IN,
ATTR_KEY,
'NOTE',
'LAST_VISITED',
'NUM_VISITED',
'LAST_ATTRIBUTE_WIDGET'
].includes(entry.attribute)
);
$: currentUntypedProperties = filteredUntypedProperties;
$: filteredUntypedLinks = untypedLinks.filter(
(entry) => ![ATTR_IN, ATTR_OF].includes(entry.attribute)
);
$: currentUntypedLinks = filteredUntypedLinks;
$: currentBacklinks =
$entity?.backlinks.filter((entry) => ![ATTR_IN, ATTR_OF].includes(entry.attribute)) || [];
$: tagged = $entity?.attr[`~${ATTR_IN}`] || [];
let attributesUsed: UpEntry[] = [];
$: {
if ($entityInfo?.t === 'Attribute') {
api
.query(`(matches ? "${$entityInfo.c}" ?)`)
.then((result) => (attributesUsed = result.entries));
}
}
let correctlyTagged: Address[] | undefined;
let incorrectlyTagged: Address[] | undefined;
$: {
if ($entity?.attr[`~${ATTR_OF}`]) {
fetchCorrectlyTagged();
}
}
async function fetchCorrectlyTagged() {
const attributes = (
await Promise.all($entity?.attr[`~${ATTR_OF}`].map((e) => api.addressToComponents(e.entity)))
)
.filter((ac) => ac.t == 'Attribute')
.map((ac) => ac.c);
const attributeQuery = await api.query(
Query.matches(
tagged.map((t) => `@${t.entity}`),
attributes,
Any
)
);
correctlyTagged = [];
incorrectlyTagged = [];
for (const element of tagged) {
const entity = attributeQuery.getObject(element.entity);
if (attributes.every((attr) => entity.attr[attr])) {
correctlyTagged = [...correctlyTagged, element.entity];
} else {
incorrectlyTagged = [...incorrectlyTagged, element.entity];
}
}
}
async function onChange(ev: CustomEvent<WidgetChange>) {
dbg('onChange', ev.detail);
const change = ev.detail;
switch (change.type) {
case 'create':
await api.putEntry({
entity: address,
attribute: change.attribute,
value: change.value
});
break;
case 'delete':
await api.deleteEntry(change.address);
break;
case 'update':
await api.putEntityAttribute(address, change.attribute, change.value);
break;
case 'entry-add':
await api.putEntry({
entity: change.address,
attribute: ATTR_IN,
value: { t: 'Address', c: 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;
}
revalidate();
}
let identities = [address];
function onResolved(ev: CustomEvent<string[]>) {
identities = ev.detail;
dispatch('resolved', ev.detail);
}
async function deleteObject() {
if (confirm(`${$i18n.t('Really delete')} "${identities.join(' | ')}"?`)) {
await api.deleteEntry(address);
dispatch('close');
}
}
const attributeWidgets: Widget[] = [
{
name: 'List',
icon: 'list-check',
components: ({ entries }) => [
{
component: EntryList,
props: {
entries,
columns: 'attribute, value'
}
}
]
}
];
const linkWidgets: Widget[] = [
{
name: 'List',
icon: 'list-check',
components: ({ entries, group }) => [
{
component: EntryList,
props: {
entries,
columns: 'attribute, value',
attributes: $allTypes[group]?.attributes || []
}
}
]
},
{
name: 'Entity List',
icon: 'image',
components: ({ entries, address }) => [
{
component: EntityList,
props: {
address,
entities: entries.filter((e) => e.value.t == 'Address').map((e) => e.value.c),
thumbnails: true
}
}
]
}
];
const taggedWidgets: Widget[] = [
{
name: 'List',
icon: 'list-check',
components: ({ entries, address }) => [
{
component: EntityList,
props: {
address,
entities: entries.map((e) => e.entity),
thumbnails: false
}
}
]
},
{
name: 'EntityList',
icon: 'image',
components: ({ entries, address }) => [
{
component: EntityList,
props: {
address,
entities: entries.map((e) => e.entity),
thumbnails: true
}
}
]
}
];
$: entity.subscribe(async (object) => {
if (object && object.listing.entries.length) {
dbg('Updating visit stats for %o', object);
await api.putEntityAttribute(
object.address,
'LAST_VISITED',
{
t: 'Number',
c: new Date().getTime() / 1000
},
'IMPLICIT'
);
await api.putEntityAttribute(
object.address,
'NUM_VISITED',
{
t: 'Number',
c: (parseInt(String(object.get('NUM_VISITED'))) || 0) + 1
},
'IMPLICIT'
);
}
});
</script>
<div
class="inspect"
class:detail
class:blob={blobHandled}
data-address-multi={($entity?.attr['~IN']?.map((e) => e.entity) || []).join(',')}
>
<header>
<h2>
{#if $entity}
<UpObject banner {address} on:resolved={onResolved} />
{:else}
<Spinner centered />
{/if}
</h2>
</header>
{#if !showAsEntries}
<div class="main-content">
<div class="detail-col">
<div class="blob-viewer">
<BlobViewer {address} {detail} on:handled={(ev) => (blobHandled = ev.detail)} />
</div>
{#if !$error}
<InspectGroups
{entity}
on:highlighted={(ev) => (highlightedType = ev.detail)}
on:change={() => revalidate()}
/>
<div class="properties">
<NotesEditor {address} on:change={onChange} />
<InspectTypeEditor {entity} on:change={() => revalidate()} />
{#each sortedTypes as [typeAddr, { labels, attributes }]}
<EntryView
entries={($entity?.attributes || []).filter((e) =>
attributes.includes(e.attribute)
)}
widgets={linkWidgets}
on:change={onChange}
highlighted={highlightedType == typeAddr}
title={labels.join(' | ')}
group={typeAddr}
{address}
/>
{/each}
{#if currentUntypedProperties.length > 0}
<EntryView
title={$i18n.t('Other Properties')}
widgets={attributeWidgets}
entries={currentUntypedProperties}
on:change={onChange}
{address}
/>
{/if}
{#if currentUntypedLinks.length > 0}
<EntryView
title={$i18n.t('Links')}
widgets={linkWidgets}
entries={currentUntypedLinks}
on:change={onChange}
{address}
/>
{/if}
{#if !correctlyTagged || !incorrectlyTagged}
<EntryView
title={`${$i18n.t('Members')}`}
widgets={taggedWidgets}
entries={tagged}
on:change={onChange}
{address}
/>
{:else}
<EntryView
title={`${$i18n.t('Typed Members')} (${correctlyTagged.length})`}
widgets={taggedWidgets}
entries={tagged.filter((e) => correctlyTagged.includes(e.entity))}
on:change={onChange}
{address}
/>
<EntryView
title={`${$i18n.t('Untyped members')} (${incorrectlyTagged.length})`}
widgets={taggedWidgets}
entries={tagged.filter((e) => incorrectlyTagged.includes(e.entity))}
on:change={onChange}
{address}
/>
{/if}
{#if currentBacklinks.length > 0}
<EntryView
title={`${$i18n.t('Referred to')} (${currentBacklinks.length})`}
entries={currentBacklinks}
on:change={onChange}
{address}
/>
{/if}
{#if $entityInfo?.t === 'Attribute'}
<LabelBorder>
<span slot="header">{$i18n.t('Used')} ({attributesUsed.length})</span>
<EntryList columns="entity,value" entries={attributesUsed} orderByValue />
</LabelBorder>
{/if}
</div>
{:else}
<div class="error">
{$error}
</div>
{/if}
</div>
</div>
{:else}
<div class="entries">
<h2>{$i18n.t('Attributes')}</h2>
<EntryList
entries={$entity.attributes}
columns={detail ? 'timestamp, provenance, attribute, value' : 'attribute, value'}
on:change={onChange}
/>
<h2>{$i18n.t('Backlinks')}</h2>
<EntryList
entries={$entity.backlinks}
columns={detail ? 'timestamp, provenance, entity, attribute' : 'entity, attribute'}
on:change={onChange}
/>
</div>
{/if}
<div class="footer">
<IconButton
name="detail"
title={$i18n.t('Show as entries')}
active={showAsEntries}
on:click={() => (showAsEntries = !showAsEntries)}
/>
</div>
<IconButton
name="trash"
outline
subdued
color="#dc322f"
on:click={deleteObject}
title={$i18n.t('Delete object')}
/>
</div>
<style lang="scss">
header h2 {
margin-bottom: 0;
}
.inspect,
.main-content {
flex: auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
min-height: 0;
}
.properties {
flex: auto;
height: 0; // https://stackoverflow.com/a/14964944
min-height: 12em;
overflow-y: auto;
padding-right: 1rem;
}
@media screen and (min-width: 1600px) {
.inspect.detail {
.main-content {
position: relative;
flex-direction: row;
justify-content: end;
}
&.blob {
.detail-col {
width: 33%;
flex-grow: 0;
}
.blob-viewer {
width: 65%;
height: 100%;
position: absolute;
left: 1%;
top: 0;
}
}
}
}
.main-content .detail-col {
display: flex;
flex-direction: column;
flex-grow: 1;
}
.entries {
flex-grow: 1;
}
.footer {
margin-top: 2rem;
display: flex;
justify-content: end;
}
.buttons {
display: flex;
}
.error {
color: red;
}
</style>

View File

@ -0,0 +1,44 @@
<script lang="ts">
import api from '$lib/api';
import { ATTR_IN } from '@upnd/upend/constants';
import { createEventDispatcher } from 'svelte';
import type { UpObject } from '@upnd/upend';
import type { Readable } from 'svelte/store';
import EntitySetEditor from './EntitySetEditor.svelte';
import { i18n } from '../i18n';
const dispatch = createEventDispatcher();
export let entity: Readable<UpObject>;
$: groups = Object.fromEntries(
($entity?.attr[ATTR_IN] || []).map((e) => [e.value.c as string, e.address])
);
async function addGroup(address: string) {
await api.putEntry([
{
entity: $entity.address,
attribute: ATTR_IN,
value: {
t: 'Address',
c: address
}
}
]);
dispatch('change');
}
async function removeGroup(address: string) {
await api.deleteEntry(groups[address]);
dispatch('change');
}
</script>
<EntitySetEditor
entities={Object.keys(groups)}
header={$i18n.t('Groups')}
hide={Object.keys(groups).length === 0}
on:add={(e) => addGroup(e.detail)}
on:remove={(e) => removeGroup(e.detail)}
on:highlighted
/>

View File

@ -0,0 +1,129 @@
<script lang="ts">
import UpObjectDisplay from './display/UpObject.svelte';
import Selector, { type SelectorValue } from './utils/Selector.svelte';
import IconButton from './utils/IconButton.svelte';
import api from '$lib/api';
import { i18n } from '../i18n';
import type { UpObject, UpEntry } from '@upnd/upend';
import type { Readable } from 'svelte/store';
import { ATTR_OF } from '@upnd/upend/constants';
import { createEventDispatcher } from 'svelte';
import LabelBorder from './utils/LabelBorder.svelte';
const dispatch = createEventDispatcher();
export let entity: Readable<UpObject>;
let adding = false;
let typeSelector: Selector;
$: if (adding && typeSelector) typeSelector.focus();
$: typeEntries = $entity?.attr[`~${ATTR_OF}`] || [];
async function add(ev: CustomEvent<SelectorValue>) {
if (ev.detail.t !== 'Attribute') {
return;
}
await api.putEntry({
entity: {
t: 'Attribute',
c: ev.detail.name
},
attribute: ATTR_OF,
value: { t: 'Address', c: $entity.address }
});
dispatch('change');
}
async function remove(entry: UpEntry) {
let really = confirm(
$i18n.t('Really remove "{{attributeName}}" from "{{typeName}}"?', {
attributeName: (await api.addressToComponents(entry.entity)).c,
typeName: $entity.identify().join('/')
})
);
if (really) {
await api.deleteEntry(entry.address);
dispatch('change');
}
}
</script>
{#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}
types={['Attribute', 'NewAttribute']}
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">
<div class="label">
<UpObjectDisplay address={typeEntry.entity} link />
</div>
<div class="controls">
<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>
<div class="add-button">
<IconButton outline small name="plus-circle" on:click={() => (adding = true)} />
</div>
</div>
</LabelBorder>
{/if}
<style lang="scss">
.attributes {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0.25em;
}
.attribute {
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;
margin: 0;
}
</style>

View File

@ -0,0 +1,72 @@
<script lang="ts">
import api from '$lib/api';
import { ATTR_IN } from '@upnd/upend/constants';
import { i18n } from '../i18n';
import { Query, UpListing } from '@upnd/upend';
import EntitySetEditor from './EntitySetEditor.svelte';
import { Any } from '@upnd/upend/query';
export let entities: string[];
let groups = [];
let groupListing: UpListing | undefined = undefined;
async function updateGroups() {
const currentEntities = entities.concat();
const allGroups = await api.query(
Query.matches(
currentEntities.map((e) => `@${e}`),
ATTR_IN,
Any
)
);
const commonGroups = new Set(
allGroups.values
.filter((v) => v.t == 'Address')
.map((v) => v.c)
.filter((groupAddr) => {
return Object.values(allGroups.objects).every((obj) => {
return obj.attr[ATTR_IN].some((v) => v.value.c === groupAddr);
});
})
);
if (entities.toString() == currentEntities.toString()) {
groups = Array.from(commonGroups);
groupListing = allGroups;
}
}
$: entities && updateGroups();
async function addGroup(address: string) {
await api.putEntry(
entities.map((entity) => ({
entity,
attribute: ATTR_IN,
value: {
t: 'Address',
c: address
}
}))
);
await updateGroups();
}
async function removeGroup(address: string) {
await Promise.all(
entities.map((entity) =>
api.deleteEntry(
groupListing.objects[entity].attr[ATTR_IN].find((v) => v.value.c === address).address
)
)
);
await updateGroups();
}
</script>
<EntitySetEditor
entities={groups}
header={$i18n.t('Common groups')}
on:add={(ev) => addGroup(ev.detail)}
on:remove={(ev) => removeGroup(ev.detail)}
/>

View File

@ -0,0 +1,439 @@
<script lang="ts">
import UpObject from './display/UpObject.svelte';
import api from '$lib/api';
import Selector, { type SelectorValue } from './utils/Selector.svelte';
import { createEventDispatcher, onMount, tick } from 'svelte';
import type { ZoomBehavior, ZoomTransform, Selection } from 'd3';
import Spinner from './utils/Spinner.svelte';
import UpObjectCard from './display/UpObjectCard.svelte';
import BlobPreview from './display/BlobPreview.svelte';
import SurfacePoint from './display/SurfacePoint.svelte';
import { i18n } from '../i18n';
import debug from 'debug';
import { Query } from '@upnd/upend';
import { Any } from '@upnd/upend/query';
const dbg = debug('kestrel:surface');
const dispatch = createEventDispatcher();
export let x: string | undefined = undefined;
export let y: string | undefined = undefined;
$: dispatch('updateAddress', { x, y });
let loaded = false;
let viewMode = 'point';
let currentX = NaN;
let currentY = NaN;
let zoom: ZoomBehavior<Element, unknown> | undefined;
let autofit: () => void | undefined;
let view: Selection<HTMLElement, unknown, null, undefined>;
let viewEl: HTMLElement | undefined;
let viewHeight = 0;
let viewWidth = 0;
let selector: Selector | undefined;
$: if (selector) selector.focus();
$: {
if ((x && !y) || (!x && y)) findPerpendicular();
}
async function findPerpendicular() {
const presentAxis = x || y;
const presentAxisAddress = await api.componentsToAddress({
t: 'Attribute',
c: presentAxis
});
const result = await api.query(
Query.or(
Query.matches(`@${presentAxisAddress}`, 'PERPENDICULAR', Any),
Query.matches(Any, 'PERPENDICULAR', `@${presentAxisAddress}`)
)
);
const perpendicular = [
...result.entries.map((e) => e.entity),
...result.values.filter((v) => v.t === 'Address').map((v) => v.c as string)
].find((address) => address !== presentAxisAddress);
if (perpendicular) {
const perpendicularComponents = await api.addressToComponents(perpendicular);
if (perpendicularComponents.t !== 'Attribute') return;
const perpendicularName = perpendicularComponents.c;
if (x) {
y = perpendicularName;
} else {
x = perpendicularName;
}
}
}
interface IPoint {
address: string;
x: number;
y: number;
}
let points: IPoint[] = [];
async function loadPoints() {
points = [];
const result = await api.query(`(matches ? (in "${x}" "${y}") ?)`);
points = Object.entries(result.objects)
.map(([address, obj]) => {
let objX = parseInt(String(obj.get(x)));
let objY = parseInt(String(obj.get(y)));
if (objX && objY) {
return {
address,
x: objX,
y: objY
};
}
})
.filter(Boolean);
tick().then(() => {
autofit();
});
}
$: {
if (x && y) {
loadPoints();
}
}
let selectorCoords: [number, number] | null = null;
onMount(async () => {
const d3 = await import('d3');
function init() {
viewWidth = viewEl.clientWidth;
viewHeight = viewEl.clientHeight;
dbg('Initializing Surface view: %dx%d', viewWidth, viewHeight);
view = d3.select(viewEl);
const svg = view.select('svg');
if (svg.empty()) {
throw new Error("Failed initializing Surface - couldn't locate SVG element");
}
svg.selectAll('*').remove();
const xScale = d3.scaleLinear().domain([0, viewWidth]).range([0, viewWidth]);
const yScale = d3.scaleLinear().domain([0, viewHeight]).range([viewHeight, 0]);
let xTicks = 10;
let yTicks = viewHeight / (viewWidth / xTicks);
const xAxis = d3
.axisBottom(xScale)
.ticks(xTicks)
.tickSize(viewHeight)
.tickPadding(5 - viewHeight);
const yAxis = d3
.axisRight(yScale)
.ticks(yTicks)
.tickSize(viewWidth)
.tickPadding(5 - viewWidth);
const gX = svg.append('g').call(xAxis);
const gY = svg.append('g').call(yAxis);
zoom = d3.zoom().on('zoom', zoomed);
function zoomed({ transform }: { transform: ZoomTransform }) {
const points = view.select('.content');
points.style(
'transform',
`translate(${transform.x}px, ${transform.y}px) scale(${transform.k})`
);
const allPoints = view.selectAll('.point');
allPoints.style('transform', `scale(${1 / transform.k})`);
gX.call(xAxis.scale(transform.rescaleX(xScale)));
gY.call(yAxis.scale(transform.rescaleY(yScale)));
updateStyles();
}
autofit = () => {
zoom.translateTo(view, 0, viewHeight);
if (points.length) {
zoom.scaleTo(
view,
Math.min(
viewWidth / 2 / Math.max(...points.map((p) => Math.abs(p.x))) - 0.3,
viewHeight / 2 / Math.max(...points.map((p) => Math.abs(p.y))) - 0.3
)
);
}
};
function updateStyles() {
svg
.selectAll('.tick line')
.attr('stroke-width', (d: number) => {
return d === 0 ? 2 : 1;
})
.attr('stroke', function (d: number) {
return d === 0 ? 'var(--foreground-lightest)' : 'var(--foreground-lighter)';
});
}
// function reset() {
// svg.transition().duration(750).call(zoom.transform, d3.zoomIdentity);
// }
view.on('mousemove', (ev: MouseEvent) => {
// not using offsetXY because `translate` transforms on .inner mess it up
const viewBBox = (view.node() as HTMLElement).getBoundingClientRect();
const [x, y] = d3
.zoomTransform(view.select('.content').node() as HTMLElement)
.invert([ev.clientX - viewBBox.left, ev.clientY - viewBBox.top]);
currentX = xScale.invert(x);
currentY = yScale.invert(y);
});
d3.select(viewEl)
.call(zoom)
.on('dblclick.zoom', (_ev: MouseEvent) => {
selectorCoords = [currentX, currentY];
});
autofit();
loaded = true;
}
const resizeObserver = new ResizeObserver(() => {
tick().then(() => init());
});
resizeObserver.observe(viewEl);
});
async function onSelectorInput(ev: CustomEvent<SelectorValue>) {
const value = ev.detail;
if (value.t !== 'Address') return;
const address = value.c;
const [xValue, yValue] = selectorCoords;
selectorCoords = null;
await Promise.all(
[
[x, xValue],
[y, yValue]
].map(([axis, value]: [string, number]) =>
api.putEntityAttribute(address, axis, {
t: 'Number',
c: value
})
)
);
await loadPoints();
}
</script>
<div class="surface">
<div class="header ui">
<div class="axis-selector">
<div class="label">X</div>
<Selector
types={['Attribute', 'NewAttribute']}
initial={x ? { t: 'Attribute', name: x } : undefined}
on:input={(ev) => {
if (ev.detail.t === 'Attribute') x = ev.detail.name;
}}
/>
<div class="value">
{(Math.round(currentX * 100) / 100).toLocaleString('en', {
useGrouping: false,
minimumFractionDigits: 2
})}
</div>
</div>
<div class="axis-selector">
<div class="label">Y</div>
<Selector
types={['Attribute', 'NewAttribute']}
initial={y ? { t: 'Attribute', name: y } : undefined}
on:input={(ev) => {
if (ev.detail.t === 'Attribute') y = ev.detail.name;
}}
/>
<div class="value">
{(Math.round(currentY * 100) / 100).toLocaleString('en', {
useGrouping: false,
minimumFractionDigits: 2
})}
</div>
</div>
</div>
<div class="view" class:loaded bind:this={viewEl}>
<div class="ui view-mode-selector">
<div class="label">
{$i18n.t('View as')}
</div>
<select bind:value={viewMode}>
<option value="point">{$i18n.t('Point')}</option>
<option value="link">{$i18n.t('Link')}</option>
<option value="card">{$i18n.t('Card')}</option>
<!-- <option value="preview">{$i18n.t("Preview")}</option> -->
</select>
</div>
{#if !loaded}
<div class="loading">
<Spinner centered="absolute" />
</div>
{/if}
<div class="content">
{#if selectorCoords !== null}
<div
class="point selector"
style="
left: {selectorCoords[0]}px;
top: {viewHeight - selectorCoords[1]}px"
>
<Selector
types={['Address', 'NewAddress']}
on:input={onSelectorInput}
on:focus={(ev) => {
if (!ev.detail) selectorCoords = null;
}}
bind:this={selector}
/>
</div>
{/if}
{#each points as point}
<div class="point" style="left: {point.x}px; top: {viewHeight - point.y}px">
<div class="inner">
{#if viewMode == 'link'}
<UpObject link address={point.address} />
{:else if viewMode == 'card'}
<UpObjectCard address={point.address} />
{:else if viewMode == 'preview'}
<BlobPreview address={point.address} />
{:else if viewMode == 'point'}
<SurfacePoint address={point.address} />
{/if}
</div>
</div>
{/each}
</div>
<svg />
</div>
</div>
<style lang="scss">
.surface {
display: flex;
flex-direction: column;
height: 100%;
}
.header {
display: flex;
flex-wrap: wrap;
gap: 1em;
align-items: center;
justify-content: space-between;
margin: 0.5em 0;
.axis-selector {
display: flex;
gap: 1em;
align-items: center;
.label {
font-size: 1rem;
&::after {
content: ':';
}
}
}
}
.view {
flex-grow: 1;
position: relative;
overflow: hidden;
:global(svg) {
width: 100%;
height: 100%;
}
:global(.tick text) {
color: var(--foreground-lightest);
font-size: 1rem;
text-shadow: 0 0 0.25em var(--background);
}
.content {
transform-origin: 0 0;
}
.point {
position: absolute;
transform-origin: 0 0;
.inner {
transform: translate(-50%, -50%);
}
&:hover {
z-index: 99;
}
}
.view-mode-selector {
position: absolute;
top: 2rem;
right: 1.5em;
padding: 0.66em;
border-radius: 4px;
border: 1px solid var(--foreground-lighter);
background: var(--background);
opacity: 0.66;
transition: opacity 0.25s;
&:hover {
opacity: 1;
}
}
&:not(.loaded) {
pointer-events: none;
}
}
.view-mode-selector {
display: flex;
flex-direction: column;
gap: 0.5em;
align-items: center;
}
.ui {
font-size: 0.8rem;
}
.loading {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
z-index: 99;
transform: scale(3);
}
</style>

View File

@ -0,0 +1,196 @@
<script lang="ts">
import { useEntity } from '$lib/entity';
import Spinner from '../utils/Spinner.svelte';
import FragmentViewer from './blobs/FragmentViewer.svelte';
import ModelViewer from './blobs/ModelViewer.svelte';
import VideoViewer from './blobs/VideoViewer.svelte';
import HashBadge from './HashBadge.svelte';
import api from '$lib/api';
import { createEventDispatcher } from 'svelte';
import { getTypes } from '$lib/util/mediatypes';
import { concurrentImage } from '../imageQueue';
import { ATTR_IN } from '@upnd/upend/constants';
import AudioPreview from './blobs/AudioPreview.svelte';
const dispatch = createEventDispatcher();
export let address: string;
export let recurse = 3;
$: ({ entity, entityInfo } = useEntity(address));
$: types = $entity && getTypes($entity, $entityInfo);
$: handled =
types &&
(!$entity ||
types.audio ||
types.video ||
types.image ||
types.text ||
types.model ||
types.web ||
types.fragment ||
(types.group && recurse > 0));
$: dispatch('handled', handled);
let loaded = null;
$: dispatch('loaded', Boolean(loaded));
let failedChildren: string[] = [];
let loadedChildren: string[] = [];
$: groupChildren = $entity?.backlinks
.filter((e) => e.attribute === ATTR_IN)
.map((e) => String(e.entity))
.filter(
(addr) =>
!failedChildren
.slice(0, $entity?.backlinks.filter((e) => e.attribute === ATTR_IN).length - 4)
.includes(addr)
)
.slice(0, 4);
$: if (groupChildren)
loaded = groupChildren.every(
(addr) => loadedChildren.includes(addr) || failedChildren.includes(addr)
);
</script>
<div class="preview">
{#if handled}
<div class="inner">
{#if !loaded}
<Spinner centered="absolute" />
{/if}
{#if types.group}
<ul class="group">
{#each groupChildren as address (address)}
<li>
<svelte:self
{address}
recurse={recurse - 1}
on:handled={(ev) => {
if (!ev.detail && !failedChildren.includes(address))
failedChildren = [...failedChildren, address];
}}
on:loaded={(ev) => {
if (ev.detail && !loadedChildren.includes(address))
loadedChildren = [...loadedChildren, address];
}}
/>
</li>
{/each}
</ul>
{:else if types.model}
<ModelViewer
lookonly
src="{api.apiUrl}/raw/{address}"
on:loaded={() => (loaded = address)}
/>
{:else if types.web}
<img
alt="OpenGraph image for {$entityInfo?.t == 'Url' && $entityInfo?.c}"
use:concurrentImage={String($entity?.get('OG_IMAGE'))}
on:load={() => (loaded = address)}
on:error={() => (handled = false)}
/>
{:else if types.fragment}
<FragmentViewer {address} detail={false} on:loaded={() => (loaded = address)} />
{:else if types.audio}
<AudioPreview
{address}
on:loaded={() => (loaded = address)}
on:error={() => (handled = false)}
/>
{:else if types.video}
<VideoViewer {address} detail={false} on:loaded={() => (loaded = address)} />
{:else}
<div class="image" class:loaded={loaded == address || !handled}>
<img
class:loaded={loaded == address}
alt="Thumbnail for {address}..."
use:concurrentImage={`${api.apiUrl}/${
types.mimeType?.includes('svg+xml') ? 'raw' : 'thumb'
}/${address}?size=512&quality=75`}
on:load={() => (loaded = address)}
on:error={() => (handled = false)}
/>
</div>
{/if}
</div>
{:else}
<div class="hashbadge">
<HashBadge {address} />
</div>
{/if}
</div>
<style lang="scss">
.preview {
flex-grow: 1;
min-height: 0;
display: flex;
flex-direction: column;
.inner {
display: flex;
min-height: 0;
flex-grow: 1;
justify-content: center;
}
}
.hashbadge {
font-size: 48px;
opacity: 0.25;
text-align: center;
line-height: 1;
}
.image {
display: flex;
min-height: 0;
min-width: 0;
justify-content: center;
img {
max-width: 100%;
object-fit: contain;
&:not(.loaded) {
flex-grow: 1;
height: 6rem;
max-height: 100%;
width: 100%;
min-width: 0;
}
}
}
.group {
padding: 0;
flex-grow: 1;
min-height: 0;
width: 100%;
min-width: 0;
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
padding: 0.25rem;
gap: 0.25rem;
border: 1px solid var(--foreground);
border-radius: 4px;
li {
display: flex;
flex-direction: column;
justify-content: end;
list-style: none;
min-height: 0;
min-width: 0;
}
}
</style>

View File

@ -0,0 +1,119 @@
<script lang="ts">
import { useEntity } from '$lib/entity';
import Spinner from '../utils/Spinner.svelte';
import AudioViewer from './blobs/AudioViewer.svelte';
import FragmentViewer from './blobs/FragmentViewer.svelte';
import ImageViewer from './blobs/ImageViewer.svelte';
import ModelViewer from './blobs/ModelViewer.svelte';
import TextViewer from './blobs/TextViewer.svelte';
import VideoViewer from './blobs/VideoViewer.svelte';
import UpLink from './UpLink.svelte';
import api from '$lib/api';
import { createEventDispatcher } from 'svelte';
import { getTypes } from '$lib/util/mediatypes';
const dispatch = createEventDispatcher();
export let address: string;
export let detail: boolean;
let handled = false;
$: ({ entity, entityInfo } = useEntity(address));
$: types = $entity && $entityInfo && getTypes($entity, $entityInfo);
$: handled =
(types &&
(types.audio ||
types.video ||
types.image ||
types.text ||
types.pdf ||
types.model ||
types.web ||
types.fragment)) ??
false;
$: dispatch('handled', handled);
let imageLoaded: string | null = null;
</script>
{#if handled}
<div class="preview" class:detail>
{#if types?.text}
<div class="text-viewer">
<TextViewer {address} />
</div>
{/if}
{#if types?.audio}
<AudioViewer {address} {detail} />
{/if}
{#if types?.video}
<VideoViewer detail {address} />
{/if}
{#if types?.image}
<ImageViewer {address} {detail} />
{/if}
{#if types?.pdf}
<iframe src="{api.apiUrl}/raw/{address}?inline" title="PDF document of {address}" />
{/if}
{#if types?.model}
<ModelViewer src="{api.apiUrl}/raw/{address}" />
{/if}
{#if types?.web}
{#if imageLoaded != address}
<Spinner />
{/if}
<img
src={String($entity?.get('OG_IMAGE'))}
alt="OpenGraph image for {$entityInfo?.t == 'Url' && $entityInfo?.c}"
on:load={() => (imageLoaded = address)}
on:error={() => (handled = false)}
/>
{/if}
{#if types?.fragment}
<UpLink passthrough to={{ entity: String($entity?.get('ANNOTATES')) }}>
<FragmentViewer {address} {detail} />
</UpLink>
{/if}
</div>
{/if}
<style lang="scss">
.preview {
display: flex;
align-items: center;
flex-direction: column;
// min-height: 33vh;
max-height: 50vh;
&.detail {
height: 100%;
max-height: 100%;
flex-grow: 1;
// min-height: 0;
}
}
img,
.text-viewer {
width: 100%;
max-height: 100%;
}
iframe {
width: 99%;
flex-grow: 1;
}
.text-viewer {
display: flex;
margin-bottom: 2rem;
min-height: 0;
}
img {
object-fit: contain;
}
</style>

View File

@ -0,0 +1,84 @@
<script lang="ts">
import { getContext } from 'svelte';
import { readable } from 'svelte/store';
import type { Address, VALUE_TYPE } from '@upnd/upend/types';
import type { BrowseContext } from '$lib/util/browse';
import api from '$lib/api';
import { goto } from '$app/navigation';
export let passthrough = false;
export let title: string | undefined = undefined;
export let text = false;
export let to: {
entity?: Address;
attribute?: string;
surfaceAttribute?: string;
value?: { t: VALUE_TYPE; c: string };
};
const NOOP = '#';
let targetHref = NOOP;
$: {
if (to.entity) {
targetHref = to.entity;
} else if (to.attribute) {
api.componentsToAddress({ t: 'Attribute', c: to.attribute }).then((address) => {
targetHref = address;
});
} else if (to.surfaceAttribute) {
targetHref = `surface:${to.surfaceAttribute}`;
}
}
const context = getContext('browse') as BrowseContext | undefined;
const index = context ? context.index : readable(0);
const addresses = context ? context.addresses : readable([]);
function onClick(ev: MouseEvent) {
if (window.location.pathname.startsWith('/browse')) {
let newAddresses = $addresses.concat();
// 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]);
}
}
goto('/browse/' + newAddresses.join(','));
return true;
} else {
goto(`/browse/${targetHref}`);
}
}
</script>
<a
class="uplink"
class:text
class:passthrough
class:unresolved={targetHref === NOOP}
href="/browse/{targetHref}"
on:click|preventDefault={onClick}
{title}
>
<slot />
</a>
<style lang="scss">
:global(.uplink) {
text-decoration: none;
max-width: 100%;
}
:global(.uplink.text) {
text-decoration: underline;
}
:global(.uplink.passthrough) {
display: contents;
}
:global(.uplink.unresolved) {
pointer-events: none;
}
</style>

View File

@ -0,0 +1,366 @@
<script lang="ts">
import { createEventDispatcher, getContext } from 'svelte';
import HashBadge from './HashBadge.svelte';
import UpObjectLabel from './UpObjectLabel.svelte';
import UpLink from './UpLink.svelte';
import Icon from '../utils/Icon.svelte';
import { readable, type Readable, writable } from 'svelte/store';
import { notify, UpNotification } from '$lib/notifications';
import IconButton from '../utils/IconButton.svelte';
import { vaultInfo } from '$lib/util/info';
import type { BrowseContext } from '$lib/util/browse';
import { Query, type UpObject } from '@upnd/upend';
import type { ADDRESS_TYPE, EntityInfo } from '@upnd/upend/types';
import { useEntity } from '$lib/entity';
import { i18n } from '$lib/i18n';
import api from '$lib/api';
import { ATTR_IN, ATTR_KEY, ATTR_LABEL, HIER_ROOT_ADDR } from '@upnd/upend/constants';
import { selected } from '../EntitySelect.svelte';
import { Any } from '@upnd/upend/query';
import type { AddressComponents } from '@upnd/upend/wasm';
const dispatch = createEventDispatcher();
export let address: string;
export let labels: string[] | undefined = undefined;
export let link = false;
export let banner = false;
export let resolve = !(labels || []).length || banner;
export let backpath = 0;
export let select = true;
export let plain = false;
let entity: Readable<UpObject | undefined> = readable(undefined);
let entityInfo: Readable<EntityInfo | AddressComponents | undefined> = writable(undefined);
$: if (resolve) ({ entity, entityInfo } = useEntity(address));
$: if (!resolve)
entityInfo = readable(undefined as undefined | AddressComponents, (set) => {
api.addressToComponents(address).then((info) => {
set(info);
});
});
let hasFile = false;
$: {
if ($entityInfo?.t == 'Hash' && banner) {
fetch(api.getRaw(address), {
method: 'HEAD'
}).then((response) => {
hasFile = response.ok;
});
}
}
// Identification
let inferredIds: string[] = [];
$: inferredIds = $entity?.identify() || [];
let addressIds: string[] = [];
$: resolving = inferredIds.concat(labels || []).length == 0 && !$entity;
$: fetchAddressLabels(address);
async function fetchAddressLabels(address: string) {
addressIds = [];
await Promise.all(
(['Hash', 'Uuid', 'Attribute', 'Url'] as ADDRESS_TYPE[]).map(async (t) => {
if ((await api.getAddress(t)) == address) {
addressIds.push(`∈ ${t}`);
}
})
);
addressIds = addressIds;
}
let displayLabel = address;
$: {
const allLabels = inferredIds.concat(addressIds).concat(labels || []);
displayLabel = Array.from(new Set(allLabels)).join(' | ');
if (!displayLabel && $entityInfo?.t === 'Attribute') {
displayLabel = `${$entityInfo.c}`;
}
displayLabel = displayLabel || address;
}
$: dispatch('resolved', inferredIds);
// Resolved backpath
let resolvedBackpath: string[] = [];
$: if (backpath) resolveBackpath();
async function resolveBackpath() {
resolvedBackpath = [];
let levels = 0;
let current = address;
while (levels < backpath && current !== HIER_ROOT_ADDR) {
const parent = await api.query(Query.matches(`@${current}`, ATTR_IN, Any));
if (parent.entries.length) {
current = parent.entries[0].value.c as string;
const label = await api.query(Query.matches(`@${current}`, ATTR_LABEL, Any));
if (label.entries.length) {
resolvedBackpath = [label.entries[0].value.c as string, ...resolvedBackpath];
}
}
levels++;
}
}
// Navigation highlights
const context = getContext('browse') as BrowseContext | undefined;
const index = context?.index || readable(0);
const addresses = context?.addresses || readable([]);
// Native open
function nativeOpen() {
notify.emit(
'notification',
new UpNotification(
$i18n.t('Opening {{identity}} in a default native application...', {
identity: inferredIds[0] || address
})
)
);
api
.nativeOpen(address)
.then(async (response) => {
if (!response.ok) {
throw new Error(`${response.statusText} - ${await response.text()}`);
}
const rawWarning = response.headers.get('warning');
if (rawWarning) {
const warningText = rawWarning?.split(' ').slice(2).join(' ');
notify.emit('notification', new UpNotification(warningText, 'warning'));
}
})
.catch((err) => {
notify.emit(
'notification',
new UpNotification(
$i18n.t('Failed to open in native application! ({{err}})', { err }),
'error'
)
);
});
}
</script>
<div
class="upobject"
class:left-active={address == $addresses[$index - 1]}
class:right-active={address == $addresses[$index + 1]}
class:selected={select && $selected.includes(address)}
class:plain
>
<div
class="address"
class:identified={inferredIds.length || addressIds.length || labels?.length}
class:banner
class:show-type={$entityInfo?.t === 'Url' && !addressIds.length}
>
<HashBadge {address} />
<div class="separator" />
<div class="label" class:resolving title={displayLabel}>
<div class="label-inner">
{#if banner && hasFile}
<UpObjectLabel label={displayLabel} backpath={resolvedBackpath} />
{:else if link}
<UpLink to={{ entity: address }}>
<UpObjectLabel label={displayLabel} backpath={resolvedBackpath} />
</UpLink>
{:else}
<UpObjectLabel label={displayLabel} backpath={resolvedBackpath} />
{/if}
</div>
{#if $entity?.get(ATTR_KEY) && !$entity?.get(ATTR_KEY)?.toString()?.startsWith('TYPE_')}
<div class="key">{$entity.get(ATTR_KEY)}</div>
{/if}
<div class="secondary">
<div class="type">
{$entityInfo?.t}
{#if $entityInfo?.t === 'Url' || $entityInfo?.t === 'Attribute'}
&mdash; {$entityInfo.c}
{/if}
</div>
</div>
</div>
{#if banner}
{#if $entityInfo?.t === 'Attribute'}
<div class="icon">
<UpLink to={{ surfaceAttribute: $entityInfo.c }} title={$i18n.t('Open on surface') || ''}>
<Icon name="cross" />
</UpLink>
</div>
{/if}
{#if $entityInfo?.t == 'Hash'}
<div
class="icon"
title={hasFile ? $i18n.t('Download as file') : $i18n.t('File not present in vault')}
>
<a
class="link-button"
class:disabled={!hasFile}
href="{api.apiUrl}/raw/{address}"
download={inferredIds[0]}
>
<Icon name="download" />
</a>
</div>
{#if $vaultInfo?.desktop && hasFile}
<div class="icon">
<IconButton
name="window-alt"
on:click={nativeOpen}
title={$i18n.t('Open in default application...') || ''}
/>
</div>
{/if}
{/if}
{/if}
</div>
</div>
<style lang="scss">
@use '$lib/styles/colors';
.upobject {
border-radius: 4px;
&.left-active {
background: linear-gradient(90deg, colors.$orange 0%, transparent 100%);
padding: 2px 0 2px 2px;
}
&.right-active {
background: linear-gradient(90deg, transparent 0%, colors.$orange 100%);
padding: 2px 2px 2px 0;
}
&.plain .address {
border: none;
background: none;
padding: 0;
}
}
.address {
display: flex;
align-items: center;
padding: 0.1em 0.25em;
font-family: var(--monospace-font);
line-break: anywhere;
background: var(--background-lighter);
border: 0.1em solid var(--foreground-lighter);
border-radius: 0.2em;
&.banner {
border: 0.12em solid var(--foreground);
padding: 0.5em 0.25em;
}
&.identified {
font-family: var(--default-font);
font-size: 0.95em;
line-break: auto;
}
.label {
display: flex;
flex-wrap: wrap;
align-items: baseline;
}
.label-inner {
max-width: 100%;
margin-right: 0.25em;
}
&.banner .label {
flex-direction: column;
gap: 0.1em;
}
.secondary {
font-size: 0.66em;
display: none;
opacity: 0.8;
}
.key {
font-family: var(--monospace-font);
color: colors.$yellow;
opacity: 0.8;
&:before {
content: '⌘';
margin-right: 0.1em;
}
}
&.banner .key {
font-size: 0.66em;
}
&:not(.banner) .key {
flex-grow: 1;
text-align: right;
}
&.show-type .secondary,
&.banner .secondary {
display: unset;
}
}
.label {
flex-grow: 1;
min-width: 0;
:global(a) {
text-decoration: none;
}
}
.separator {
width: 0.5em;
}
.icon {
margin: 0 0.1em;
}
.resolving {
opacity: 0.7;
}
.link-button {
opacity: 0.66;
transition:
opacity 0.2s,
color 0.2s;
&:hover {
opacity: 1;
color: var(--active-color, var(--primary));
}
}
.upobject {
transition:
margin 0.2s ease,
box-shadow 0.2s ease;
}
.selected {
margin: 0.12rem;
box-shadow: 0 0 0.1rem 0.11rem colors.$red;
}
.disabled {
pointer-events: none;
opacity: 0.7;
}
</style>

View File

@ -0,0 +1,75 @@
<script lang="ts">
import { useEntity } from '$lib/entity';
import api from '$lib/api';
import { createEventDispatcher } from 'svelte';
import { formatDuration } from '../../../util/fragments/time';
import { concurrentImage } from '../../imageQueue';
const dispatch = createEventDispatcher();
export let address: string;
$: ({ entity } = useEntity(address));
let loaded = null;
let handled = true;
$: dispatch('handled', handled);
$: dispatch('loaded', Boolean(loaded));
let clientHeight = 0;
let clientWidth = 0;
$: fontSize = Math.min(clientHeight, clientWidth) * 0.66;
let mediaDuration = '';
$: {
let duration = $entity?.get('MEDIA_DURATION') as number | undefined;
if (duration) {
mediaDuration = formatDuration(duration);
}
}
</script>
<div class="audiopreview" bind:clientWidth bind:clientHeight>
<img
class:loaded={loaded === address}
alt="Thumbnail for {address}"
use:concurrentImage={`${api.apiUrl}/thumb/${address}?mime=audio`}
on:load={() => (loaded = address)}
on:error
/>
{#if mediaDuration}
<div class="duration" style="--font-size: {fontSize}px">
{mediaDuration}
</div>
{/if}
</div>
<style lang="scss">
.audiopreview {
position: relative;
width: 100%;
}
img {
width: 100%;
height: 100%;
&:not(.loaded) {
flex-grow: 1;
height: 6rem;
max-height: 100%;
width: 100%;
min-width: 0;
}
}
.duration {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: var(--font-size);
font-weight: bold;
color: var(--foreground-lightest);
text-shadow: 0px 0px 0.2em var(--background-lighter);
}
</style>

View File

@ -0,0 +1,431 @@
<script lang="ts">
import { debounce, throttle } from 'lodash';
import { onMount } from 'svelte';
import type { IValue } from '@upnd/upend/types';
import type WaveSurfer from 'wavesurfer.js';
import type { Region, RegionParams } from 'wavesurfer.js/src/plugin/regions';
import api from '$lib/api';
import { TimeFragment } from '../../../util/fragments/time';
import Icon from '../../utils/Icon.svelte';
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 '@upnd/upend/constants';
import debug from 'debug';
const dbg = debug('kestrel:AudioViewer');
export let address: string;
export let detail: boolean;
let editable = false;
let containerEl: HTMLDivElement;
let timelineEl: HTMLDivElement;
let loaded = false;
let wavesurfer: WaveSurfer;
// Zoom handling
let zoom = 1;
const setZoom = throttle((level: number) => {
wavesurfer.zoom(level);
}, 250);
$: if (zoom && wavesurfer) setZoom(zoom);
// Annotations
const DEFAULT_ANNOTATION_COLOR = '#cb4b16';
type UpRegion = Region & { data: IValue };
let currentAnnotation: UpRegion | undefined;
async function loadAnnotations() {
const entity = await api.fetchEntity(address);
entity.backlinks
.filter((e) => e.attribute == 'ANNOTATES')
.forEach(async (e) => {
const annotation = await api.fetchEntity(e.entity);
if (annotation.get('W3C_FRAGMENT_SELECTOR')) {
const fragment = TimeFragment.parse(String(annotation.get('W3C_FRAGMENT_SELECTOR')));
if (fragment) {
wavesurfer.addRegion({
id: `ws-region-${e.entity}`,
color: annotation.get('COLOR') || DEFAULT_ANNOTATION_COLOR,
attributes: {
'upend-address': annotation.address,
label: annotation.get(ATTR_LABEL)
},
data: (annotation.attr['NOTE'] || [])[0]?.value,
...fragment
} as RegionParams);
}
}
});
}
$: if (wavesurfer) {
if (editable) {
wavesurfer.enableDragSelection({ color: DEFAULT_ANNOTATION_COLOR });
} else {
wavesurfer.disableDragSelection();
}
Object.values(wavesurfer.regions.list).forEach((region) => {
region.update({ drag: editable, resize: editable });
});
}
async function updateAnnotation(region: Region) {
dbg('Updating annotation %o', region);
let entity = region.attributes['upend-address'];
// Newly created
if (!entity) {
let [_, newEntity] = await api.putEntry({
entity: {
t: 'Uuid'
}
});
entity = newEntity;
const nextAnnotationIndex = Object.values(wavesurfer.regions.list).length;
const label = `Annotation #${nextAnnotationIndex}`;
region.update({
attributes: { label }
// incorrect types, `update()` does take `attributes`
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
}
if (region.attributes['label']) {
await api.putEntityAttribute(entity, ATTR_LABEL, {
t: 'String',
c: region.attributes['label']
});
}
await api.putEntityAttribute(entity, 'ANNOTATES', {
t: 'Address',
c: address
});
await api.putEntityAttribute(entity, 'W3C_FRAGMENT_SELECTOR', {
t: 'String',
c: new TimeFragment(region.start, region.end).toString()
});
if (region.color !== DEFAULT_ANNOTATION_COLOR) {
await api.putEntityAttribute(entity, 'COLOR', {
t: 'String',
c: region.color
});
}
if (Object.values(region.data).length) {
await api.putEntityAttribute(entity, 'NOTE', region.data as IValue);
}
region.update({
attributes: {
'upend-address': entity
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
}
const updateAnnotationDebounced = debounce(updateAnnotation, 250);
async function deleteAnnotation(region: Region) {
if (region.attributes['upend-address']) {
await api.deleteEntry(region.attributes['upend-address']);
}
}
let rootEl: HTMLElement;
onMount(async () => {
const WaveSurfer = await import('wavesurfer.js');
const TimelinePlugin = await import('wavesurfer.js/src/plugin/timeline');
const RegionsPlugin = await import('wavesurfer.js/src/plugin/regions');
const timelineColor = getComputedStyle(document.documentElement).getPropertyValue(
'--foreground'
);
wavesurfer = WaveSurfer.default.create({
container: containerEl,
waveColor: '#dc322f',
progressColor: '#991c1a',
responsive: true,
backend: 'MediaElement',
mediaControls: true,
normalize: true,
xhr: { cache: 'force-cache' },
plugins: [
TimelinePlugin.default.create({
container: timelineEl,
primaryColor: timelineColor,
primaryFontColor: timelineColor,
secondaryColor: timelineColor,
secondaryFontColor: timelineColor
}),
RegionsPlugin.default.create({})
]
});
wavesurfer.on('ready', () => {
dbg('wavesurfer ready');
loaded = true;
loadAnnotations();
});
wavesurfer.on('region-created', async (region: UpRegion) => {
dbg('wavesurfer region-created', region);
// Updating here, because if `drag` and `resize` are passed during adding,
// updating no longer works.
region.update({ drag: editable, resize: editable });
// If the region was created from the UI
if (!region.attributes['upend-address']) {
await updateAnnotation(region);
// currentAnnotation = region;
}
});
wavesurfer.on('region-updated', (region: UpRegion) => {
// dbg("wavesurfer region-updated", region);
currentAnnotation = region;
});
wavesurfer.on('region-update-end', (region: UpRegion) => {
dbg('wavesurfer region-update-end', region);
updateAnnotation(region);
currentAnnotation = region;
});
wavesurfer.on('region-removed', (region: UpRegion) => {
dbg('wavesurfer region-removed', region);
currentAnnotation = null;
deleteAnnotation(region);
});
// wavesurfer.on("region-in", (region: UpRegion) => {
// dbg("wavesurfer region-in", region);
// currentAnnotation = region;
// });
// wavesurfer.on("region-out", (region: UpRegion) => {
// dbg("wavesurfer region-out", region);
// if (currentAnnotation.id === region.id) {
// currentAnnotation = undefined;
// }
// });
wavesurfer.on('region-click', (region: UpRegion, _ev: MouseEvent) => {
dbg('wavesurfer region-click', region);
currentAnnotation = region;
});
wavesurfer.on('region-dblclick', (region: UpRegion, _ev: MouseEvent) => {
dbg('wavesurfer region-dblclick', region);
currentAnnotation = region;
setTimeout(() => wavesurfer.setCurrentTime(region.start));
});
try {
const peaksReq = await fetch(`${api.apiUrl}/thumb/${address}?mime=audio&type=json`);
const peaks = await peaksReq.json();
wavesurfer.load(`${api.apiUrl}/raw/${address}`, peaks.data);
} catch (e) {
console.warn(`Failed to load peaks: ${e}`);
const entity = await api.fetchEntity(address);
if (
(parseInt(String(entity.get('FILE_SIZE'))) || 0) < 20_000_000 ||
confirm(
$i18n.t(
'File is large (>20 MiB) and UpEnd failed to load waveform from server. Generating the waveform locally may slow down your browser. Do you wish to proceed anyway?'
)
)
) {
console.warn(`Failed to load peaks, falling back to client-side render...`);
wavesurfer.load(`${api.apiUrl}/raw/${address}`);
}
}
const drawBufferThrottled = throttle(() => wavesurfer.drawBuffer(), 200);
const resizeObserver = new ResizeObserver((_entries) => {
drawBufferThrottled();
});
resizeObserver.observe(rootEl);
});
</script>
<div class="audio" class:editable bind:this={rootEl}>
{#if !loaded}
<Spinner centered />
{/if}
{#if loaded}
<header>
<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 class="wavesurfer-timeline" bind:this={timelineEl} class:hidden={!detail} />
<div class="wavesurfer" bind:this={containerEl} />
{#if currentAnnotation}
<LabelBorder>
<span slot="header">{$i18n.t('Annotation')}</span>
{#if currentAnnotation.attributes['upend-address']}
<UpObject link address={currentAnnotation.attributes['upend-address']} />
{/if}
<div class="baseControls">
<div class="regionControls">
<div class="start">
Start: <input
type="number"
value={Math.round(currentAnnotation.start * 100) / 100}
disabled={!editable}
on:input={(ev) => {
currentAnnotation.update({
start: parseInt(ev.currentTarget.value)
});
updateAnnotationDebounced(currentAnnotation);
}}
/>
</div>
<div class="end">
End: <input
type="number"
value={Math.round(currentAnnotation.end * 100) / 100}
disabled={!editable}
on:input={(ev) => {
currentAnnotation.update({
end: parseInt(ev.currentTarget.value)
});
updateAnnotationDebounced(currentAnnotation);
}}
/>
</div>
<div class="color">
Color: <input
type="color"
value={currentAnnotation.color || DEFAULT_ANNOTATION_COLOR}
disabled={!editable}
on:input={(ev) => {
currentAnnotation.update({ color: ev.currentTarget.value });
updateAnnotation(currentAnnotation);
}}
/>
</div>
</div>
{#if editable}
<div class="existControls">
<IconButton outline name="trash" on:click={() => currentAnnotation.remove()} />
<!-- <div class="button">
<Icon name="check" />
</div> -->
</div>
{/if}
</div>
<div class="content">
{#key currentAnnotation}
<Selector
types={['String', 'Address']}
initial={currentAnnotation.data}
disabled={!editable}
on:input={(ev) => {
currentAnnotation.update({ data: ev.detail });
updateAnnotation(currentAnnotation);
}}
/>
{/key}
</div>
</LabelBorder>
{/if}
</div>
<style lang="scss">
@use '../../../styles/colors';
.audio {
width: 100%;
}
header {
display: flex;
justify-content: space-between;
& > * {
flex-basis: 50%;
}
.zoom {
display: flex;
align-items: baseline;
input {
flex-grow: 1;
margin: 0 0.5em 1em 0.5em;
}
}
}
.baseControls,
.content {
margin: 0.5em 0;
}
.baseControls,
.regionControls,
.existControls {
display: flex;
gap: 0.5em;
}
.baseControls {
justify-content: space-between;
}
.regionControls div {
display: flex;
align-items: center;
gap: 0.25em;
}
input[type='number'] {
width: 6em;
}
.hidden {
display: none;
}
:global(.audio:not(.editable) .wavesurfer-handle) {
display: none;
}
:global(.wavesurfer-handle) {
background: var(--foreground-lightest) !important;
}
:global(.wavesurfer-region) {
opacity: 0.5;
}
</style>

View File

@ -0,0 +1,61 @@
<script lang="ts">
import { useEntity } from '$lib/entity';
import Spinner from '../../utils/Spinner.svelte';
export let address: string;
export let detail: boolean;
import { xywh } from '../../../util/fragments/xywh';
import { createEventDispatcher } from 'svelte';
import api from '$lib/api';
const dispatch = createEventDispatcher();
const { entity } = useEntity(address);
$: objectAddress = String($entity?.get('ANNOTATES') || '');
$: imageFragment = String($entity?.get('W3C_FRAGMENT_SELECTOR')).includes('xywh=');
let imageLoaded = false;
$: imageLoaded && dispatch('loaded');
$: if ($entity && !imageFragment) imageLoaded = true;
</script>
<div class="fragment-viewer">
{#if !imageLoaded}
<Spinner />
{/if}
{#if $entity}
{#if imageFragment}
<img
class="preview-image"
class:imageLoaded
src="{api.apiUrl}/{detail ? 'raw' : 'thumb'}/{objectAddress}#{$entity?.get(
'W3C_FRAGMENT_SELECTOR'
)}"
use:xywh
alt={address}
on:load={() => (imageLoaded = true)}
draggable="false"
/>
{/if}
{/if}
</div>
<style lang="scss">
@use '../../../styles/colors';
.fragment-viewer {
width: 100%;
display: flex;
justify-content: center;
min-height: 0;
}
img {
max-width: 100%;
box-sizing: border-box;
min-height: 0;
&.imageLoaded {
border: 2px dashed colors.$yellow;
}
}
</style>

View File

@ -0,0 +1,310 @@
<script lang="ts">
import type { IEntry } from '@upnd/upend/types';
import api from '$lib/api';
import { useEntity } from '$lib/entity';
import IconButton from '../../utils/IconButton.svelte';
import Spinner from '../../utils/Spinner.svelte';
import UpObject from '../UpObject.svelte';
import { ATTR_LABEL } from '@upnd/upend/constants';
import { i18n } from '../../../i18n';
export let address: string;
export let detail: boolean;
let editable = false;
const { entity } = useEntity(address);
let imageLoaded = false;
let imageEl: HTMLImageElement;
$: svg = Boolean($entity?.get('FILE_MIME')?.toString().includes('svg+xml'));
interface Annotorious {
addAnnotation: (a: W3cAnnotation) => void;
on: ((e: 'createAnnotation' | 'deleteAnnotation', c: (a: W3cAnnotation) => void) => void) &
((e: 'updateAnnotation', c: (a: W3cAnnotation, b: W3cAnnotation) => void) => void);
clearAnnotations: () => void;
readOnly: boolean;
destroy: () => void;
}
interface W3cAnnotation {
type: 'Annotation';
body: Array<{ type: 'TextualBody'; value: string; purpose: 'commenting' }>;
target: {
selector: {
type: 'FragmentSelector';
conformsTo: 'http://www.w3.org/TR/media-frags/';
value: string;
};
};
'@context': 'http://www.w3.org/ns/anno.jsonld';
id: string;
}
let anno: Annotorious;
$: if (anno) anno.readOnly = !editable;
$: if (anno) {
anno.clearAnnotations();
$entity?.backlinks
.filter((e) => e.attribute == 'ANNOTATES')
.forEach(async (e) => {
const annotation = await api.fetchEntity(e.entity);
if (annotation.get('W3C_FRAGMENT_SELECTOR')) {
anno.addAnnotation({
type: 'Annotation',
body: annotation.attr[ATTR_LABEL].map((e) => {
return {
type: 'TextualBody',
value: String(e.value.c),
purpose: 'commenting'
};
}),
target: {
selector: {
type: 'FragmentSelector',
conformsTo: 'http://www.w3.org/TR/media-frags/',
value: String(annotation.get('W3C_FRAGMENT_SELECTOR'))
}
},
'@context': 'http://www.w3.org/ns/anno.jsonld',
id: e.entity
});
}
});
}
$: hasAnnotations = $entity?.backlinks.some((e) => e.attribute === 'ANNOTATES');
let a8sLinkTarget: HTMLDivElement;
let a8sLinkAddress: string;
async function loaded() {
const { Annotorious } = await import('@recogito/annotorious');
if (anno) {
anno.destroy();
}
anno = new Annotorious({
image: imageEl,
drawOnSingleClick: true,
fragmentUnit: 'percent',
widgets: [
'COMMENT',
(info: { annotation: W3cAnnotation }) => {
a8sLinkAddress = info.annotation?.id;
return a8sLinkTarget;
}
]
});
anno.on('createAnnotation', async (annotation) => {
const [_, uuid] = await api.putEntry({
entity: {
t: 'Uuid'
}
});
annotation.id = uuid;
await api.putEntry([
{
entity: uuid,
attribute: 'ANNOTATES',
value: {
t: 'Address',
c: address
}
},
{
entity: uuid,
attribute: 'W3C_FRAGMENT_SELECTOR',
value: {
t: 'String',
c: annotation.target.selector.value
}
},
...annotation.body.map((body) => {
return {
entity: uuid,
attribute: ATTR_LABEL,
value: {
t: 'String',
c: body.value
}
} as IEntry;
})
]);
});
anno.on('updateAnnotation', async (annotation) => {
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))
);
await api.putEntry([
{
entity: annotation.id,
attribute: 'W3C_FRAGMENT_SELECTOR',
value: {
t: 'String',
c: annotation.target.selector.value
}
},
...annotation.body.map((body) => {
return {
entity: annotation.id,
attribute: ATTR_LABEL,
value: {
t: 'String',
c: body.value
}
} as IEntry;
})
]);
});
anno.on('deleteAnnotation', async (annotation) => {
await api.deleteEntry(annotation.id);
});
imageLoaded = true;
}
function clicked() {
if (!document.fullscreenElement) {
if (!editable && !hasAnnotations) {
imageEl.requestFullscreen();
}
} else {
document.exitFullscreen();
}
}
let brightnesses = [0.5, 0.75, 1, 1.25, 1.5, 2, 2.5];
let brightnessIdx = 2;
function cycleBrightness() {
brightnessIdx++;
brightnessIdx = brightnessIdx % brightnesses.length;
}
let contrasts = [0.5, 0.75, 1, 1.25, 1.5];
let contrastsIdx = 2;
function cycleContrast() {
contrastsIdx++;
contrastsIdx = contrastsIdx % contrasts.length;
}
$: {
if (imageEl) {
const brightness = brightnesses[brightnessIdx];
const contrast = contrasts[contrastsIdx];
imageEl.style.filter = `brightness(${brightness}) contrast(${contrast})`;
}
}
</script>
<div class="image-viewer">
{#if !imageLoaded}
<Spinner centered />
{/if}
{#if imageLoaded}
<div class="toolbar">
<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
class="image"
class:zoomable={!editable && !hasAnnotations}
on:click={clicked}
on:keydown={(ev) => {
if (ev.key === 'Enter') clicked();
}}
>
<img
class="preview-image"
src="{api.apiUrl}/{detail || svg ? 'raw' : 'thumb'}/{address}"
alt={address}
on:load={loaded}
bind:this={imageEl}
draggable="false"
/>
</div>
<div class="a8sUpLink" bind:this={a8sLinkTarget}>
{#if a8sLinkAddress}
<div class="link">
<UpObject link address={a8sLinkAddress} />
</div>
{/if}
</div>
</div>
<style global lang="scss">
@use '@recogito/annotorious/dist/annotorious.min.css';
.image-viewer {
display: flex;
flex-direction: column;
min-height: 0;
.image {
display: flex;
justify-content: center;
min-height: 0;
& > *,
img {
min-width: 0;
max-width: 100%;
min-height: 0;
max-height: 100%;
}
img {
margin: auto;
}
}
.toolbar {
display: flex;
justify-content: space-between;
margin-bottom: 0.5em;
.image-controls {
display: flex;
}
}
.zoomable {
cursor: zoom-in;
}
img:fullscreen {
cursor: zoom-out;
}
}
.r6o-editor {
font-family: inherit;
}
.a8sUpLink {
text-align: initial;
.link {
margin: 0.5em 1em;
}
}
</style>

View File

@ -0,0 +1,113 @@
<script lang="ts">
import api from '$lib/api';
import IconButton from '../../utils/IconButton.svelte';
import Spinner from '../../utils/Spinner.svelte';
export let address: string;
let mode: 'preview' | 'full' | 'markdown' = 'preview';
$: textContent = (async () => {
const response = await api.fetchRaw(address, mode == 'preview');
const text = await response.text();
if (mode === 'markdown') {
const { marked } = await import('marked');
const DOMPurify = await import('dompurify');
return DOMPurify.default.sanitize(marked.parse(text));
} else {
return text;
}
})();
const tabs = [
['image', 'preview', 'Preview'],
['shape-circle', 'full', 'Full'],
['edit', 'markdown', 'Markdown']
] as [string, typeof mode, string][];
</script>
<div class="text-preview">
<header class="text-header">
{#each tabs as [icon, targetMode, label]}
<div
class="tab"
class:active={mode == targetMode}
on:click={() => (mode = targetMode)}
on:keydown={(ev) => {
if (ev.key === 'Enter') {
mode = targetMode;
}
}}
>
<IconButton name={icon} active={mode == targetMode} on:click={() => (mode = targetMode)} />
<div class="label">{label}</div>
</div>
{/each}
</header>
<div class="text" class:markdown={mode === 'markdown'}>
{#await textContent}
<Spinner centered />
{:then text}
{#if mode === 'markdown'}
{@html text}
{:else}
{text}{#if mode === 'preview'}{/if}
{/if}
{/await}
</div>
</div>
<style lang="scss">
.text-preview {
flex: 1;
min-width: 0;
}
.text {
background: var(--background);
padding: 0.5em;
height: 100%;
box-sizing: border-box;
overflow: auto;
border-radius: 4px;
border: 1px solid var(--foreground);
white-space: pre-wrap;
&.markdown {
white-space: unset;
:global(img) {
max-width: 75%;
}
}
}
header {
display: flex;
justify-content: flex-end;
.tab {
display: flex;
cursor: pointer;
border: 1px solid var(--foreground);
border-bottom: 0;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
padding: 0.15em;
margin: 0 0.1em;
&.active {
background: var(--background);
}
.label {
margin-right: 0.5em;
}
}
}
</style>

View File

@ -0,0 +1,262 @@
<script lang="ts">
import { throttle } from 'lodash';
import Spinner from '../../utils/Spinner.svelte';
import Icon from '../../utils/Icon.svelte';
import { useEntity } from '$lib/entity';
import { i18n } from '../../../i18n';
import { createEventDispatcher } from 'svelte';
import api from '$lib/api';
const dispatch = createEventDispatcher();
export let address: string;
export let detail: boolean;
const { entity } = useEntity(address);
enum State {
LOADING = 'loading',
PREVIEW = 'preview',
PREVIEWING = 'previewing',
PLAYING = 'playing',
ERRORED = 'errored'
}
let state = State.LOADING;
let supported = true;
$: if (state == State.PREVIEW) dispatch('loaded');
$: {
if ($entity && videoEl) {
const mime = $entity.get('FILE_MIME');
if (mime) {
supported = Boolean(videoEl.canPlayType(mime as string));
}
}
}
let videoEl: HTMLVideoElement;
let currentTime: number;
let timeCodeWidth: number;
let timeCodeLeft: string;
let timeCodeSize: string;
const seek = throttle((progress: number) => {
if (state === State.PREVIEWING && videoEl.duration) {
currentTime = videoEl.duration * progress;
if (timeCodeWidth) {
let timeCodeLeftPx = Math.min(
Math.max(videoEl.clientWidth * progress, timeCodeWidth / 2),
videoEl.clientWidth - timeCodeWidth / 2
);
timeCodeLeft = `${timeCodeLeftPx}px`;
timeCodeSize = `${videoEl.clientHeight / 9}px`;
}
}
}, 100);
function updatePreviewPosition(ev: MouseEvent) {
if (state === State.PREVIEW || state === State.PREVIEWING) {
state = State.PREVIEWING;
const bcr = videoEl.getBoundingClientRect();
const progress = (ev.clientX - bcr.x) / bcr.width;
seek(progress);
}
}
function resetPreview() {
if (state === State.PREVIEWING) {
state = State.PREVIEW;
videoEl.load();
}
}
function startPlaying() {
if (detail) {
state = State.PLAYING;
videoEl.play();
}
}
</script>
<div class="video-viewer {state}" class:detail class:unsupported={!supported}>
<div class="player" style="--icon-size: {detail ? 100 : 32}px">
{#if state === State.LOADING}
<Spinner />
{/if}
{#if state === State.LOADING || (!detail && state === State.PREVIEW)}
<!-- svelte-ignore a11y-mouse-events-have-key-events -->
<img
class="thumb"
src="{api.apiUrl}/thumb/{address}?mime=video"
alt="Preview for {address}"
loading="lazy"
on:load={() => (state = State.PREVIEW)}
on:mouseover={() => (state = State.PREVIEWING)}
on:error={() => (state = State.ERRORED)}
/>
{:else}
<!-- svelte-ignore a11y-media-has-caption -->
<video
preload={detail ? 'auto' : 'metadata'}
src="{api.apiUrl}/raw/{address}"
poster="{api.apiUrl}/thumb/{address}?mime=video"
on:mousemove={updatePreviewPosition}
on:mouseleave={resetPreview}
on:click|preventDefault={startPlaying}
controls={state === State.PLAYING}
bind:this={videoEl}
bind:currentTime
/>
{#if !supported}
<div class="unsupported-message">
<div class="label">
{$i18n.t('UNSUPPORTED FORMAT')}
</div>
</div>
{/if}
{/if}
<div class="play-icon">
<Icon plain border name="play" />
</div>
<div
class="timecode"
bind:clientWidth={timeCodeWidth}
style:left={timeCodeLeft}
style:font-size={timeCodeSize}
>
{#if videoEl?.duration && currentTime}
{#if videoEl.duration > 3600}{String(Math.floor(currentTime / 3600)).padStart(
2,
'0'
)}:{/if}{String(Math.floor((currentTime % 3600) / 60)).padStart(2, '0')}:{String(
Math.floor((currentTime % 3600) % 60)
).padStart(2, '0')}
{:else if supported}
<Spinner />
{/if}
</div>
</div>
</div>
<style lang="scss">
.video-viewer {
min-width: 0;
min-height: 0;
&,
.player {
display: flex;
align-items: center;
min-height: 0;
flex-direction: column;
width: 100%;
}
img,
video {
width: 100%;
max-height: 100%;
min-height: 0;
object-fit: contain;
// background: rgba(128, 128, 128, 128);
transition: filter 0.2s;
}
.player {
position: relative;
}
.play-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: var(--icon-size);
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
}
.timecode {
display: none;
pointer-events: none;
position: absolute;
top: 50%;
left: var(--left);
transform: translate(-50%, -50%);
font-feature-settings: 'tnum', 'zero';
font-weight: bold;
color: white;
opacity: 0.66;
}
&.unsupported.detail {
.play-icon {
display: none;
}
}
.unsupported-message {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(1, 1, 1, 0.7);
pointer-events: none;
.label {
position: absolute;
top: 50%;
left: 0;
transform: translateY(-50%);
width: 100%;
text-align: center;
font-weight: bold;
color: darkred;
}
}
&.loading {
.player > * {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
}
&.standby,
&.preview {
img,
video {
filter: brightness(0.75);
}
.play-icon {
opacity: 0.8;
}
}
&.previewing {
.timecode {
display: block;
}
video {
cursor: pointer;
}
}
}
</style>

View File

@ -0,0 +1,121 @@
import debug from 'debug';
import { DEBUG } from '$lib/debug';
const dbg = debug('kestrel:imageQueue');
class ImageQueue {
concurrency: number;
queue: {
element: HTMLElement;
id: string;
callback: () => Promise<void>;
check?: () => boolean;
}[] = [];
active = 0;
constructor(concurrency: number) {
this.concurrency = concurrency;
}
public add(
element: HTMLImageElement,
id: string,
callback: () => Promise<void>,
check?: () => boolean
) {
this.queue = this.queue.filter((e) => e.element !== element);
this.queue.push({ element, id, callback, check });
this.update();
}
private update() {
this.queue.sort((a, b) => {
const aBox = a.element.getBoundingClientRect();
const bBox = b.element.getBoundingClientRect();
const topDifference = aBox.top - bBox.top;
if (topDifference !== 0) {
return topDifference;
} else {
return aBox.left - bBox.left;
}
});
while (this.active < this.concurrency && this.queue.length) {
const nextIdx = this.queue.findIndex((e) => e.check()) || 0;
const next = this.queue.splice(nextIdx, 1)[0];
dbg(`Getting ${next.id}...`);
this.active += 1;
next.element.classList.add('image-loading');
if (DEBUG.imageQueueHalt) {
return;
}
next
.callback()
.then(() => {
dbg(`Loaded ${next.id}`);
})
.catch(() => {
dbg(`Failed to load ${next.id}...`);
})
.finally(() => {
this.active -= 1;
next.element.classList.remove('image-loading');
this.update();
});
}
dbg(
'Active: %d, Queue: %O',
this.active,
this.queue.map((e) => [e.element, e.id])
);
}
}
const imageQueue = new ImageQueue(2);
export function concurrentImage(element: HTMLImageElement, src: string) {
const bbox = element.getBoundingClientRect();
let visible =
bbox.top >= 0 &&
bbox.left >= 0 &&
bbox.bottom <= window.innerHeight &&
bbox.right <= window.innerWidth;
const observer = new IntersectionObserver((entries) => {
visible = entries.some((e) => e.isIntersecting);
});
observer.observe(element);
function queueSelf() {
element.classList.add('image-queued');
const loadSelf = () => {
element.classList.remove('image-queued');
return new Promise<void>((resolve, reject) => {
if (element.src === src) {
resolve();
return;
}
element.addEventListener('load', () => {
resolve();
});
element.addEventListener('error', () => {
reject();
});
element.src = src;
});
};
imageQueue.add(element, src, loadSelf, () => visible);
}
queueSelf();
return {
update(_src: string) {
queueSelf();
},
destroy() {
observer.disconnect();
}
};
}

View File

@ -0,0 +1,144 @@
<script lang="ts">
import { addEmitter } from '../AddModal.svelte';
import Icon from '../utils/Icon.svelte';
import { jobsEmitter } from './Jobs.svelte';
import api from '$lib/api';
import Selector, { type SelectorValue } from '../utils/Selector.svelte';
import { i18n } from '$lib/i18n';
import { goto } from '$app/navigation';
let selector: Selector;
let lastSearched: SelectorValue[] = [];
function addLastSearched(value: SelectorValue) {
switch (value.t) {
case 'Address':
lastSearched = lastSearched.filter((v) => v.t !== 'Address' || v.c !== value.c);
break;
case 'Attribute':
lastSearched = lastSearched.filter((v) => v.t !== 'Attribute' || v.name !== value.name);
break;
}
lastSearched.unshift(value);
lastSearched = lastSearched.slice(0, 10);
}
async function onInput(event: CustomEvent<SelectorValue>) {
const value = event.detail;
if (!value) return;
switch (value.t) {
case 'Address':
addLastSearched(value);
goto(`/browse/${value.c}`);
break;
case 'Attribute':
addLastSearched(value);
{
const attributeAddress = await api.componentsToAddress({
t: 'Attribute',
c: value.name
});
goto(`/browse/${attributeAddress}`);
}
break;
}
selector.reset();
// searchQuery = event.detail;
// if (searchQuery.length > 0) {
// navigate(`/search/${encodeURIComponent(searchQuery)}`, {
// replace: $location.pathname.includes("search"),
// });
// }
}
let fileInput: HTMLInputElement;
function onFileChange() {
if (fileInput.files?.length) {
addEmitter.emit('files', Array.from(fileInput.files));
}
}
async function rescan() {
await api.refreshVault();
jobsEmitter.emit('reload');
}
</script>
<div class="header">
<h1>
<a href="/">
<img class="logo" src="/assets/upend.svg" alt="UpEnd logo" />
<div class="name">UpEnd</div>
</a>
</h1>
<div class="input">
<Selector
types={['Address', 'NewAddress', 'Attribute']}
placeholder={$i18n.t('Search or add') || ''}
on:input={onInput}
bind:this={selector}
emptyOptions={lastSearched}
>
<Icon name="search" slot="prefix" />
</Selector>
</div>
<button class="button" on:click={() => fileInput.click()}>
<Icon name="upload" />
<input type="file" multiple bind:this={fileInput} on:change={onFileChange} />
</button>
<button class="button" on:click={() => rescan()} title="Rescan vault">
<Icon name="refresh" />
</button>
</div>
<style lang="scss">
.header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
height: 3.5rem;
border-bottom: 1px solid var(--foreground);
background: var(--background);
h1 {
font-size: 16pt;
font-weight: normal;
margin: 0;
:global(a) {
display: flex;
align-items: center;
color: var(--foreground-lightest);
text-decoration: none;
font-weight: normal;
}
img {
margin-right: 0.5em;
}
}
.logo {
display: inline-block;
height: 1.5em;
}
.input {
flex-grow: 1;
min-width: 3rem;
}
}
@media screen and (max-width: 600px) {
.name {
display: none;
}
}
</style>

View File

@ -0,0 +1,81 @@
<script lang="ts" context="module">
import mitt from 'mitt';
export type JobsEvents = {
reload: undefined;
};
export const jobsEmitter = mitt<JobsEvents>();
</script>
<script lang="ts">
import type { IJob } from '@upnd/upend/types';
import { fade } from 'svelte/transition';
import ProgessBar from '../utils/ProgessBar.svelte';
import api from '$lib/api';
import { DEBUG } from '$lib/debug';
interface JobWithId extends IJob {
id: string;
}
let jobs: IJob[] = [];
let activeJobs: JobWithId[] = [];
export let active = 0;
$: active = activeJobs.length;
let timeout: NodeJS.Timeout;
async function updateJobs() {
clearTimeout(timeout);
if (!DEBUG.mockJobs) {
jobs = await api.fetchJobs();
} else {
jobs = Array(DEBUG.mockJobs)
.fill(0)
.map((_, i) => ({
id: i.toString(),
title: `Job ${i}`,
job_type: `JobType ${i}`,
state: 'InProgress',
progress: Math.floor(Math.random() * 100)
}));
}
activeJobs = Object.entries(jobs)
.filter(([_, job]) => job.state == 'InProgress')
.map(([id, job]) => {
return { id, ...job };
})
.sort((j1, j2) => j1.id.localeCompare(j2.id))
.sort((j1, j2) => (j2.job_type || '').localeCompare(j1.job_type || ''));
if (activeJobs.length) {
timeout = setTimeout(updateJobs, 500);
} else {
timeout = setTimeout(updateJobs, 5000);
}
}
updateJobs();
jobsEmitter.on('reload', () => {
updateJobs();
});
</script>
{#each activeJobs as job (job.id)}
<div class="job" transition:fade>
<div class="job-label">{job.title}</div>
<ProgessBar value={job.progress} />
</div>
{/each}
<style lang="scss">
.job {
display: flex;
.job-label {
white-space: nowrap;
margin-right: 2em;
}
}
</style>

View File

@ -0,0 +1,81 @@
<script lang="ts">
import type { UpNotification, UpNotificationLevel } from '../../notifications';
import { notify } from '../../notifications';
import { fade } from 'svelte/transition';
import Icon from '../utils/Icon.svelte';
import { DEBUG, lipsum } from '$lib/debug';
let notifications: UpNotification[] = [];
if (DEBUG.mockNotifications) {
notifications = [
{
id: '1',
level: 'error',
content: `This is an error notification, ${lipsum(5)}`
},
{
id: '2',
level: 'warning',
content: `This is a warning notification, ${lipsum(5)}`
},
{
id: '3',
level: 'info',
content: `This is an info notification, ${lipsum(5)}`
}
];
notifications = notifications.slice(0, DEBUG.mockNotifications);
if (notifications.length < DEBUG.mockNotifications) {
notifications = [
...notifications,
...Array(DEBUG.mockNotifications - notifications.length)
.fill(0)
.map(() => ({
id: Math.random().toString(),
level: ['error', 'warning', 'info'][
Math.floor(Math.random() * 3)
] as UpNotificationLevel,
content: lipsum(12)
}))
];
}
notifications = notifications;
}
notify.on('notification', (notification) => {
notifications.push(notification);
notifications = notifications;
setTimeout(() => {
notifications.splice(
notifications.findIndex((n) => (n.id = notification.id)),
1
);
notifications = notifications;
}, 5000);
});
const icons = {
error: 'error-alt',
warning: 'error'
};
</script>
{#each notifications as notification (notification.id)}
<div class="notification notification-{notification.level || 'info'}" transition:fade>
<Icon name={icons[notification.level] || 'bell'} />
{notification.content}
</div>
{/each}
<style lang="scss">
@use '../../styles/colors';
.notification-error {
color: colors.$red;
}
.notification-warning {
color: colors.$orange;
}
</style>

View File

@ -0,0 +1,29 @@
<script lang="ts" context="module">
let loaded = false;
</script>
<script lang="ts">
export let plain = false;
export let name: string;
export let border = false;
if (!loaded) {
document.head.innerHTML += `<link
rel="stylesheet"
href="/vendor/boxicons/css/boxicons.min.css"
/>`;
loaded = true;
}
</script>
<i class="bx bx-{name}" class:plain class:bx-border={border} />
<style>
.bx:not(.plain) {
font-size: 115%;
}
.bx-border {
border-color: white;
}
</style>

View File

@ -0,0 +1,60 @@
<script lang="ts">
import { debounce } from 'lodash';
import { createEventDispatcher } from 'svelte';
import { useEntity } from '$lib/entity';
import type { AttributeCreate, AttributeUpdate } from '$lib/types/base';
import type { UpEntry } from '@upnd/upend';
import LabelBorder from './LabelBorder.svelte';
const dispatch = createEventDispatcher();
export let address: string;
$: ({ entity } = useEntity(address));
let noteEntry: UpEntry | undefined;
let notes: string | undefined = undefined;
$: {
if ($entity?.attr['NOTE']?.length && $entity?.attr['NOTE'][0]?.value?.c) {
noteEntry = $entity?.attr['NOTE'][0];
notes = String(noteEntry.value.c);
} else {
noteEntry = undefined;
notes = undefined;
}
}
let contentEl: HTMLDivElement;
const update = debounce(() => {
if (noteEntry) {
dispatch('change', {
type: 'update',
address: noteEntry.address,
attribute: 'NOTE',
value: { t: 'String', c: contentEl.innerText }
} as AttributeUpdate);
} else {
dispatch('change', {
type: 'create',
address: address,
attribute: 'NOTE',
value: { t: 'String', c: contentEl.innerText }
} as AttributeCreate);
}
}, 500);
</script>
<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">
.notes {
background: var(--background);
border-radius: 4px;
padding: 0.5em !important;
}
</style>

View File

@ -0,0 +1,559 @@
<script lang="ts" context="module">
import type { IValue } from '@upnd/upend/types';
import type { UpEntry } from '@upnd/upend';
import UpEntryComponent from '../display/UpEntry.svelte';
export type SELECTOR_TYPE =
| 'Address'
| 'LabelledAddress'
| 'NewAddress'
| 'Attribute'
| 'NewAttribute'
| 'String'
| 'Number'
| 'Null';
export type SelectorValue = {
t: SELECTOR_TYPE;
} & (
| {
t: 'Address';
c: Address;
entry?: UpEntry;
labels?: string[];
}
| {
t: 'Attribute';
name: string;
labels?: string[];
}
| {
t: 'String';
c: string;
}
| {
t: 'Number';
c: number;
}
| {
t: 'Null';
c: null;
}
);
export type SelectorOption =
| SelectorValue
| { t: 'NewAddress'; c: string }
| { t: 'NewAttribute'; name: string; label: string };
export async function selectorValueAsValue(value: SelectorValue): Promise<IValue> {
switch (value.t) {
case 'Address':
return {
t: 'Address',
c: value.c
};
case 'Attribute':
return {
t: 'Address',
c: await api.componentsToAddress({ t: 'Attribute', c: value.name })
};
case 'String':
return {
t: 'String',
c: value.c
};
case 'Number':
return {
t: 'Number',
c: value.c
};
case 'Null':
return {
t: 'Null',
c: null
};
}
}
</script>
<script lang="ts">
import { debounce } from 'lodash';
import { createEventDispatcher } from 'svelte';
import type { UpListing } from '@upnd/upend';
import type { Address } from '@upnd/upend/types';
import { baseSearchOnce, createLabelled } from '../../util/search';
import UpObject from '../display/UpObject.svelte';
import IconButton from './IconButton.svelte';
import Input from './Input.svelte';
import { matchSorter } from 'match-sorter';
import api from '$lib/api';
import { ATTR_LABEL } from '@upnd/upend/constants';
import { i18n } from '../../i18n';
import debug from 'debug';
import Spinner from './Spinner.svelte';
const dispatch = createEventDispatcher();
const dbg = debug('kestrel:Selector');
let selectorEl: HTMLElement;
export let MAX_OPTIONS = 25;
export let types: SELECTOR_TYPE[] = ['Address', 'NewAddress', 'Attribute', 'String', 'Number'];
export let attributeOptions: string[] | undefined = undefined;
export let emptyOptions: SelectorOption[] | undefined = undefined;
export let placeholder = '';
export let disabled = false;
export let keepFocusOnSet = false;
export let initial: SelectorValue | undefined = undefined;
let inputValue = '';
let updating = false;
$: setInitial(initial);
function setInitial(initial: SelectorValue | undefined) {
if (initial) {
switch (initial.t) {
case 'Address':
case 'String':
inputValue = initial.c;
break;
case 'Attribute':
inputValue = initial.name;
break;
case 'Number':
inputValue = String(initial.c);
break;
}
}
}
let current: (SelectorOption & { t: 'Address' | 'Attribute' | 'String' | 'Number' }) | undefined =
undefined;
export function reset() {
inputValue = '';
current = undefined;
dispatch('input', current);
}
let options: SelectorOption[] = [];
let searchResult: UpListing | undefined = undefined;
const updateOptions = debounce(async (query: string, doSearch: boolean) => {
updating = true;
let result: SelectorOption[] = [];
if (query.length === 0 && emptyOptions !== undefined) {
options = emptyOptions;
updating = false;
return;
}
if (types.includes('Number')) {
const number = parseFloat(query);
if (!Number.isNaN(number)) {
result.push({
t: 'Number',
c: number
});
}
}
if (types.includes('String') && query.length) {
result.push({
t: 'String',
c: query
});
}
options = result;
if (types.includes('Address') || types.includes('LabelledAddress')) {
if (doSearch) {
if (emptyOptions === undefined || query.length > 0) {
searchResult = await baseSearchOnce(query);
} else {
searchResult = undefined;
}
}
let exactHits = Object.entries(addressToLabels)
.filter(([_, labels]) => labels.map((l) => l.toLowerCase()).includes(query.toLowerCase()))
.map(([addr, _]) => addr);
if (exactHits.length) {
exactHits.forEach((addr) =>
result.push({
t: 'Address',
c: addr,
labels: addressToLabels[addr],
entry: null
})
);
} else if (query.length && types.includes('NewAddress')) {
result.push({
t: 'NewAddress',
c: query
});
}
let validOptions = (searchResult?.entries || []).filter((e) => !exactHits.includes(e.entity));
// only includes LabelledAddress
if (!types.includes('Address')) {
validOptions = validOptions.filter((e) => e.attribute == ATTR_LABEL);
}
const sortedOptions = matchSorter(validOptions, inputValue, {
keys: ['value.c', (i) => addressToLabels[i.entity]?.join(' ')]
});
for (const entry of sortedOptions) {
const common = {
t: 'Address' as const,
c: entry.entity
};
if (entry.attribute == ATTR_LABEL) {
result.push({
...common,
labels: [entry.value.c.toString()]
});
} else {
result.push({ ...common, entry });
}
}
}
if (types.includes('Attribute')) {
const allAttributes = await api.fetchAllAttributes();
const attributes = attributeOptions
? allAttributes.filter((attr) => attributeOptions.includes(attr.name))
: allAttributes;
if (emptyOptions === undefined || query.length > 0) {
result.push(
...attributes
.filter(
(attr) =>
attr.name.toLowerCase().includes(query.toLowerCase()) ||
attr.labels.some((label) => label.toLowerCase().includes(query.toLowerCase()))
)
.map(
(attribute) =>
({
t: 'Attribute',
...attribute
}) as SelectorOption
)
);
}
const attributeToCreate = query.toUpperCase().replaceAll(/[^A-Z0-9]/g, '_');
if (
!attributeOptions &&
query &&
!allAttributes.map((attr) => attr.name).includes(attributeToCreate) &&
types.includes('NewAttribute')
) {
result.push({
t: 'NewAttribute',
name: attributeToCreate,
label: query
});
}
}
options = result;
updating = false;
}, 200);
$: dbg('%o Options: %O', selectorEl, options);
$: {
if (inputFocused) {
updateOptions.cancel();
updateOptions(inputValue, true);
addressToLabels = {};
}
}
let addressToLabels: { [key: string]: string[] } = {};
function onAddressResolved(address: string, ev: CustomEvent<string[]>) {
addressToLabels[address] = ev.detail;
updateOptions.cancel();
updateOptions(inputValue, false);
}
async function set(option: SelectorOption) {
dbg('%o Setting option %O', selectorEl, option);
switch (option.t) {
case 'Address':
inputValue = option.c;
current = option;
break;
case 'NewAddress':
{
const addr = await createLabelled(option.c);
inputValue = addr;
current = {
t: 'Address',
c: addr,
labels: [option.c]
};
}
break;
case 'Attribute':
inputValue = option.name;
current = option;
break;
case 'NewAttribute':
inputValue = option.name;
{
const address = await api.componentsToAddress({
t: 'Attribute',
c: option.name
});
await api.putEntityAttribute(address, ATTR_LABEL, {
t: 'String',
c: option.label
});
current = {
t: 'Attribute',
name: option.name,
labels: [option.label]
};
}
break;
case 'String':
inputValue = option.c;
current = option;
break;
case 'Number':
inputValue = String(option.c);
current = option;
break;
}
dbg('%o Result set value: %O', selectorEl, current);
dispatch('input', current);
options = [];
optionFocusIndex = -1;
hover = false;
if (keepFocusOnSet) {
focus();
}
}
let listEl: HTMLUListElement;
let optionFocusIndex = -1;
function handleArrowKeys(ev: KeyboardEvent) {
if (!options.length) {
return;
}
const optionEls = Array.from(listEl.children) as HTMLLIElement[];
let targetIndex = optionEls.findIndex((el) => document.activeElement === el);
switch (ev.key) {
case 'ArrowDown':
targetIndex += 1;
// pressed down on last
if (targetIndex >= optionEls.length) {
targetIndex = 0;
}
break;
case 'ArrowUp':
targetIndex -= 1;
// pressed up on input
if (targetIndex == -2) {
targetIndex = optionEls.length - 1;
}
// pressed up on first
if (targetIndex == -1) {
focus();
return;
}
break;
default:
return; // early return, stop processing
}
if (optionEls[targetIndex]) {
optionEls[targetIndex].focus();
}
}
let input: Input;
export function focus() {
// dbg("%o Focusing input", selectorEl);
input.focus();
}
let inputFocused = false;
let hover = false; // otherwise clicking makes options disappear faster than it can emit a set
$: visible =
(inputFocused || hover || optionFocusIndex > -1) && Boolean(options.length || updating);
$: dispatch('focus', inputFocused || hover || optionFocusIndex > -1);
$: dbg('%o focus = %s, hover = %s, visible = %s', selectorEl, inputFocused, hover, visible);
</script>
<div class="selector" bind:this={selectorEl}>
{#if current?.t === 'Address' && inputValue.length > 0}
<div class="input">
<div class="label">
<UpObject link address={String(current.c)} />
</div>
<IconButton name="x" on:click={() => (inputValue = '')} />
</div>
{:else}
<Input
bind:this={input}
bind:value={inputValue}
on:focusChange={(ev) => (inputFocused = ev.detail)}
on:keydown={handleArrowKeys}
{disabled}
{placeholder}
>
<slot name="prefix" slot="prefix" />
</Input>
{/if}
<ul
class="options"
class:visible
on:mouseenter={() => (hover = true)}
on:mouseleave={() => (hover = false)}
bind:this={listEl}
>
{#if updating}
<li><Spinner centered /></li>
{/if}
{#each options.slice(0, MAX_OPTIONS) as option, idx}
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<li
tabindex="0"
on:click={() => set(option)}
on:mousemove={() => focus()}
on:focus={() => (optionFocusIndex = idx)}
on:blur={() => (optionFocusIndex = -1)}
on:keydown={(ev) => {
if (ev.key === 'Enter') {
set(option);
} else {
handleArrowKeys(ev);
}
}}
>
{#if option.t === 'Address'}
{@const address = option.c}
{#if option.entry}
<UpEntryComponent entry={option.entry} />
{:else}
<UpObject
{address}
labels={option.labels}
on:resolved={(ev) => onAddressResolved(address, ev)}
/>{/if}
{:else if option.t === 'NewAddress'}
<div class="content new">{option.c}</div>
<div class="type">{$i18n.t('Create object')}</div>
{:else if option.t === 'Attribute'}
{#if option.labels.length}
<div class="content">
{#each option.labels as label}
<div class="label">{label}</div>
{/each}
</div>
<div class="type">{option.name}</div>
{:else}
<div class="content">
{option.name}
</div>
{/if}
{:else if option.t === 'NewAttribute'}
<div class="content">{option.label}</div>
<div class="type">{$i18n.t('Create attribute')} ({option.name})</div>
{:else}
<div class="type">{option.t}</div>
<div class="content">{option.c}</div>
{/if}
</li>
{/each}
</ul>
</div>
<style lang="scss">
.selector {
position: relative;
}
.input {
display: flex;
min-width: 0;
.label {
flex: 1;
min-width: 0;
}
}
.options {
position: absolute;
list-style: none;
margin: 2px 0 0;
padding: 0;
border: 1px solid var(--foreground-lighter);
width: 100%;
border-radius: 4px;
background: var(--background);
visibility: hidden;
opacity: 0;
transition: opacity 0.2s;
z-index: 99;
&.visible {
visibility: visible;
opacity: 1;
}
li {
cursor: pointer;
padding: 0.25em;
transition: background-color 0.1s;
&:hover {
background-color: var(--background-lighter);
}
&:focus {
background-color: var(--background-lighter);
outline: none;
}
.type,
.content {
display: inline-block;
}
.type {
opacity: 0.8;
font-size: smaller;
}
.label {
display: inline-block;
}
}
.content.new {
padding: 0.25em;
}
}
</style>

View File

@ -0,0 +1,309 @@
<script lang="ts">
import { readable, type Readable } from 'svelte/store';
import type { UpListing } from '@upnd/upend';
import type { Address } from '@upnd/upend/types';
import { query } from '$lib/entity';
import UpObject from '../display/UpObject.svelte';
import UpObjectCard from '../display/UpObjectCard.svelte';
import { ATTR_LABEL } from '@upnd/upend/constants';
import { i18n } from '../../i18n';
import IconButton from '../utils/IconButton.svelte';
import Selector, { type SelectorValue } 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 select: 'add' | 'remove' = 'add';
export let sort = true;
export let address: Address | undefined = undefined;
$: deduplicatedEntities = Array.from(new Set(entities));
let style: 'list' | 'grid' | 'flex' = 'grid';
let clientWidth: number;
$: style = !thumbnails || clientWidth < 600 ? 'list' : 'grid';
// Sorting
let sortedEntities: Address[] = [];
let sortKeys: { [key: string]: string[] } = {};
function addSortKeys(key: string, vals: string[], resort: boolean) {
if (!sortKeys[key]) {
sortKeys[key] = [];
}
let changed = false;
vals.forEach((val) => {
if (!sortKeys[key].includes(val)) {
changed = true;
sortKeys[key].push(val);
}
});
if (resort && changed) sortEntities();
}
function sortEntities() {
if (!sort) return;
sortedEntities = deduplicatedEntities.concat();
sortedEntities.sort((a, b) => {
if (!sortKeys[a]?.length || !sortKeys[b]?.length) {
if (Boolean(sortKeys[a]?.length) && !sortKeys[b]?.length) {
return -1;
} else if (!sortKeys[a]?.length && Boolean(sortKeys[b]?.length)) {
return 1;
} else {
return a.localeCompare(b);
}
} else {
return sortKeys[a][0].localeCompare(sortKeys[b][0], undefined, {
numeric: true
});
}
});
}
// Labelling
let labelListing: Readable<UpListing> = readable(undefined);
$: {
const addressesString = deduplicatedEntities.map((addr) => `@${addr}`).join(' ');
labelListing = query(`(matches (in ${addressesString}) "${ATTR_LABEL}" ? )`).result;
}
$: {
if ($labelListing) {
deduplicatedEntities.forEach((address) => {
addSortKeys(address, $labelListing.getObject(address).identify(), false);
});
sortEntities();
}
}
if (!sort) {
sortedEntities = entities;
}
// Visibility
let visible: Set<string> = new Set();
let observer = new IntersectionObserver((intersections) => {
intersections.forEach((intersection) => {
const address = (intersection.target as HTMLElement).dataset['address'];
if (!address) {
console.warn('Intersected wrong element?');
return;
}
if (intersection.isIntersecting) {
visible.add(address);
}
visible = visible;
});
});
function observe(node: HTMLElement) {
observer.observe(node);
return {
destroy() {
observer.unobserve(node);
}
};
}
// Adding
let addSelector: Selector | undefined;
let adding = false;
$: if (adding && addSelector) addSelector.focus();
function addEntity(ev: CustomEvent<SelectorValue>) {
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="entitylist style-{style}" class:has-thumbnails={thumbnails} bind:clientWidth>
{#if !sortedEntities.length}
<div class="message">
{$i18n.t('No entries.')}
</div>
{/if}
<div class="items">
{#each sortedEntities as entity (entity)}
<div data-address={entity} data-select-mode={select} use:observe class="item">
{#if visible.has(entity)}
{#if thumbnails}
<UpObjectCard
address={entity}
labels={sortKeys[entity]}
banner={false}
select={select === 'add'}
on:resolved={(event) => {
addSortKeys(entity, event.detail, true);
}}
/>
<div class="icon">
<IconButton name="trash" color="#dc322f" on:click={() => removeEntity(entity)} />
</div>
{:else}
<div class="object">
<UpObject
link
address={entity}
labels={sortKeys[entity]}
select={select === 'add'}
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">
{#if adding}
<Selector
bind:this={addSelector}
placeholder={$i18n.t('Search database or paste an URL')}
types={['Address', 'NewAddress']}
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>
</div>
<style lang="scss">
@use '../../styles/colors';
.items {
gap: 4px;
}
.entitylist.has-thumbnails .items {
gap: 1rem;
}
:global(.entitylist.style-grid .items) {
display: grid;
grid-template-columns: repeat(4, 1fr);
align-items: end;
}
:global(.entitylist.style-flex .items) {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
}
:global(.entitylist.style-list .items) {
display: flex;
flex-direction: column;
align-items: stretch;
}
.item {
min-width: 0;
overflow: hidden;
}
.message {
text-align: center;
margin: 0.5em;
opacity: 0.66;
}
.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

@ -0,0 +1,427 @@
<script lang="ts">
import filesize from 'filesize';
import { formatRelative, fromUnixTime, parseISO } from 'date-fns';
import Ellipsis from '../utils/Ellipsis.svelte';
import UpObject from '../display/UpObject.svelte';
import { createEventDispatcher } from 'svelte';
import type { AttributeUpdate, WidgetChange } from '../../types/base';
import type { UpEntry, UpListing } from '@upnd/upend';
import IconButton from '../utils/IconButton.svelte';
import Selector, { type SelectorValue, selectorValueAsValue } from '../utils/Selector.svelte';
import Editable from '../utils/Editable.svelte';
import { query } from '$lib/entity';
import { type Readable, readable } from 'svelte/store';
import { defaultEntitySort, entityValueSort } from '../../util/sort';
import { attributeLabels } from '../../util/labels';
import { formatDuration } from '../../util/fragments/time';
import { i18n } from '../../i18n';
import UpLink from '../display/UpLink.svelte';
import { ATTR_ADDED, ATTR_LABEL } from '@upnd/upend/constants';
const dispatch = createEventDispatcher();
export let columns: string | undefined = undefined;
export let header = true;
export let orderByValue = false;
export let columnWidths: string[] | undefined = undefined;
export let entries: UpEntry[];
export let attributes: string[] | undefined = undefined;
// Display
$: displayColumns = (columns || 'entity, attribute, value').split(',').map((c) => c.trim());
const TIMESTAMP_COL = 'timestamp';
const PROVENANCE_COL = 'provenance';
const ENTITY_COL = 'entity';
const ATTR_COL = 'attribute';
const VALUE_COL = 'value';
$: templateColumns = (
(displayColumns || []).map((column, idx) => {
if (columnWidths?.[idx]) return columnWidths[idx];
return 'minmax(6em, auto)';
}) as string[]
)
.concat(['auto'])
.join(' ');
// Editing
let adding = false;
let addHover = false;
let addFocus = false;
let newAttrSelector: Selector;
let newEntryAttribute = '';
let newEntryValue: SelectorValue | undefined;
$: if (adding && newAttrSelector) newAttrSelector.focus();
$: if (!addFocus && !addHover) adding = false;
async function addEntry(attribute: string, value: SelectorValue) {
dispatch('change', {
type: 'create',
attribute,
value: await selectorValueAsValue(value)
} as WidgetChange);
newEntryAttribute = '';
newEntryValue = undefined;
}
async function removeEntry(address: string) {
if (confirm($i18n.t('Are you sure you want to remove the property?'))) {
dispatch('change', { type: 'delete', address } as WidgetChange);
}
}
async function updateEntry(address: string, attribute: string, value: SelectorValue) {
dispatch('change', {
type: 'update',
address,
attribute,
value: await selectorValueAsValue(value)
} as AttributeUpdate);
}
// Labelling
let labelListing: Readable<UpListing> = readable(undefined);
$: {
const addresses = [];
entries
.flatMap((e) => (e.value.t === 'Address' ? [e.entity, e.value.c] : [e.entity]))
.forEach((addr) => {
if (!addresses.includes(addr)) {
addresses.push(addr);
}
});
const addressesString = addresses.map((addr) => `@${addr}`).join(' ');
labelListing = query(`(matches (in ${addressesString}) "${ATTR_LABEL}" ? )`).result;
}
// Sorting
let sortedEntries = entries;
let sortKeys: { [key: string]: string[] } = {};
function addSortKeys(key: string, vals: string[], resort: boolean) {
if (!sortKeys[key]) {
sortKeys[key] = [];
}
let changed = false;
vals.forEach((val) => {
if (!sortKeys[key].includes(val)) {
changed = true;
sortKeys[key].push(val);
}
});
if (resort && changed) sortEntries();
}
function sortEntries() {
sortedEntries = orderByValue
? entityValueSort(entries, Object.assign(sortKeys, $attributeLabels))
: defaultEntitySort(entries, Object.assign(sortKeys, $attributeLabels));
}
$: {
if ($labelListing) {
entries.forEach((entry) => {
addSortKeys(entry.entity, $labelListing.getObject(entry.entity).identify(), false);
if (entry.value.t === 'Address') {
addSortKeys(
entry.value.c,
$labelListing.getObject(String(entry.value.c)).identify(),
false
);
}
});
sortEntries();
}
}
entries.forEach((entry) => {
addSortKeys(entry.entity, entry.listing.getObject(entry.entity).identify(), false);
if (entry.value.t === 'Address') {
addSortKeys(entry.value.c, entry.listing.getObject(String(entry.value.c)).identify(), false);
}
});
sortEntries();
// Visibility
let visible: Set<string> = new Set();
let observer = new IntersectionObserver((intersections) => {
intersections.forEach((intersection) => {
const address = (intersection.target as HTMLElement).dataset['address'];
if (!address) {
console.warn('Intersected wrong element?');
return;
}
if (intersection.isIntersecting) {
visible.add(address);
}
visible = visible;
});
});
function observe(node: HTMLElement) {
observer.observe(node);
return {
destroy() {
observer.unobserve(node);
}
};
}
// Formatting & Display
const COLUMN_LABELS: { [key: string]: string } = {
timestamp: $i18n.t('Added at'),
provenance: $i18n.t('Provenance'),
entity: $i18n.t('Entity'),
attribute: $i18n.t('Attribute'),
value: $i18n.t('Value')
};
function formatValue(value: string | number, attribute: string): string {
try {
switch (attribute) {
case 'FILE_SIZE':
return filesize(parseInt(String(value), 10), { base: 2 });
case ATTR_ADDED:
case 'LAST_VISITED':
return formatRelative(fromUnixTime(parseInt(String(value), 10)), new Date());
case 'NUM_VISITED':
return `${value} times`;
case 'MEDIA_DURATION':
return formatDuration(parseInt(String(value), 10));
}
} catch {
// noop.
}
return String(value);
}
// Unused attributes
let unusedAttributes = [];
$: (async () => {
unusedAttributes = await Promise.all(
(attributes || []).filter((attr) => !entries.some((entry) => entry.attribute === attr))
);
})();
</script>
<div class="entry-list" style:--template-columns={templateColumns}>
{#if header}
<header>
{#each displayColumns as column}
<div class="label">
{COLUMN_LABELS[column] || $attributeLabels[column] || column}
</div>
{/each}
<div class="attr-action"></div>
</header>
{/if}
{#each sortedEntries as entry (entry.address)}
{#if visible.has(entry.address)}
{#each displayColumns as column}
{#if column == TIMESTAMP_COL}
<div class="cell" title={entry.timestamp}>
{formatRelative(parseISO(entry.timestamp), new Date())}
</div>
{:else if column == PROVENANCE_COL}
<div class="cell">{entry.provenance}</div>
{:else if column == ENTITY_COL}
<div class="cell entity mark-entity">
<UpObject
link
labels={$labelListing?.getObject(String(entry.entity))?.identify() || []}
address={entry.entity}
on:resolved={(event) => {
addSortKeys(entry.entity, event.detail, true);
}}
/>
</div>
{:else if column == ATTR_COL}
<div
class="cell mark-attribute"
class:formatted={Boolean(Object.keys($attributeLabels).includes(entry.attribute))}
>
<UpLink to={{ attribute: entry.attribute }}>
<Ellipsis
value={$attributeLabels[entry.attribute] || entry.attribute}
title={$attributeLabels[entry.attribute]
? `${$attributeLabels[entry.attribute]} (${entry.attribute})`
: entry.attribute}
/>
</UpLink>
</div>
{:else if column == VALUE_COL}
<div
class="cell value mark-value"
data-address={entry.value.t === 'Address' ? entry.value.c : undefined}
>
<Editable
value={entry.value}
on:edit={(ev) => updateEntry(entry.address, entry.attribute, ev.detail)}
>
{#if entry.value.t === 'Address'}
<UpObject
link
address={String(entry.value.c)}
labels={$labelListing?.getObject(String(entry.value.c))?.identify() || []}
on:resolved={(event) => {
addSortKeys(String(entry.value.c), event.detail, true);
}}
/>
{:else}
<div class:formatted={Boolean(formatValue(entry.value.c, entry.attribute))}>
<Ellipsis
value={formatValue(entry.value.c, entry.attribute) || String(entry.value.c)}
/>
</div>
{/if}
</Editable>
</div>
{:else}
<div>?</div>
{/if}
{/each}
<div class="attr-action">
<IconButton
plain
subdued
name="x-circle"
color="#dc322f"
on:click={() => removeEntry(entry.address)}
/>
</div>
{:else}
<div class="skeleton" data-address={entry.address} use:observe>...</div>
{/if}
{/each}
{#each unusedAttributes as attribute}
{#each displayColumns as column}
{#if column == ATTR_COL}
<div
class="cell mark-attribute"
class:formatted={Boolean(Object.keys($attributeLabels).includes(attribute))}
>
<UpLink to={{ attribute }}>
<Ellipsis
value={$attributeLabels[attribute] || attribute}
title={$attributeLabels[attribute]
? `${$attributeLabels[attribute]} (${attribute})`
: attribute}
/>
</UpLink>
</div>
{:else if column == VALUE_COL}
<div class="cell">
<Editable on:edit={(ev) => addEntry(attribute, ev.detail)}>
<span class="unset">{$i18n.t('(unset)')}</span>
</Editable>
</div>
{:else}
<div class="cell"></div>
{/if}
{/each}
<div class="attr-action"></div>
{/each}
{#if !attributes?.length}
{#if adding}
<div
class="add-row"
on:mouseenter={() => (addHover = true)}
on:mouseleave={() => (addHover = false)}
>
{#each displayColumns as column}
{#if column == ATTR_COL}
<div class="cell mark-attribute">
<Selector
types={['Attribute', 'NewAttribute']}
on:input={(ev) => (newEntryAttribute = ev.detail.name)}
on:focus={(ev) => (addFocus = ev.detail)}
keepFocusOnSet
bind:this={newAttrSelector}
/>
</div>
{:else if column === VALUE_COL}
<div class="cell mark-value">
<Selector
on:input={(ev) => (newEntryValue = ev.detail)}
on:focus={(ev) => (addFocus = ev.detail)}
keepFocusOnSet
/>
</div>
{:else}
<div class="cell"></div>
{/if}
{/each}
<div class="attr-action">
<IconButton name="save" on:click={() => addEntry(newEntryAttribute, newEntryValue)} />
</div>
</div>
{:else}
<div class="add-button">
<IconButton outline subdued name="plus-circle" on:click={() => (adding = true)} />
</div>
{/if}
{/if}
</div>
<style lang="scss">
.entry-list {
display: grid;
grid-template-columns: var(--template-columns);
gap: 0.05rem 0.5rem;
header {
display: contents;
.label {
font-weight: 600;
}
}
.cell {
font-family: var(--monospace-font);
line-break: anywhere;
min-width: 0;
border-radius: 4px;
padding: 2px;
&.formatted,
.formatted {
font-family: var(--default-font);
}
}
.attr-action {
display: flex;
justify-content: center;
align-items: center;
}
.add-row {
display: contents;
}
.add-button {
display: flex;
flex-direction: column;
grid-column: 1 / -1;
}
.unset {
opacity: 0.66;
pointer-events: none;
}
}
</style>

View File

@ -1,59 +1,53 @@
// import { useSWR } from "sswr";
import { derived, type Readable } from "svelte/store";
import { UpListing, UpObject } from "@upnd/upend";
import type {
ListingResult,
EntityListing,
EntityInfo,
} from "@upnd/upend/types";
import { useSWR } from "../util/fetch";
import api from "./api";
import debug from "debug";
const dbg = debug("kestrel:lib");
import { derived, type Readable } from 'svelte/store';
import { UpListing, UpObject } from '@upnd/upend';
import type { EntityInfo, EntityListing, ListingResult } from '@upnd/upend/types';
import { useSWR } from '$lib/util/fetch';
import api from './api';
import debug from 'debug';
const dbg = debug('kestrel:lib');
export function useEntity(address: string) {
const { data, error, revalidate } = useSWR<EntityListing, unknown>(
`${api.apiUrl}/obj/${address}`,
);
const { data, error, revalidate } = useSWR<EntityListing, unknown>(
`${api.apiUrl}/obj/${address}`
);
const entity: Readable<UpObject | undefined> = derived(data, ($listing) => {
if ($listing) {
const listing = new UpListing($listing.entries);
return listing.getObject(address);
}
});
const entity: Readable<UpObject | undefined> = derived(data, ($listing) => {
if ($listing) {
const listing = new UpListing($listing.entries);
return listing.getObject(address);
}
});
const entityInfo: Readable<EntityInfo | undefined> = derived(
data,
($listing) => {
if ($listing) {
return $listing.entity;
}
},
);
const entityInfo: Readable<EntityInfo | undefined> = derived(data, ($listing) => {
if ($listing) {
return $listing.entity;
}
});
return {
entity,
entityInfo,
error,
revalidate,
};
return {
entity,
entityInfo,
error,
revalidate
};
}
export function query(query: string) {
dbg(`Querying: ${query}`);
const { data, error, revalidate } = useSWR<ListingResult, unknown>(
`${api.apiUrl}/query`,
{ method: "POST", body: query },
);
dbg(`Querying: ${query}`);
const { data, error, revalidate } = useSWR<ListingResult, unknown>(`${api.apiUrl}/query`, {
method: 'POST',
body: query
});
const result = derived(data, ($values) => {
return $values ? new UpListing($values) : undefined;
});
const result = derived(data, ($values) => {
return $values ? new UpListing($values) : undefined;
});
return {
result,
error,
revalidate,
};
return {
result,
error,
revalidate
};
}

1
webui/src/lib/index.ts Normal file
View File

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

Some files were not shown because too many files have changed in this diff Show More