[ui] first hackable version
parent
e51f1b43d3
commit
2c476fcf49
|
@ -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;
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -1,4 +1,11 @@
|
|||
/node_modules/
|
||||
/public/build/
|
||||
/public/vendor/
|
||||
|
||||
.DS_Store
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,3 @@
|
|||
nodeLinker: node-modules
|
||||
|
||||
yarnPath: .yarn/releases/yarn-3.1.0.cjs
|
177
ui/lib/entity.ts
177
ui/lib/entity.ts
|
@ -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;
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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");
|
||||
|
||||
html {
|
||||
--default-font: "Inter", sans-serif;
|
||||
--foreground: #2c3e50;
|
||||
--background: white;
|
||||
|
||||
b {
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #ff3e00;
|
||||
text-transform: uppercase;
|
||||
font-size: 4em;
|
||||
font-weight: 100;
|
||||
@supports (font-variation-settings: normal) {
|
||||
html {
|
||||
--default-font: "Inter var", sans-serif;
|
||||
font-feature-settings: "ss02" on;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
main {
|
||||
max-width: none;
|
||||
@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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -2,9 +2,6 @@ import App from './App.svelte';
|
|||
|
||||
const app = new App({
|
||||
target: document.body,
|
||||
props: {
|
||||
name: 'world'
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -1,6 +1,5 @@
|
|||
{
|
||||
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules/*", "__sapper__/*", "public/*"]
|
||||
"include": ["src/**/*", "node_modules/upend/*"],
|
||||
}
|
4341
ui/yarn.lock
4341
ui/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue