[ui] first hackable version

feat/vaults
Tomáš Mládek 2021-11-11 23:37:42 +01:00
parent e51f1b43d3
commit 2c476fcf49
29 changed files with 5622 additions and 1285 deletions

21
tools/upend_js/index.ts Normal file
View File

@ -0,0 +1,21 @@
import type { Address, IEntry, ListingResult, OrderedListing } from "./types";
export function listingAsOrdered(listing: ListingResult): OrderedListing {
const entries = Object.entries(listing) as [Address, IEntry][];
return entries
.sort(([_, a], [__, b]) => String(a.value.c).localeCompare(b.value.c))
.sort(([_, a], [__, b]) => String(a.value.t).localeCompare(b.value.t))
.sort(([_, a], [__, b]) => a.attribute.localeCompare(b.attribute));
}
export function asDict(attributes: OrderedListing): {
[key: string]: string;
} {
const result = {} as { [key: string]: string };
attributes
.map(([_, attribute]) => attribute)
.forEach((attribute) => {
result[attribute.attribute] = attribute.value.c;
});
return result;
}

View File

@ -0,0 +1,10 @@
{
"name": "upend",
"version": "0.0.1",
"description": "Client library to interact with the UpEnd system.",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Tomáš Mládek <t@mldk.cz>",
"license": "MIT"
}

34
tools/upend_js/types.ts Normal file
View File

@ -0,0 +1,34 @@
export type Address = string;
export type VALUE_TYPE = "Value" | "Address" | "Invalid";
export interface IEntry {
entity: Address;
attribute: string;
value: { t: VALUE_TYPE; c: string };
}
export interface ListingResult {
[key: string]: IEntry;
}
export type OrderedListing = [Address, IEntry][];
export interface Job {
title: string;
progress: number;
state: "InProgress" | "Done" | "Failed";
}
export interface IFile {
hash: string;
path: string;
valid: boolean;
added: string;
size: number;
mtime: string;
}
export interface VaultInfo {
name: string | null;
location: string;
}

9
ui/.gitignore vendored
View File

@ -1,4 +1,11 @@
/node_modules/
/public/build/
/public/vendor/
.DS_Store
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions

768
ui/.yarn/releases/yarn-3.1.0.cjs vendored Executable file

File diff suppressed because one or more lines are too long

3
ui/.yarnrc.yml Normal file
View File

@ -0,0 +1,3 @@
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-3.1.0.cjs

View File

@ -1,177 +0,0 @@
import { IEntry, ListingResult } from "@/types/base";
import { fetcher } from "@/utils";
import useSWRV from "swrv";
import { computed, ComputedRef, Ref } from "vue";
export function useEntity(
address: string | (() => string),
condition?: () => Boolean
) {
const { data, error, mutate } = useSWRV<ListingResult, unknown>(
() =>
condition === undefined || condition()
? `/api/obj/${typeof address === "string" ? address : address()}`
: null,
fetcher,
{ revalidateOnFocus: false }
);
const entries = computed(() => {
if (data?.value) {
const entries = Object.entries(data.value) as [string, IEntry][];
return entries
.sort(([_, a], [__, b]) => String(a.value.c).localeCompare(b.value.c))
.sort(([_, a], [__, b]) => String(a.value.t).localeCompare(b.value.t))
.sort(([_, a], [__, b]) => a.attribute.localeCompare(b.attribute));
} else {
return [];
}
});
const attributes = computed(() => {
const addr = typeof address === "string" ? address : address();
return entries.value.filter(([_, e]) => e.entity === addr);
});
const backlinks = computed(() => {
const addr = typeof address === "string" ? address : address();
return entries.value.filter(([_, e]) => e.entity !== addr);
});
return {
entries,
attributes,
backlinks,
data,
error,
mutate
};
}
export function query(
query: string | (() => string),
condition?: () => Boolean
) {
const { data, error, mutate } = useSWRV<ListingResult, unknown>(
() =>
condition === undefined || condition()
? `/api/obj?query=${typeof query === "string" ? query : query()}`
: null,
fetcher,
{ revalidateOnFocus: false }
);
const result = computed(() => {
if (data?.value) {
const entries = Object.entries(data.value) as [string, IEntry][];
return entries
.sort(([_, a], [__, b]) => String(a.value.c).localeCompare(b.value.c))
.sort(([_, a], [__, b]) => String(a.value.t).localeCompare(b.value.t))
.sort(([_, a], [__, b]) => a.attribute.localeCompare(b.attribute));
} else {
return [];
}
});
return {
result,
data,
error,
mutate
};
}
interface EntityIdentification {
type: string;
value: string;
}
export function identify(
attributes: ComputedRef<[string, IEntry][]>,
backlinks: ComputedRef<[string, IEntry][]>
): ComputedRef<EntityIdentification[]> {
// Get all entries where the object is linked
const hasEntries = computed(() => {
return backlinks.value
.filter(([_, entry]) => entry.attribute === "HAS")
.map(([addr, _]) => addr);
});
// Out of those relations, retrieve their ALIAS attrs
const { data: hasListing } = query(() => {
return (
hasEntries.value &&
`(matches (in ${hasEntries.value
.map(e => `"${e}"`)
.join(" ")}) "ALIAS" ?)`
);
});
const aliasValues: ComputedRef<string[]> = computed(() => {
return Object.values(hasListing.value || {}).map(entry => {
return entry.value.c;
});
});
// Get all identities of the object
const isEntries = computed(() => {
return attributes.value
.filter(([_, entry]) => entry.attribute === "IS")
.map(([_, entry]) => entry.value.c);
});
// Out of those, retrieve their TYPE_ID entries
const { data: typeIdListing } = query(() => {
return (
isEntries.value &&
`(matches (in ${isEntries.value
.map(e => `"${e}"`)
.join(" ")}) "TYPE_ID" ?)`
);
});
const typeIdAttributes: ComputedRef<[string, string][]> = computed(() => {
return Object.values(typeIdListing.value || {}).map(entry => {
return [entry.entity, entry.value.c];
});
});
// Finally, filter own object's attributes according to TYPE_IDs
return computed(() => {
// For each identity/TYPE_ID pair
return typeIdAttributes.value
.map(([type, attrName]) => {
// And each associated TYPE_ID attribute...
// return own matchin attributes
return attributes.value
.filter(([_, e]) => e.attribute === attrName)
.map(([_, attr]) => {
return {
type,
value: attr.value.c
};
});
})
.flat()
.concat(
aliasValues.value.map(value => {
return {
type: "ALIAS",
value
};
})
);
});
}
export function asDict(
attributes: [string, IEntry][]
): { [key: string]: string } {
const result = {} as { [key: string]: string };
attributes
.map(([_, attribute]) => attribute)
.forEach(attribute => {
result[attribute.attribute] = attribute.value.c;
});
return result;
}

View File

@ -1,59 +0,0 @@
import { ComponentOptions } from "vue";
export class UpType {
address: string;
name: string | null = null;
attributes: string[] = [];
constructor(address: string) {
this.address = address;
}
public get icon(): string | undefined {
return this.name ? TYPE_ICONS[this.name] : undefined;
}
public get widgetInfo(): Widget | undefined {
return this.name ? TYPE_WIDGETS[this.name] : undefined;
}
}
export interface Widget {
name: string;
icon?: string;
components: ComponentOptions[];
}
const TYPE_ICONS: { [key: string]: string } = {
BLOB: "box",
HIER: "folder"
};
const TYPE_WIDGETS: { [key: string]: Widget } = {
KSX_TRACK_MOODS: {
name: "ksx-track-compass",
icon: "plus-square",
components: [
{
name: "Compass",
id: "compass_tint_energy",
props: {
xAttrName: "KSX_TINT",
yAttrName: "KSX_ENERGY",
xLabel: "Lightsoft // Heavydark",
yLabel: "Chill // Extreme"
}
},
{
name: "Compass",
id: "compass_seriousness_materials",
props: {
xAttrName: "KSX_SERIOUSNESS",
yAttrName: "KSX_MATERIALS",
xLabel: "Dionysia // Apollonia",
yLabel: "Natural // Reinforced"
}
}
]
}
};

View File

@ -11,10 +11,16 @@
"devDependencies": {
"@rollup/plugin-commonjs": "^17.0.0",
"@rollup/plugin-node-resolve": "^11.0.0",
"@rollup/plugin-typescript": "^8.0.0",
"@rollup/plugin-replace": "^3.0.0",
"@rollup/plugin-typescript": "^8.3.0",
"@tsconfig/svelte": "^2.0.0",
"rollup": "^2.3.4",
"@types/history": "^4.7.9",
"@types/lru-cache": "^5.1.1",
"postcss": "^8.3.11",
"rollup": "^2.59.0",
"rollup-plugin-copy": "^3.4.0",
"rollup-plugin-css-only": "^3.1.0",
"rollup-plugin-dev": "^2.0.0",
"rollup-plugin-livereload": "^2.0.0",
"rollup-plugin-svelte": "^7.0.0",
"rollup-plugin-terser": "^7.0.0",
@ -26,6 +32,16 @@
},
"dependencies": {
"@shoelace-style/shoelace": "^2.0.0-beta.58",
"sirv-cli": "^1.0.0"
}
"date-fns": "^2.25.0",
"filesize": "^8.0.6",
"history": "^5.1.0",
"lru-cache": "^6.0.0",
"normalize.css": "^8.0.1",
"sass": "^1.43.4",
"sirv-cli": "^1.0.0",
"sswr": "^1.3.1",
"svelte-navigator": "^3.1.5",
"upend": "file:../tools/upend_js"
},
"packageManager": "yarn@3.1.0"
}

View File

@ -4,10 +4,10 @@
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width,initial-scale=1'>
<title>Svelte app</title>
<title>UpEnd</title>
<link rel='icon' type='image/png' href='/favicon.png'>
<link rel='stylesheet' href='/global.css'>
<!-- <link rel='stylesheet' href='/global.css'> -->
<link rel='stylesheet' href='/build/bundle.css'>
<script defer src='/build/bundle.js'></script>

View File

@ -1,39 +1,18 @@
import replace from "@rollup/plugin-replace";
import svelte from "rollup-plugin-svelte";
import commonjs from "@rollup/plugin-commonjs";
import path from "path";
import copy from "rollup-plugin-copy";
import resolve from "@rollup/plugin-node-resolve";
import livereload from "rollup-plugin-livereload";
import { terser } from "rollup-plugin-terser";
import sveltePreprocess from "svelte-preprocess";
import typescript from "@rollup/plugin-typescript";
import css from "rollup-plugin-css-only";
import dev from "rollup-plugin-dev";
const production = !process.env.ROLLUP_WATCH;
function serve() {
let server;
function toExit() {
if (server) server.kill(0);
}
return {
writeBundle() {
if (server) return;
server = require("child_process").spawn(
"npm",
["run", "start", "--", "--dev"],
{
stdio: ["ignore", "inherit", "inherit"],
shell: true,
}
);
process.on("SIGTERM", toExit);
process.on("exit", toExit);
},
};
}
export default {
input: "src/main.ts",
output: {
@ -43,12 +22,18 @@ export default {
file: "public/build/bundle.js",
},
plugins: [
// To fix `history`
replace({
"process.env.NODE_ENV": JSON.stringify("production"),
}),
svelte({
preprocess: sveltePreprocess({ sourceMap: !production }),
compilerOptions: {
// enable run-time checks when not in production
dev: !production,
},
exclude: [path.resolve(__dirname, "public/vendor")],
}),
// we'll extract any component CSS out into
// a separate file - better for performance
@ -70,20 +55,29 @@ export default {
}),
copy({
copyOnce: true,
hook: "closeBundle",
targets: [
{
src: path.resolve(
__dirname,
"node_modules/@shoelace-style/shoelace/dist/assets"
),
dest: path.resolve(__dirname, "assets/shoelace"),
dest: path.resolve(__dirname, "public/vendor/shoelace"),
},
],
}),
// In dev mode, call `npm run start` once
// the bundle has been generated
!production && serve(),
!production &&
dev({
dirs: ["public"],
proxy: [
{
from: "/api/",
to: "http://localhost:8093/api/",
},
],
}),
// Watch the `public` directory and refresh the
// browser on changes when not in production

View File

@ -1,30 +1,117 @@
<script lang="ts">
export let name: string;
import { Router, Route, createHistory } from "svelte-navigator";
import createHashSource from "./util/history";
import Header from "./layout/Header.svelte";
import Home from "./views/Home.svelte";
import { setBasePath } from "@shoelace-style/shoelace/dist/utilities/base-path.js";
import "@shoelace-style/shoelace/dist/components/input/input.js";
import "@shoelace-style/shoelace/dist/components/icon-button/icon-button.js";
import Browse from "./views/Browse.svelte";
setBasePath("/vendor/shoelace");
$: document.body.classList.toggle(
"sl-theme-dark",
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches
);
const history = createHistory(createHashSource());
</script>
<main>
<h1>Hello {name}!</h1>
<p>Visit the <a href="https://svelte.dev/tutorial">Svelte tutorial</a> to learn how to build Svelte apps.</p>
</main>
<Router {history} primary={false}>
<Header />
<Route path="/"><Home /></Route>
<Route path="/browse/*addresses" let:params>
<Browse />
</Route>
</Router>
<style>
main {
text-align: center;
padding: 1em;
max-width: 240px;
margin: 0 auto;
}
<style global lang="scss">
@use "../node_modules/normalize.css/normalize.css";
@use "../node_modules/@shoelace-style/shoelace/dist/themes/light.css";
@use "../node_modules/@shoelace-style/shoelace/dist/themes/dark.css";
@import url("/assets/fonts/inter.css");
h1 {
color: #ff3e00;
text-transform: uppercase;
font-size: 4em;
font-weight: 100;
}
html {
--default-font: "Inter", sans-serif;
--foreground: #2c3e50;
--background: white;
@media (min-width: 640px) {
main {
max-width: none;
}
}
</style>
b {
color: red;
}
}
@supports (font-variation-settings: normal) {
html {
--default-font: "Inter var", sans-serif;
font-feature-settings: "ss02" on;
}
}
@media (prefers-color-scheme: dark) {
html {
--foreground: white;
--background: #2c3e50;
}
}
html,
body,
#app,
#root {
height: 100%;
font-family: var(--default-font);
color: var(--foreground);
background: var(--background);
}
#root {
color: var(--foreground);
display: flex;
flex-direction: column;
justify-content: space-between;
margin: 1rem 0;
--monospace-font: "Fira Code", "Consolas", "JetBrains Mono", "Inconsolata",
monospace;
}
#main {
display: flex;
flex-grow: 1;
}
#main,
#header {
margin: 0 2rem;
}
#footer {
position: fixed;
bottom: 0;
width: 100%;
display: flex;
flex-direction: column;
background: var(--background);
}
#footer > * {
margin: 1rem;
}
a {
color: var(--foreground);
}
a:visited {
color: var(--foreground);
}
</style>

View File

@ -0,0 +1,64 @@
<script lang="ts">
import { identify, useEntity } from "../lib/entity";
import HashBadge from "./HashBadge.svelte";
import Marquee from "./Marquee.svelte";
import UpLink from "./UpLink.svelte";
export let address: string;
export let link = false;
export let isFile = false;
export let resolve = true;
// Identification
let inferredIds = [];
const { attributes, backlinks } = useEntity(address, () => resolve);
$: {
if (resolve) {
identify($attributes, $backlinks).then((inferredEntries) => {
inferredIds = inferredEntries.map((eid) => eid.value);
});
}
}
</script>
<div class="address" class:identified={Boolean(inferredIds)}>
<HashBadge {address} />
<Marquee>
{#if isFile}
<UpLink to={{ entity: address }}>
{address}
</UpLink>
{:else if link}
<UpLink to={{ entity: address }}>
{inferredIds.join(" | ") || address}
</UpLink>
{:else}
{inferredIds.join(" | ") || address}
{/if}
</Marquee>
</div>
<style scoped lang="scss">
.address {
font-family: var(--monospace-font);
display: flex;
align-items: center;
&,
& a {
line-break: anywhere;
}
&.identified {
font-family: var(--default-font);
font-size: 0.95em;
line-break: auto;
}
.hash-badge {
margin-right: 0.5em;
}
}
</style>

View File

@ -0,0 +1,148 @@
<script lang="ts">
import { createEventDispatcher, SvelteComponent } from "svelte";
import type { IEntry } from "upend/types";
import UpLink from "./UpLink.svelte";
import type { Component, UpType, Widget } from "../lib/types";
import Table from "./widgets/Table.svelte";
const dispatcher = createEventDispatcher();
export let attributes: [string, IEntry][];
export let type: UpType | undefined = undefined;
export let address: String;
export let title: String | undefined = undefined;
export let editable = false;
export let reverse = false;
let currentWidget = "table";
let availableWidgets: Widget[] = [];
$: {
availableWidgets = [
{
name: "table",
icon: "table",
components: [
{
component: Table,
},
],
},
];
if (type?.widgetInfo) {
availableWidgets = [type.widgetInfo, ...availableWidgets];
}
}
let components: Component[] = [];
$: {
components = availableWidgets.find(
(w) => w.name === currentWidget
)!.components;
}
function processChange() {
// noop
}
</script>
<section class="attribute-view">
<header>
<h3>
{#if type}
<UpLink to={{ entity: type.address }}>
{#if type.icon}
<sl-icon name={type.icon} />
{/if}
{type.name || "???"}
</UpLink>
{:else}
{title || "???"}
{/if}
</h3>
{#if availableWidgets.length > 1 || editable}
<div class="views">
{#each availableWidgets as widget (widget.name)}
<sl-icon-button
name={widget.icon || "question-diamond"}
class:active={widget.name === currentWidget}
on:click={() => (currentWidget = widget.name)}
/>
{/each}
</div>
{/if}
</header>
{#each components as component}
<svelte:component
this={component.component}
{...component.props || {}}
{attributes}
{editable}
{reverse}
on:edit={processChange}
/>
{/each}
</section>
<style scoped lang="scss">
section {
position: relative;
overflow: visible;
margin-top: 1.66em;
padding: 1ex 1em;
border: 1px solid var(--foreground);
border-radius: 4px;
header {
margin-bottom: 0.2em;
& > * {
position: absolute;
top: -0.66em;
margin: 0;
background: var(--background);
font-weight: 600;
line-height: 1;
padding: 0 0.75ex;
sl-icon {
margin-bottom: -2px;
}
a {
text-decoration: none;
}
}
h3 {
left: 1ex;
}
.views {
right: 1ex;
font-size: 18px;
sl-icon-button {
&::part(base) {
padding: 0 calc(0.75ex / 2);
}
&.active {
&::part(base) {
color: var(--foreground);
}
}
}
}
}
}
</style>

View File

@ -0,0 +1,58 @@
<script lang="ts">
import { onMount } from "svelte";
const BADGE_HEIGHT = 3;
export let address: string;
let canvas: HTMLCanvasElement | undefined;
let width = 0;
const bytes = [...address].map((c) => c.charCodeAt(0));
while (bytes.length % (3 * BADGE_HEIGHT) !== 0) {
bytes.push(bytes[bytes.length - 1]);
}
width = Math.ceil(bytes.length / 3 / BADGE_HEIGHT);
onMount(() => {
const ctx = canvas?.getContext("2d");
if (!ctx) {
console.warn("Couldn't initialize canvas!");
return;
}
let idx = 0;
while (bytes.length > 0) {
const tmp = [];
while (bytes.length > 0 && tmp.length < 3) {
tmp.push(bytes.shift());
}
while (tmp.length < 3) {
tmp.push(tmp[tmp.length - 1]);
}
const r = (tmp[0]! / 128) * 255;
const g = (tmp[1]! / 128) * 255;
const b = (tmp[2]! / 128) * 255;
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.fillRect(Math.floor(idx / BADGE_HEIGHT), idx % BADGE_HEIGHT, 1, 1);
idx++;
}
});
</script>
<div class="hash-badge">
<canvas bind:this={canvas} {width} height="3" title={address} />
</div>
<style scoped>
.hash-badge {
display: inline-block;
height: 1em;
}
.hash-badge canvas {
height: 100%;
image-rendering: optimizeSpeed;
}
</style>

View File

@ -0,0 +1,159 @@
<script lang="ts">
import AttributeView from "./AttributeView.svelte";
import { query, useEntity } from "../lib/entity";
import Address from "./Address.svelte";
import { UpType } from "../lib/types";
import type { IEntry } from "upend/types";
export let address: string;
export let editable = false;
const { error, revalidate, attributes, backlinks } = useEntity(address);
$: allTypeAddresses = $attributes
.map(([_, attr]) => attr)
.filter((attr) => attr.attribute == "IS")
.map((attr) => attr.value.c);
$: allTypeEntries = query(
() =>
`(matches (in ${allTypeAddresses
.map((addr) => `"${addr}"`)
.join(" ")}) ? ?)`
).result;
let allTypes: { [key: string]: UpType } = {};
$: {
allTypes = {};
$allTypeEntries.forEach(([_, entry]) => {
if (allTypes[entry.entity] === undefined) {
allTypes[entry.entity] = new UpType(entry.entity);
}
switch (entry.attribute) {
case "TYPE":
allTypes[entry.entity].name = entry.value.c;
break;
case "TYPE_HAS":
case "TYPE_REQUIRES":
case "TYPE_ID":
allTypes[entry.entity].attributes.push(entry.value.c);
break;
}
});
allTypes = allTypes;
}
let typedAttributes = {} as { [key: string]: [string, IEntry][] };
let untypedAttributes = [] as [string, IEntry][];
$: {
typedAttributes = {};
untypedAttributes = [];
$attributes.forEach(([entryAddr, entry]) => {
const entryTypes = Object.entries(allTypes).filter(([_, t]) =>
t.attributes.includes(entry.attribute)
);
if (entryTypes.length > 0) {
entryTypes.forEach(([addr, _]) => {
if (typedAttributes[addr] == undefined) {
typedAttributes[addr] = [];
}
typedAttributes[addr].push([entryAddr, entry]);
});
} else {
untypedAttributes.push([entryAddr, entry]);
}
});
typedAttributes = typedAttributes;
untypedAttributes = untypedAttributes;
}
$: filteredUntypedAttributes = untypedAttributes.filter(
([_, entry]) =>
entry.attribute !== "IS" ||
!Object.keys(typedAttributes).includes(entry.value.c)
);
</script>
<div class="inspect">
<h2>
<Address
{address}
isFile={$backlinks.some(([_, e]) => e.attribute === "FILE_IS")}
/>
</h2>
<blob-preview :address="address" />
{#if !$error}
<div>
{#each Object.entries(typedAttributes) as [typeAddr, attributes] (typeAddr)}
<AttributeView
{editable}
{address}
type={allTypes[typeAddr]}
{attributes}
on:edit={revalidate}
/>
{/each}
{#if filteredUntypedAttributes.length > 0 || editable}
<AttributeView
title="Other attributes"
{editable}
{address}
attributes={untypedAttributes}
on:change={revalidate}
/>
{/if}
{#if $backlinks.length > 0}
<AttributeView
title={`Referred to (${$backlinks.length})`}
{address}
attributes={$backlinks}
reverse
/>
{/if}
</div>
{:else}
<div class="error">
{JSON.stringify($error)}
</div>
{/if}
</div>
<style scoped lang="scss">
.hr {
position: relative;
margin: 2rem 0 1rem 0;
hr {
border: none;
border-top: 4px double var(--foreground);
height: 1rem;
}
.hr-label {
position: absolute;
top: -1ex;
left: 50%;
transform: translateX(-50%);
font-weight: bold;
font-size: 18px;
color: var(--foreground);
background: var(--background);
padding: 0 4px;
sl-icon {
margin-bottom: -4px;
}
}
}
.error {
color: red;
}
</style>

View File

@ -0,0 +1,67 @@
<script lang="ts">
import { onMount } from "svelte";
export let speed = 30;
let root: HTMLDivElement | undefined;
let inner: HTMLDivElement | undefined;
let overflowed = false;
let shiftWidth = "unset";
let animLength = "unset";
onMount(() => {
const resizeObserver = new ResizeObserver(() => {
if (!root) return;
overflowed = root.scrollWidth > root.clientWidth;
shiftWidth = `-${inner.clientWidth - root.clientWidth}px`;
animLength = `${inner.clientWidth / speed}s`;
});
resizeObserver.observe(inner);
});
</script>
<div
class="marquee"
class:overflowed
style={`--shift-width: ${shiftWidth}; --anim-length: ${animLength}`}
bind:this={root}
>
<div class="inner" bind:this={inner}>
<slot />
</div>
</div>
<style lang="scss">
.marquee {
height: 1.1em;
overflow: hidden;
flex-grow: 1;
}
.inner {
white-space: nowrap;
display: inline-block;
}
:global {
.overflowed .inner {
animation: marquee var(--anim-length) ease-in-out infinite;
--padding: 0.5em;
}
@keyframes marquee {
0% {
transform: translateX(var(--padding));
}
50% {
transform: translateX(calc(var(--shift-width) - var(--padding)));
}
100% {
transform: translateX(var(--padding));
}
}
}
</style>

View File

@ -0,0 +1,23 @@
<script lang="ts">
import { Link, useLocation } from "svelte-navigator";
import type { Address, VALUE_TYPE } from "upend/types";
export let to: IPointer;
interface IPointer {
entity?: Address;
attribute?: string;
value?: { t: VALUE_TYPE; c: string };
}
const location = useLocation();
let routerTo = "#";
if ($location.pathname.startsWith("/browse") && to.entity) {
routerTo = `${$location.pathname},${to.entity}`;
}
</script>
<Link to={routerTo}>
<slot />
</Link>

View File

@ -0,0 +1,225 @@
<script lang="ts">
import filesize from "filesize";
import { format, fromUnixTime } from "date-fns";
import type { Readable } from "svelte/store";
import type { OrderedListing } from "upend/types";
import Marquee from "../Marquee.svelte";
import Address from "../Address.svelte";
export let attributes: OrderedListing;
export let editable = false;
export let reverse = false;
let newEntryAttribute = "'";
let newEntryValue = "";
let currentDisplay = 999;
const MAX_DISPLAY = 50;
async function addEntry() {
// this.$emit("edit", {
// type: "create",
// attribute: this.newEntryAttribute,
// value: this.newEntryValue,
// } as AttributeChange);
// this.newEntryAttribute = "";
// this.newEntryValue = "";
}
async function removeEntry(addr: string) {
// if (confirm("Are you sure you want to remove the attribute?")) {
// this.$emit("edit", { type: "delete", addr } as AttributeChange);
// }
}
async function updateEntry(addr: string, attribute: string, value: string) {
// this.$emit("edit", {
// type: "update",
// addr,
// value
// } as AttributeChange);
// this.$emit("edit", {
// type: "delete",
// addr,
// } as AttributeChange);
// this.$emit("edit", {
// type: "create",
// attribute,
// value,
// } as AttributeChange);
}
let resolve = [];
const ATTRIBUTE_LABELS: { [key: string]: string } = {
FILE_MIME: "MIME type",
FILE_MTIME: "Last modified",
FILE_SIZE: "File size",
};
const VALUE_FORMATTERS: { [key: string]: (val: string) => string } = {
FILE_MTIME: (val) => format(fromUnixTime(parseInt(val, 10)), "PPpp"),
FILE_SIZE: (val) => filesize(parseInt(val, 10), { base: 2 }),
};
function formatAttribute(attribute: string) {
return ATTRIBUTE_LABELS[attribute];
}
function formatValue(value: string, attribute: string): string | undefined {
const handler = VALUE_FORMATTERS[attribute];
if (handler) {
return handler(value);
}
}
</script>
<div class="table">
<table class:reverse>
<colgroup>
{#if editable}
<col class="attr-action-col" />
{/if}
<col class="attr-col" />
<col />
</colgroup>
{#if !reverse}
<tr>
{#if editable}
<th />
{/if}
<th>Attribute</th>
<th>Value</th>
</tr>
{#each attributes as [id, entry] (id)}
<tr v-for="[id, entry] in limitedAttributes">
{#if editable}
<td class="attr-action">
<sl-icon-button name="x-circle" on:click={removeEntry(id)} />
</td>
{/if}
<td class:formatted={Boolean(formatAttribute(entry.attribute))}>
<Marquee>
{formatAttribute(entry.attribute) || entry.attribute}
</Marquee>
</td>
<td class="value">
<text-input
{editable}
value={entry.value.c}
on:edit={(val) => updateEntry(id, entry.attribute, val)}
>
{#if entry.value.t === "Address"}
<Address
link
address={entry.value.c}
resolve={Boolean(resolve[id]) || true}
data-id={id}
/>
{:else}
<div
class:formatted={Boolean(
formatValue(entry.value.c, entry.attribute)
)}
>
<Marquee>
{formatValue(entry.value.c, entry.attribute) ||
entry.value.c}
</Marquee>
</div>
{/if}
</text-input>
</td>
</tr>
{/each}
{#if attributes.length > currentDisplay}
<tr>
<td colspan={editable ? 3 : 2}>
<sl-button
class="more-button"
on:click={(currentDisplay += MAX_DISPLAY)}
>
+ {attributes.length - currentDisplay} more...
</sl-button>
</td>
</tr>
{/if}
{#if editable}
<tr>
<td class="attr-action">
<sl-icon-button name="plus-circle" on:click={addEntry()} />
</td>
<td>
<sl-input v-sl-model:newEntryAttribute size="small" />
</td>
<td>
<sl-input v-sl-model:newEntryValue size="small" />
</td>
</tr>
{/if}
{:else}
<tr>
<th>Entities</th>
<th>Attribute name</th>
</tr>
{#each attributes as [id, entry] (id)}
<tr>
<td>
<Address link address={entry.entity} />
</td>
<td>
<Marquee>
{entry.attribute}
</Marquee>
</td>
</tr>
{/each}
{/if}
</table>
</div>
<style lang="scss" scoped>
table {
width: 100%;
table-layout: fixed;
th {
text-align: left;
}
td {
font-family: var(--monospace-font);
padding-right: 1em;
line-height: 1em;
line-break: anywhere;
&.attr-action {
max-width: 1em;
}
.formatted {
font-family: var(--default-font);
}
}
.attr-action-col {
width: 1.5em;
}
.attr-col {
width: 33%;
}
&.reverse .attr-col {
width: 70%;
}
sl-icon-button {
&::part(base) {
padding: 2px;
}
}
.more-button {
width: 100%;
}
}
</style>

View File

@ -0,0 +1,67 @@
<script lang="ts">
import { Link } from "svelte-navigator";
export let searchQuery = "";
$: console.log(searchQuery);
// this.$router.replace({ name: "search", query: { q: this.searchQuery } });
</script>
<div class="header">
<h1>
<Link to="/">
<img class="logo" src="/assets/upend.svg" alt="UpEnd logo" />
UpEnd
</Link>
</h1>
<sl-input
placeholder="Search"
value={searchQuery}
on:sl-input={(ev) => (searchQuery = ev.target.value)}
>
<sl-icon name="search" slot="prefix" />
</sl-input>
</div>
<style lang="scss">
.header {
display: flex;
align-items: center;
padding: 0.5rem;
border-bottom: 1px solid var(--foreground);
margin-bottom: 0.5rem;
background: var(--background);
h1 {
font-size: 16pt;
font-weight: normal;
margin: 0;
:global(a) {
display: flex;
align-items: center;
color: var(--foreground);
text-decoration: none;
font-weight: normal;
}
img {
margin-right: 0.5em;
}
}
.logo {
display: inline-block;
height: 1.5em;
@media (prefers-color-scheme: dark) {
filter: invert(1);
}
}
sl-input {
margin-left: 1em;
flex-grow: 1;
}
}
</style>

138
ui/src/lib/entity.ts Normal file
View File

@ -0,0 +1,138 @@
// import { useSWR } from "sswr";
import { useSWR } from "../util/fetch";
import { derived, Readable, readable, writable } from "svelte/store";
import type { IEntry, ListingResult, OrderedListing } from "upend/types";
import { listingAsOrdered } from "upend";
import LRU from "lru-cache";
export function useEntity(
address: string | (() => string),
condition?: () => Boolean
) {
const { data, error, revalidate } = useSWR<ListingResult, unknown>(() =>
condition === undefined || condition()
? `/api/obj/${typeof address === "string" ? address : address()}`
: null
);
const entries = derived(data, ($values) =>
$values ? listingAsOrdered($values) : []
);
const attributes = derived(entries, ($entries) => {
const addr = typeof address === "string" ? address : address();
return $entries.filter(([_, e]) => e.entity === addr);
});
const backlinks = derived(entries, ($entries) => {
const addr = typeof address === "string" ? address : address();
return $entries.filter(([_, e]) => e.entity !== addr);
});
return {
entries,
attributes,
backlinks,
data,
error,
revalidate,
};
}
export function query(query: () => string) {
let queryString = typeof query === "string" ? query : query();
console.debug(`Querying: ${queryString}`);
const { data, error, revalidate } = useSWR<ListingResult, unknown>(
() => `/api/obj?query=${query()}`
);
const result = derived(data, ($values) => {
return $values ? listingAsOrdered($values) : [];
});
return {
result,
data,
error,
revalidate,
};
}
const queryOnceLRU = new LRU<string, OrderedListing>(128);
export async function queryOnce(query: string): Promise<OrderedListing> {
const cacheResult = queryOnceLRU.get(query);
if (!cacheResult) {
console.debug(`Querying: ${query}`);
const response = await fetch(`/api/obj?query=${query}`);
return listingAsOrdered(await response.json());
} else {
console.debug(`Returning cached: ${query}`);
return cacheResult;
}
}
interface EntityIdentification {
type: string;
value: string;
}
export async function identify(
attributes: OrderedListing,
backlinks: OrderedListing
): Promise<EntityIdentification[]> {
// Get all entries where the object is linked
const hasEntries = backlinks
.filter(([_, entry]) => entry.attribute === "HAS")
.map(([addr, _]) => addr);
// Out of those relations, retrieve their ALIAS attrs
const hasAliases = hasEntries.length
? await queryOnce(
`(matches (in ${hasEntries.map((e) => `"${e}"`).join(" ")}) "ALIAS" ?)`
)
: [];
const aliasValues = hasAliases.map(([_, entry]) => {
return entry.value.c;
});
// Get all identities of the object
const isEntries = attributes
.filter(([_, entry]) => entry.attribute === "IS")
.map(([_, entry]) => entry.value.c);
// Out of those, retrieve their TYPE_ID entries
const typeIdListing = isEntries.length
? await queryOnce(
`(matches (in ${isEntries.map((e) => `"${e}"`).join(" ")}) "TYPE_ID" ?)`
)
: [];
const typeIdAttributes = typeIdListing.map(([_, entry]) => {
return [entry.entity, entry.value.c];
});
// Finally, filter own object's attributes according to TYPE_IDs
// For each identity/TYPE_ID pair
return typeIdAttributes
.map(([type, attrName]) => {
// And each associated TYPE_ID attribute...
// return own matchin attributes
return attributes
.filter(([_, e]) => e.attribute === attrName)
.map(([_, attr]) => {
return {
type,
value: attr.value.c,
};
});
})
.flat()
.concat(
aliasValues.map((value) => {
return {
type: "ALIAS",
value,
};
})
);
}

87
ui/src/lib/types.ts Normal file
View File

@ -0,0 +1,87 @@
import type { SvelteComponent, SvelteComponentTyped } from "svelte";
export class UpType {
address: string;
name: string | null = null;
attributes: string[] = [];
constructor(address: string) {
this.address = address;
}
public get icon(): string | undefined {
return this.name ? TYPE_ICONS[this.name] : undefined;
}
public get widgetInfo(): Widget | undefined {
return this.name ? TYPE_WIDGETS[this.name] : undefined;
}
}
export interface Component {
component: any; // TODO
props?: { [key: string]: unknown };
}
export interface Widget {
name: string;
icon?: string;
components: Array<Component>;
}
const TYPE_ICONS: { [key: string]: string } = {
BLOB: "box",
HIER: "folder",
};
const TYPE_WIDGETS: { [key: string]: Widget } = {
KSX_TRACK_MOODS: {
name: "ksx-track-compass",
icon: "plus-square",
components: [
// {
// name: "Compass",
// id: "compass_tint_energy",
// props: {
// xAttrName: "KSX_TINT",
// yAttrName: "KSX_ENERGY",
// xLabel: "Lightsoft // Heavydark",
// yLabel: "Chill // Extreme",
// },
// },
// {
// name: "Compass",
// id: "compass_seriousness_materials",
// props: {
// xAttrName: "KSX_SERIOUSNESS",
// yAttrName: "KSX_MATERIALS",
// xLabel: "Dionysia // Apollonia",
// yLabel: "Natural // Reinforced",
// },
// },
],
},
};
export type AttributeChange =
| AttributeCreate
| AttributeUpdate
| AttributeDelete;
export interface AttributeCreate {
type: "create";
attribute: string;
value: any;
}
export interface AttributeUpdate {
type: "update";
addr: string;
value: any;
}
export interface AttributeDelete {
type: "delete";
addr: string;
}

View File

@ -2,9 +2,6 @@ import App from './App.svelte';
const app = new App({
target: document.body,
props: {
name: 'world'
}
});
export default app;

29
ui/src/util/fetch.ts Normal file
View File

@ -0,0 +1,29 @@
import { writable } from "svelte/store";
// stale shim until https://github.com/ConsoleTVs/sswr/issues/24 is resolved
export type SWRKey = string;
export function useSWR<D = unknown, E = Error>(
key: SWRKey | undefined | (() => SWRKey | undefined)
) {
const data = writable<D | undefined>();
const error = writable<D | undefined>();
function doFetch() {
let keyString = typeof key === "string" ? key : key();
fetch(keyString)
.then(async (response) => {
data.set(await response.json());
})
.catch((err) => {
error.set(err);
});
}
doFetch();
return {
data,
error,
revalidate: doFetch,
};
}

41
ui/src/util/history.ts Normal file
View File

@ -0,0 +1,41 @@
import { createHashHistory } from "history";
import type { HistorySource } from "svelte-navigator";
export default function(): HistorySource {
const history = createHashHistory({window});
let listeners = [];
history.listen(location => {
if (history.action === "POP") {
listeners.forEach(listener => listener(location));
}
});
return {
get location() {
return history.location as any;
},
addEventListener(name, handler) {
if (name !== "popstate") return;
listeners.push(handler);
},
removeEventListener(name, handler) {
if (name !== "popstate") return;
listeners = listeners.filter(fn => fn !== handler);
},
history: {
get state() {
return history.location.state;
},
pushState(state, title, uri) {
history.push(uri, state);
},
replaceState(state, title, uri) {
history.replace(uri, state);
},
go(to) {
history.go(to);
},
},
};
}

View File

@ -0,0 +1,94 @@
<script lang="ts">
import { useNavigate, useParams } from "svelte-navigator";
import Inspect from "../components/Inspect.svelte";
const navigate = useNavigate();
const params = useParams();
let root: HTMLDivElement;
let addresses: string[] = $params.addresses.split(",");
let editable: boolean[] = [];
function visit(idx: number) {
addresses = [addresses[idx]];
}
function close(idx: number) {
addresses.splice(idx, 1);
}
$: {
navigate(`/browse/${addresses.join(",")}`);
root?.scrollTo({
left: root.scrollWidth,
behavior: "smooth",
});
}
</script>
<div class="browser" bind:this={root}>
{#each addresses as address, idx (address)}
<div class="view" data-address={address}>
<header>
<sl-icon-button
class="edit-button"
name="pencil"
click={(editable[idx] = !editable[idx])}
/>
<sl-icon-button
class="this-button"
name="bookmark"
on:click={visit(idx)}
:disabled="addresses.length === 1"
/>
<sl-icon-button
class="close-button"
name="x-circle"
on:click={close(idx)}
disabled={addresses.length === 1}
/>
</header>
<Inspect {address} editable={editable[idx] || false} />
</div>
{/each}
</div>
<style scoped lang="scss">
.browser {
display: flex;
margin-left: -2rem;
margin-right: -2rem;
padding: 0 2rem;
overflow-x: auto;
}
.view {
min-width: 30em;
max-width: 30em;
border-left: 1px solid var(--foreground);
border-right: 1px solid var(--foreground);
margin: 1rem 0;
padding: 0 1rem;
header {
position: relative;
margin: 0;
min-height: 1em;
.this-button {
position: absolute;
left: 50%;
translate: transformX(-50%);
}
.close-button {
position: absolute;
right: 0;
}
}
}
</style>

62
ui/src/views/Home.svelte Normal file
View File

@ -0,0 +1,62 @@
<script lang="ts">
import { Link } from "svelte-navigator";
import type { IFile, VaultInfo } from "upend/types";
let infoData: VaultInfo | undefined;
let latestFiles: IFile[] = [];
fetch("/api/info").then(async (response) => {
infoData = await response.json();
});
fetch("/api/files/latest").then(async (response) => {
latestFiles = await response.json();
});
</script>
<div class="home">
<h1>
Welcome to <em> "{infoData?.name || "UpEnd"}" </em>
</h1>
{#if latestFiles}
<section class="latest">
<h2>Most recently added files</h2>
<ul>
{#each latestFiles as file}
<li>
<div class="file-added">{file.added}</div>
<Link to="/browse/{file.hash}">
<div class="file-path">{file.path}</div>
</Link>
</li>
{/each}
</ul>
</section>
{/if}
</div>
<style lang="scss">
h1 {
text-align: center;
font-weight: normal;
}
.latest {
ul {
list-style: none;
padding: 0;
}
li {
display: flex;
& > * {
margin: 0.1em 0.25em;
}
}
.file-added {
opacity: 0.77;
}
}
</style>

View File

@ -1,6 +1,5 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"include": ["src/**/*"],
"exclude": ["node_modules/*", "__sapper__/*", "public/*"]
}
"include": ["src/**/*", "node_modules/upend/*"],
}

File diff suppressed because it is too large Load Diff