[ui] skeleton of the Svelte ui

feat/vaults
Tomáš Mládek 2021-10-29 18:50:33 +02:00
parent a5dace9e72
commit 929c89ef9a
45 changed files with 1327 additions and 15241 deletions

View File

@ -1,3 +0,0 @@
> 1%
last 2 versions
not dead

View File

@ -1,5 +0,0 @@
[*.{js,jsx,ts,tsx,vue}]
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true

View File

@ -1,2 +0,0 @@
VUE_APP_TITLE=UpEnd
VUE_APP_ASSET_PATH="assets"

40
ui/.gitattributes vendored
View File

@ -1,40 +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.css 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

32
ui/.gitignore vendored
View File

@ -1,30 +1,4 @@
node_modules
/dist
/node_modules/
/public/build/
# yarn
.yarn/*
!.yarn/patches
!.yarn/releases
!.yarn/plugins
!.yarn/sdks
!.yarn/versions
.pnp.*
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.DS_Store

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,7 +0,0 @@
nodeLinker: node-modules
plugins:
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
spec: "@yarnpkg/plugin-interactive-tools"
yarnPath: .yarn/releases/yarn-berry.cjs

View File

@ -1,24 +1,109 @@
# upend
*Psst — looking for a more complete solution? Check out [SvelteKit](https://kit.svelte.dev), the official framework for building web applications of all sizes, with a beautiful development experience and flexible filesystem-based routing.*
## Project setup
*Looking for a shareable component template instead? You can [use SvelteKit for that as well](https://kit.svelte.dev/docs#packaging) or the older [sveltejs/component-template](https://github.com/sveltejs/component-template)*
---
# svelte app
This is a project template for [Svelte](https://svelte.dev) apps. It lives at https://github.com/sveltejs/template.
To create a new project based on this template using [degit](https://github.com/Rich-Harris/degit):
```bash
npx degit sveltejs/template svelte-app
cd svelte-app
```
*Note that you will need to have [Node.js](https://nodejs.org) installed.*
## Get started
Install the dependencies...
```bash
cd svelte-app
npm install
```
### Compiles and hot-reloads for development
```
npm run serve
...then start [Rollup](https://rollupjs.org):
```bash
npm run dev
```
### Compiles and minifies for production
```
Navigate to [localhost:5000](http://localhost:5000). You should see your app running. Edit a component file in `src`, save it, and reload the page to see your changes.
By default, the server will only respond to requests from localhost. To allow connections from other computers, edit the `sirv` commands in package.json to include the option `--host 0.0.0.0`.
If you're using [Visual Studio Code](https://code.visualstudio.com/) we recommend installing the official extension [Svelte for VS Code](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode). If you are using other editors you may need to install a plugin in order to get syntax highlighting and intellisense.
## Building and running in production mode
To create an optimised version of the app:
```bash
npm run build
```
### Lints and fixes files
```
npm run lint
You can run the newly built app with `npm run start`. This uses [sirv](https://github.com/lukeed/sirv), which is included in your package.json's `dependencies` so that the app will work when you deploy to platforms like [Heroku](https://heroku.com).
## Single-page app mode
By default, sirv will only respond to requests that match files in `public`. This is to maximise compatibility with static fileservers, allowing you to deploy your app anywhere.
If you're building a single-page app (SPA) with multiple routes, sirv needs to be able to respond to requests for *any* path. You can make it so by editing the `"start"` command in package.json:
```js
"start": "sirv public --single"
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).
## Using TypeScript
This template comes with a script to set up a TypeScript development environment, you can run it immediately after cloning the template with:
```bash
node scripts/setupTypeScript.js
```
Or remove the script via:
```bash
rm scripts/setupTypeScript.js
```
If you want to use `baseUrl` or `path` aliases within your `tsconfig`, you need to set up `@rollup/plugin-alias` to tell Rollup to resolve the aliases. For more info, see [this StackOverflow question](https://stackoverflow.com/questions/63427935/setup-tsconfig-path-in-svelte).
## Deploying to the web
### With [Vercel](https://vercel.com)
Install `vercel` if you haven't already:
```bash
npm install -g vercel
```
Then, from within your project folder:
```bash
cd public
vercel deploy --name my-project
```
### With [surge](https://surge.sh/)
Install `surge` if you haven't already:
```bash
npm install -g surge
```
Then, from within your project folder:
```bash
npm run build
surge public my-project.surge.sh
```

View File

@ -1,5 +0,0 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

View File

@ -1,36 +1,31 @@
{
"name": "upend-ui",
"version": "0.1.0",
"name": "svelte-app",
"version": "1.0.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
"dependencies": {
"@shoelace-style/shoelace": "^2.0.0-beta.40",
"core-js": "^3.12.1",
"date-fns": "^2.21.3",
"filesize": "^8.0.3",
"normalize.css": "^8.0.1",
"sass": "^1.34.0",
"sass-loader": "^10.2.0",
"swrv": "^1.0.0-beta.8",
"vue": "^3.0.11",
"vue-router": "^4.0.8"
"build": "rollup -c",
"dev": "rollup -c -w",
"start": "sirv public --no-clear",
"check": "svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^2.34.0",
"@typescript-eslint/parser": "^2.34.0",
"@vue/cli-plugin-babel": "~4.5.13",
"@vue/cli-plugin-eslint": "~4.5.13",
"@vue/cli-plugin-router": "~4.5.13",
"@vue/cli-plugin-typescript": "~4.5.13",
"@vue/cli-service": "~4.5.13",
"@vue/compiler-sfc": "^3.0.11",
"@vue/eslint-config-typescript": "^5.1.0",
"eslint": "^6.8.0",
"eslint-plugin-vue": "^7.9.0",
"typescript": "^4.2.4"
"@rollup/plugin-commonjs": "^17.0.0",
"@rollup/plugin-node-resolve": "^11.0.0",
"@rollup/plugin-typescript": "^8.0.0",
"@tsconfig/svelte": "^2.0.0",
"rollup": "^2.3.4",
"rollup-plugin-css-only": "^3.1.0",
"rollup-plugin-livereload": "^2.0.0",
"rollup-plugin-svelte": "^7.0.0",
"rollup-plugin-terser": "^7.0.0",
"svelte": "^3.0.0",
"svelte-check": "^2.0.0",
"svelte-preprocess": "^4.0.0",
"tslib": "^2.0.0",
"typescript": "^4.0.0"
},
"dependencies": {
"@shoelace-style/shoelace": "^2.0.0-beta.58",
"sirv-cli": "^1.0.0"
}
}

BIN
ui/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

63
ui/public/global.css Normal file
View File

@ -0,0 +1,63 @@
html, body {
position: relative;
width: 100%;
height: 100%;
}
body {
color: #333;
margin: 0;
padding: 8px;
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
}
a {
color: rgb(0,100,200);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
a:visited {
color: rgb(0,80,160);
}
label {
display: block;
}
input, button, select, textarea {
font-family: inherit;
font-size: inherit;
-webkit-padding: 0.4em 0;
padding: 0.4em;
margin: 0 0 0.5em 0;
box-sizing: border-box;
border: 1px solid #ccc;
border-radius: 2px;
}
input:disabled {
color: #ccc;
}
button {
color: #333;
background-color: #f4f4f4;
outline: none;
}
button:disabled {
color: #999;
}
button:not(:disabled):active {
background-color: #ddd;
}
button:focus {
border-color: #666;
}

View File

@ -1,17 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
<head>
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width,initial-scale=1'>
<title>Svelte app</title>
<link rel='icon' type='image/png' href='/favicon.png'>
<link rel='stylesheet' href='/global.css'>
<link rel='stylesheet' href='/build/bundle.css'>
<script defer src='/build/bundle.js'></script>
</head>
<body>
</body>
</html>

99
ui/rollup.config.js Normal file
View File

@ -0,0 +1,99 @@
import svelte from "rollup-plugin-svelte";
import commonjs from "@rollup/plugin-commonjs";
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";
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: {
sourcemap: true,
format: "iife",
name: "app",
file: "public/build/bundle.js",
},
plugins: [
svelte({
preprocess: sveltePreprocess({ sourceMap: !production }),
compilerOptions: {
// enable run-time checks when not in production
dev: !production,
},
}),
// we'll extract any component CSS out into
// a separate file - better for performance
css({ output: "bundle.css" }),
// If you have external dependencies installed from
// npm, you'll most likely need these plugins. In
// some cases you'll need additional configuration -
// consult the documentation for details:
// https://github.com/rollup/plugins/tree/master/packages/commonjs
resolve({
browser: true,
dedupe: ["svelte"],
}),
commonjs(),
typescript({
sourceMap: !production,
inlineSources: !production,
}),
copy({
targets: [
{
src: path.resolve(
__dirname,
"node_modules/@shoelace-style/shoelace/dist/assets"
),
dest: path.resolve(__dirname, "assets/shoelace"),
},
],
}),
// In dev mode, call `npm run start` once
// the bundle has been generated
!production && serve(),
// Watch the `public` directory and refresh the
// browser on changes when not in production
!production && livereload("public"),
// If we're building for production (npm run build
// instead of npm run dev), minify
production && terser(),
],
watch: {
clearScreen: false,
},
};

30
ui/src/App.svelte Normal file
View File

@ -0,0 +1,30 @@
<script lang="ts">
export let name: string;
</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>
<style>
main {
text-align: center;
padding: 1em;
max-width: 240px;
margin: 0 auto;
}
h1 {
color: #ff3e00;
text-transform: uppercase;
font-size: 4em;
font-weight: 100;
}
@media (min-width: 640px) {
main {
max-width: none;
}
}
</style>

View File

@ -1,119 +0,0 @@
<template>
<div id="root" :class="{ 'sl-theme-dark': prefersDark }">
<header id="header">
<Header />
</header>
<main id="main">
<router-view />
</main>
<footer id="footer">
<Jobs />
</footer>
</div>
</template>
<script lang="ts">
import Header from "@/components/Header.vue";
import Jobs from "@/components/Jobs.vue";
import { defineComponent } from "vue";
export default defineComponent({
name: "App",
components: { Header, Jobs },
data() {
return {
prefersDark: false,
};
},
mounted() {
this.prefersDark =
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches;
},
});
</script>
<style lang="scss">
@import "../node_modules/normalize.css/normalize.css";
@import "../node_modules/@shoelace-style/shoelace/dist/themes/base.css";
@import "../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;
}
@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

@ -1,88 +0,0 @@
<template>
<div :class="['address', { identified: Boolean(inferredIds) }]" ref="root">
<hash-badge :address="address" class="hash-badge" />
<Marquee class="label">
<up-link v-if="isFile" :to="{ entity: address }">
{{ address }}
</up-link>
<template v-else>
<up-link v-if="link" :to="{ entity: address }">
{{ inferredIds.join(" | ") || address }}
</up-link>
<template v-else>
{{ inferredIds.join(" | ") || address }}
</template>
</template>
</Marquee>
</div>
</template>
<script lang="ts">
import { identify, useEntity } from "@/lib/entity";
import { computed, ComputedRef, defineComponent, onMounted, ref } from "vue";
import HashBadge from "./HashBadge.vue";
import UpLink from "./UpLink.vue";
import Marquee from "./Marquee.vue";
export default defineComponent({
components: { HashBadge, UpLink, Marquee },
name: "Address",
props: {
address: {
type: String,
required: true,
},
link: {
type: Boolean,
default: false,
},
isFile: {
type: Boolean,
default: false,
},
resolve: {
type: Boolean,
default: true,
},
},
setup(props) {
// Identification
const { attributes, backlinks } = useEntity(props.address, () => props.resolve);
const inferredEntries = identify(attributes, backlinks);
const inferredIds: ComputedRef<string[]> = computed(() => {
return inferredEntries.value.map((eid) => eid.value);
});
return {
inferredIds,
};
},
});
</script>
<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;
}
.label {
flex-grow: 1;
}
}
</style>

View File

@ -1,195 +0,0 @@
<template>
<section class="attribute-view">
<header>
<h3>
<up-link v-if="type" :to="{ entity: type.address }">
<sl-icon v-if="type.icon" :name="type.icon" />
{{ type.name || "???" }}
</up-link>
<template v-else> {{ title || "???" }} </template>
</h3>
<div class="views" v-if="availableWidgets.length > 1 || editable">
<sl-icon-button
v-for="widget in availableWidgets"
:key="widget.name"
:name="widget.icon || 'question-diamond'"
:class="{ active: widget.name === currentWidget }"
@click="currentWidget = widget.name"
/>
</div>
</header>
<component
v-for="component in components"
:key="component.id"
:is="component.name"
v-bind="component.props"
:attributes="attributes"
:editable="editable"
:reverse="reverse"
@edit="processChange"
/>
</section>
</template>
<script lang="ts">
import UpLink from "@/components/UpLink.vue";
import Compass from "@/components/widgets/Compass.vue";
import Table from "@/components/widgets/Table.vue";
import { UpType, Widget } from "@/lib/types";
import { AttributeChange, IEntry } from "@/types/base";
import { ComponentOptions, defineComponent, PropType } from "vue";
export default defineComponent({
name: "AttributeView",
components: {
UpLink,
Table,
Compass,
},
emits: ["edit"],
props: {
attributes: {
type: Array as PropType<[string, IEntry][]>,
required: true,
},
type: {
type: Object as PropType<UpType>,
},
address: {
type: String,
required: true,
},
title: {
type: String,
required: false,
},
editable: {
type: Boolean,
default: false,
},
reverse: {
type: Boolean,
default: false,
},
},
data() {
return {
currentWidget: "table",
};
},
computed: {
availableWidgets(): Widget[] {
const result = [] as Widget[];
if (this.type?.widgetInfo) {
result.push(this.type.widgetInfo);
}
result.push({
name: "table",
icon: "table",
components: [
{
name: "Table",
},
],
});
return result;
},
components(): ComponentOptions[] {
return this.availableWidgets.find((w) => w.name === this.currentWidget)!
.components;
},
},
methods: {
async processChange(change: AttributeChange) {
switch (change.type) {
case "create":
await fetch(`/api/obj`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
entity: this.address,
attribute: change.attribute,
value: {
t: "Value",
c: change.value,
},
}),
});
break;
case "delete":
await fetch(`/api/obj/${change.addr}`, { method: "DELETE" });
break;
default:
console.error(`Unimplemented: ${JSON.stringify(change)}`);
}
this.$emit("edit");
},
},
mounted() {
this.currentWidget = this.availableWidgets[0].name;
},
});
</script>
<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

@ -1,49 +0,0 @@
<template>
<div class="preview" v-if="mimeType">
<template v-if="mimeType.startsWith('audio')">
<audio controls :src="`/api/raw/${address}`" />
</template>
<template v-if="mimeType.startsWith('image')">
<a target="_blank" :href="`/api/raw/${address}`">
<img :src="`/api/raw/${address}`" />
</a>
</template>
</div>
</template>
<script lang="ts">
import { useEntity } from "@/lib/entity";
import { defineComponent } from "vue";
export default defineComponent({
name: "BlobPreview",
props: {
address: {
type: String,
required: true
}
},
computed: {
mimeType(): string | undefined {
return this.attributes.find(([_, e]) => e.attribute === "FILE_MIME")?.[1]
.value.c;
}
},
setup(props) {
const { attributes, backlinks, error } = useEntity(props.address);
return { attributes };
}
});
</script>
<style scoped lang="scss">
.preview {
display: flex;
justify-content: center;
align-items: center;
}
audio {
width: 100%;
}
</style>

View File

@ -1,75 +0,0 @@
<template>
<div class="hash-badge">
<canvas ref="canvas" :width="width" height="3" :title="address" />
</div>
</template>
<script lang="ts">
import { defineComponent, onMounted, ref } from "vue";
const BADGE_HEIGHT = 3;
export default defineComponent({
name: "HashBadge",
props: {
address: {
type: String,
required: true,
},
},
setup(props) {
const canvas = ref<HTMLCanvasElement | undefined>(undefined);
const width = ref(0);
const bytes = [...props.address].map((c) => c.charCodeAt(0));
while (bytes.length % (3 * BADGE_HEIGHT) !== 0) {
bytes.push(bytes[bytes.length - 1]);
}
width.value = Math.ceil(bytes.length / 3 / BADGE_HEIGHT);
onMounted(() => {
const ctx = canvas.value?.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++;
}
});
return {
canvas,
width,
};
},
});
</script>
<style scoped>
.hash-badge {
display: inline-block;
height: 1em;
}
.hash-badge canvas {
height: 100%;
image-rendering: optimizeSpeed;
}
</style>

View File

@ -1,77 +0,0 @@
<template>
<div class="header">
<h1>
<router-link :to="{ name: 'home' }">
<img class="logo" src="/assets/upend.svg" alt="UpEnd logo" />
UpEnd
</router-link>
</h1>
<sl-input placeholder="Search" v-sl-model:searchQuery>
<!-- eslint-disable-next-line vue/no-deprecated-slot-attribute -->
<sl-icon name="search" slot="prefix"></sl-icon>
</sl-input>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
name: "Header",
data() {
return {
searchQuery: "",
};
},
watch: {
searchQuery() {
this.$router.replace({ name: "search", query: { q: this.searchQuery } });
},
},
});
</script>
<style scoped lang="scss">
.header {
display: flex;
align-items: center;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--foreground);
margin-bottom: 0.5rem;
background: var(--background);
h1 {
font-size: 14pt;
font-weight: normal;
margin: 0;
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>

View File

@ -1,71 +0,0 @@
<template>
<transition-group name="fade">
<div class="job" v-for="job in activeJobs" :key="job.id">
<div class="job-label">{{ job.title }}</div>
<sl-progress-bar :percentage="job.progress"
>{{ Math.round(job.progress) }}%</sl-progress-bar
>
</div>
</transition-group>
</template>
<script lang="ts">
import { Job } from "@/types/base";
import { defineComponent } from "vue";
interface JobWithId extends Job {
id: string;
}
export default defineComponent({
name: "Jobs",
data: () => {
return {
jobs: {} as { [key: string]: Job },
};
},
computed: {
activeJobs(): JobWithId[] {
return Object.entries(this.jobs)
.filter(([_, job]) => job.state == "InProgress")
.map(([id, job]) => {
return { id, ...job };
});
},
},
mounted() {
setInterval(async () => {
let request = await fetch("/api/jobs");
this.jobs = await request.json();
}, 3333);
},
});
</script>
<style scoped lang="scss">
.job {
display: flex;
.job-label {
white-space: nowrap;
margin-right: 2em;
}
sl-progress-bar {
width: 100%;
}
}
</style>
<style lang="scss">
.fade-enter-active,
.fade-leave-active {
transition: all 1s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(30px);
}
</style>

View File

@ -1,86 +0,0 @@
<template>
<div
class="marquee"
:class="{ overflowed }"
:style="`--shift-width: ${shiftWidth}; --anim-length: ${animLength}`"
ref="root"
>
<div class="inner" ref="inner">
<slot></slot>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, onMounted, ref } from "vue";
export default defineComponent({
name: "Marquee",
props: {
speed: {
default: 30,
type: Number,
},
},
setup(props) {
const root = ref<HTMLDivElement | null>(null);
const inner = ref<HTMLDivElement | null>(null);
const overflowed = ref(false);
const shiftWidth = ref("unset");
const animLength = ref("unset");
onMounted(() => {
const resizeObserver = new ResizeObserver(() => {
if (!root.value) return;
overflowed.value = root.value!.scrollWidth > root.value!.clientWidth;
shiftWidth.value = `-${
inner.value!.clientWidth - root.value!.clientWidth
}px`;
animLength.value = `${inner.value!.clientWidth / props.speed}s`;
});
resizeObserver.observe(inner.value!);
});
return {
root,
inner,
overflowed,
shiftWidth,
animLength,
};
},
});
</script>
<style scoped lang="scss">
.marquee {
height: 1.1em;
overflow: hidden;
}
.inner {
white-space: nowrap;
display: inline-block;
}
</style>
<style lang="scss">
.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

@ -1,61 +0,0 @@
<template>
<router-link
:to="{ name: 'browse', params: { addresses: [result.entity] } }"
class="search-result"
>
<div class="search-result-container">
<div class="search-result-attribute">{{ result.attribute }}</div>
<div class="search-result-value">
<Marquee>{{ result.value.c }}</Marquee>
</div>
</div>
</router-link>
</template>
<script lang="ts">
import { IEntry } from "@/types/base";
import { defineComponent, PropType } from "vue";
import Marquee from "./Marquee.vue";
export default defineComponent({
components: { Marquee },
name: "SearchResult",
props: {
result: {
type: Object as PropType<IEntry>,
required: true,
},
},
});
</script>
<style scoped lang="scss">
.search-result {
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
height: 100%;
border: 1px solid var(--foreground);
border-radius: 1rem;
.search-result-container {
padding: 1em;
}
text-align: center;
color: --foreground;
text-decoration: inherit;
div {
margin: 0.5rem 0;
}
.search-result-attribute {
font-weight: bold;
}
}
</style>

View File

@ -1,92 +0,0 @@
<template>
<div class="text-input">
<sl-icon-button
v-if="editable"
class="edit-button"
name="pencil"
@click="editing = true"
:disabled="editing"
/>
<div v-if="editing" class="edit">
<sl-input
type="text"
size="small"
v-sl-model:myValue
@blur="submit"
ref="input"
/>
<!-- <sl-icon-button
name="box-arrow-in-right"
@click="$emit('edit', myValue)"
/> -->
</div>
<div v-else class="content">
<slot></slot>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, nextTick, ref, watchEffect } from "vue";
export default defineComponent({
name: "TextInput",
emits: ["edit"],
props: {
editable: {
type: Boolean,
default: true,
},
value: {
required: true,
},
},
setup(props) {
const myValue = ref(props.value);
const editing = ref(false);
const input = ref<HTMLInputElement | undefined>(undefined);
watchEffect(async () => {
if (editing.value) {
await nextTick();
input.value?.focus();
}
});
return {
editing,
myValue,
input,
};
},
methods: {
submit() {
if (this.myValue !== this.value) {
this.$emit("edit", this.myValue);
}
this.editing = false;
},
},
});
</script>
<style scoped lang="scss">
.text-input {
display: flex;
align-items: center;
.content {
min-width: 0;
flex-grow: 1;
}
}
.edit {
display: flex;
sl-input {
flex-grow: 1;
}
}
</style>

View File

@ -1,47 +0,0 @@
<template>
<router-link :to="routerTo">
<slot />
</router-link>
</template>
<script lang="ts">
import { Address, VALUE_TYPE } from "@/types/base";
import { defineComponent, PropType } from "vue";
import { RouteLocationRaw } from "vue-router";
interface IPointer {
entity?: Address;
attribute?: string;
value?: { t: VALUE_TYPE; c: string };
}
export default defineComponent({
name: "UpLink",
props: {
to: {
type: Object as PropType<IPointer>,
required: true,
},
},
computed: {
routerTo(): RouteLocationRaw {
if (this.$route.name == "browse") {
if (this.to.entity) {
return {
name: "browse",
params: {
addresses: [...this.$route.params.addresses].concat([
this.to.entity,
]),
},
};
}
}
return {};
},
},
});
</script>
<style scoped lang="scss">
</style>

View File

@ -1,288 +0,0 @@
<template>
<div class="compass-widget">
<div class="compass-row-helper">
<div class="compass-label compass-label-left">{{ leftLabel }}</div>
<div class="compass-container">
<div class="compass-label compass-label-top">{{ topLabel }}</div>
<div
class="compass"
ref="compass"
@mousedown="onMouse"
@mousemove="onMouse"
>
<div class="compass-x-axis"></div>
<div class="compass-y-axis"></div>
<div
class="compass-marker compass-select-marker"
:style="{
bottom: markerBottom,
left: markerLeft,
}"
></div>
<div class="context-markers"></div>
</div>
<div class="compass-label compass-label-bottom">{{ bottomLabel }}</div>
</div>
<div class="compass-label compass-label-right">{{ rightLabel }}</div>
</div>
<div class="compass-fields">
<div class="compass-field">
<label for="compass_field_x_{{ widget.name }}">
{{ xLabel }}
</label>
<input
type="number"
v-model.number="xVal"
:min="-xRange"
:max="xRange"
/>
</div>
<sl-icon-button name="save" :disabled="!dirty" @click="submit" />
<div class="compass-field">
<label for="compass_field_y_{{ widget.name }}">
{{ yLabel }}
</label>
<input
type="number"
v-model.number="yVal"
:min="-yRange"
:max="yRange"
/>
</div>
</div>
</div>
</template>
<script lang="ts">
import { asDict } from "@/lib/entity";
import { AttributeChange, IEntry } from "@/types/base";
import { defineComponent, PropType, ref, watch } from "vue";
export default defineComponent({
name: "Compass",
emits: ["edit"],
props: {
attributes: {
type: Array as PropType<[string, IEntry][]>,
required: true,
},
xAttrName: {
type: String,
required: true,
},
yAttrName: {
type: String,
required: true,
},
xLabel: {
type: String,
default: "X",
},
yLabel: {
type: String,
default: "Y",
},
xRange: {
type: Number,
default: 100,
},
yRange: {
type: Number,
default: 100,
},
},
computed: {
topLabel(): string | undefined {
return this.yLabel.includes("//")
? this.yLabel.split("//")[1]
: undefined;
},
bottomLabel(): string | undefined {
return this.yLabel.includes("//")
? this.yLabel.split("//")[0]
: undefined;
},
leftLabel(): string | undefined {
return this.xLabel.includes("//")
? this.xLabel.split("//")[0]
: undefined;
},
rightLabel(): string | undefined {
return this.xLabel.includes("//")
? this.xLabel.split("//")[1]
: undefined;
},
markerBottom(): string {
return `${((this.yVal + this.yRange) / (this.yRange * 2)) * 100}%`;
},
markerLeft(): string {
return `${((this.xVal + this.xRange) / (this.xRange * 2)) * 100}%`;
},
dirty(): boolean {
return this.origXVal !== this.xVal || this.origYVal !== this.yVal;
},
},
setup(props) {
const attrs = asDict(props.attributes);
const origXVal = ref(parseInt(attrs[props.xAttrName]) || -1);
const origYVal = ref(parseInt(attrs[props.yAttrName]) || -1);
const xVal = ref(origXVal.value);
const yVal = ref(origYVal.value);
watch(
() => props.attributes,
() => {
const attrs = asDict(props.attributes);
origXVal.value = parseInt(attrs[props.xAttrName]) || -1;
origYVal.value = parseInt(attrs[props.yAttrName]) || -1;
}
);
return {
xVal,
yVal,
origXVal,
origYVal,
};
},
methods: {
submit() {
const xValAddr = this.attributes.find(
([_, attr]) => attr.attribute === this.xAttrName
)?.[0];
if (xValAddr) {
this.$emit("edit", {
type: "delete",
addr: xValAddr,
} as AttributeChange);
// this.$emit("edit", {
// type: "update",
// addr: xValAddr,
// value: this.xVal,
// } as AttributeChange);
}
this.$emit("edit", {
type: "create",
attribute: this.xAttrName,
value: this.xVal,
} as AttributeChange);
const yValAddr = this.attributes.find(
([_, attr]) => attr.attribute === this.yAttrName
)?.[0];
if (yValAddr) {
this.$emit("edit", {
type: "delete",
addr: yValAddr,
} as AttributeChange);
// this.$emit("edit", {
// type: "update",
// addr: yValAddr,
// value: this.yVal,
// } as AttributeChange);
}
this.$emit("edit", {
type: "create",
attribute: this.yAttrName,
value: this.yVal,
} as AttributeChange);
},
onMouse(ev: MouseEvent) {
if (ev.buttons > 0) {
const bbox = (
this.$refs.compass as HTMLDivElement
).getBoundingClientRect();
this.xVal = Math.round(
((ev.clientX - bbox.x - bbox.width / 2) / bbox.width) *
this.xRange *
2
);
this.yVal = Math.round(
((ev.clientY - bbox.y - bbox.height / 2) / bbox.height) *
this.yRange *
-2
);
}
},
},
});
</script>
<style lang="scss" scoped>
.compass-container {
width: 100%;
}
.compass {
position: relative;
width: 100%;
padding-top: 100%; /* 1:1 Aspect Ratio */
}
.compass-row-helper {
display: flex;
align-items: center;
}
.compass-label {
margin: 0.5em;
text-align: center;
}
.compass-marker {
position: absolute;
width: 1.5%;
height: 1.5%;
background: red;
}
.context-markers div {
opacity: 25%;
}
.compass-x-axis {
position: absolute;
top: 50%;
left: 0;
height: 0;
width: 100%;
border: 1px dashed gray;
}
.compass-y-axis {
position: absolute;
top: 0;
left: 50%;
width: 0;
height: 100%;
border: 1px dashed gray;
}
.compass-fields {
display: flex;
justify-content: center;
margin: 1rem 0;
.compass-field {
margin: 0 1em;
text-align: center;
label {
display: inline-block;
margin: 0 0.5em;
}
input {
width: 4em;
font-family: monospace;
text-align: left;
}
}
}
</style>

View File

@ -1,300 +0,0 @@
<template>
<div class="table">
<table :class="{ reverse }">
<colgroup>
<col v-if="editable" class="attr-action-col" />
<col class="attr-col" />
<col />
</colgroup>
<template v-if="!reverse">
<tr>
<th v-if="editable"></th>
<th>Attribute</th>
<th>Value</th>
</tr>
<tr v-for="[id, entry] in limitedAttributes" :key="id">
<td v-if="editable" class="attr-action">
<sl-icon-button
v-if="editable"
name="x-circle"
@click="removeEntry(id)"
/>
</td>
<td>
<Marquee
:class="{
formatted: Boolean(formatAttribute(entry.attribute)),
}"
>
{{ formatAttribute(entry.attribute) || entry.attribute }}
</Marquee>
</td>
<td class="value">
<text-input
:editable="editable"
:value="entry.value.c"
@edit="(val) => updateEntry(id, entry.attribute, val)"
>
<Address
link
v-if="entry.value.t === 'Address'"
:address="entry.value.c"
:resolve="Boolean(resolve[id])"
:data-id="id"
:ref="
(el) => {
if (el) addressEls.push(el);
}
"
/>
<Marquee
v-else
:class="{
formatted: Boolean(
formatValue(entry.value.c, entry.attribute)
),
}"
>
{{
formatValue(entry.value.c, entry.attribute) || entry.value.c
}}
</Marquee>
</text-input>
</td>
</tr>
<tr v-if="attributes.length > currentDisplay">
<td :colspan="editable ? 3 : 2">
<sl-button
class="more-button"
@click="currentDisplay += MAX_DISPLAY"
>
+ {{ attributes.length - currentDisplay }} more...
</sl-button>
</td>
</tr>
<tr v-if="editable">
<td class="attr-action">
<sl-icon-button name="plus-circle" @click="addEntry()" />
</td>
<td>
<sl-input v-sl-model:newEntryAttribute size="small" />
</td>
<td>
<sl-input v-sl-model:newEntryValue size="small" />
</td>
</tr>
</template>
<template v-else>
<tr>
<th>Entities</th>
<th>Attribute name</th>
</tr>
<tr v-for="[id, entry] in attributes" :key="id">
<td>
<Address link :address="entry.entity" />
</td>
<td>
<Marquee>
{{ entry.attribute }}
</Marquee>
</td>
</tr>
</template>
</table>
</div>
</template>
<script lang="ts">
import Address from "@/components/Address.vue";
import Marquee from "@/components/Marquee.vue";
import TextInput from "@/components/TextInput.vue";
import { AttributeChange, IEntry } from "@/types/base";
import {
computed,
defineComponent,
onMounted,
PropType,
reactive,
ref,
watchEffect,
} from "vue";
import filesize from "filesize";
import { format, fromUnixTime } from "date-fns";
export default defineComponent({
name: "Table",
components: {
Address,
Marquee,
TextInput,
},
emits: ["edit"],
props: {
attributes: {
type: Array as PropType<[string, IEntry][]>,
required: true,
},
editable: {
type: Boolean,
default: false,
},
reverse: {
type: Boolean,
default: false,
},
},
data() {
return {
newEntryAttribute: "",
newEntryValue: "",
};
},
setup(props) {
const editable = computed(() => {
return props.editable && !props.reverse;
});
// Enable IntersectionObserver for performance reasons
const addressEls = ref<InstanceType<typeof Address>[]>([]);
const resolve = reactive<{ [key: string]: boolean }>({});
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
const id = (entry.target as HTMLElement).dataset["id"];
if (id) resolve[id] = entry.isIntersecting;
});
});
onMounted(() => {
watchEffect(() => {
addressEls.value.forEach((el: InstanceType<typeof Address>) => {
observer.observe(el.$el);
});
});
});
// "Pagination"
const MAX_DISPLAY = 50;
const currentDisplay = ref(MAX_DISPLAY);
const limitedAttributes = computed(() => {
return props.attributes.slice(0, currentDisplay.value);
});
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);
}
}
return {
editable,
addressEls,
resolve,
limitedAttributes,
currentDisplay,
MAX_DISPLAY,
formatAttribute,
formatValue,
};
},
methods: {
async addEntry() {
this.$emit("edit", {
type: "create",
attribute: this.newEntryAttribute,
value: this.newEntryValue,
} as AttributeChange);
this.newEntryAttribute = "";
this.newEntryValue = "";
},
async removeEntry(addr: string) {
if (confirm("Are you sure you want to remove the attribute?")) {
this.$emit("edit", { type: "delete", addr } as AttributeChange);
}
},
async 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);
},
},
});
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 }),
};
</script>
<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>

1
ui/src/global.d.ts vendored Normal file
View File

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

View File

@ -1,45 +1,10 @@
import { setBasePath, SlInput } from "@shoelace-style/shoelace";
import * as Vue from "vue";
import { DirectiveBinding } from "vue";
import App from "./App.vue";
import router from "./router";
import App from './App.svelte';
// TODO: Remove when UI settles!
setBasePath(`${window.location.origin}/`);
const app = Vue.createApp(App);
app.use(router);
app.directive("sl-model", {
beforeMount: (element: Element, binding: DirectiveBinding<string>) => {
element.addEventListener("sl-input", (event) => {
const slElement = event?.target as
| typeof SlInput.prototype
| undefined;
const value = slElement?.value;
if (value && binding.instance) {
if (Object.hasOwnProperty.bind(binding.instance)(binding.arg!)) {
(binding.instance as any)[binding.arg!] = value;
} else {
const data = (binding.instance.$data as { [key: string]: unknown });
if (data.hasOwnProperty(binding.arg!)) {
data[binding.arg!] = value;
} else {
console.error(`No property "${binding.arg}" exists on instance.`)
}
}
}
});
},
mounted: (element: SlInput, binding: DirectiveBinding<string>) => {
element.value = (binding.instance as any)[binding.arg!];
},
updated: (element: Element, binding: DirectiveBinding<string>) => {
const slElement = element as typeof SlInput.prototype | undefined;
if (slElement) {
slElement.value = (binding.instance?.$data as { [key: string]: unknown })[binding.arg as string] as string;
}
},
const app = new App({
target: document.body,
props: {
name: 'world'
}
});
app.mount("#app");
export default app;

View File

@ -1,52 +0,0 @@
import Inspect from "@/views/Inspect.vue";
import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router';
import Home from "../views/Home.vue";
import Search from '../views/Search.vue';
import File from '../views/File.vue';
import Browse from '../views/Browse.vue';
const routes: Array<RouteRecordRaw> = [
{
path: '/',
name: 'home',
component: Home
},
{
path: '/search',
name: 'search',
component: Search,
},
{
path: "/inspect/:address",
name: "inspect",
component: Inspect,
props: true
},
{
path: "/file/:address",
name: "file",
component: File,
props: true
},
{
path: "/browse/:addresses+",
name: "browse",
component: Browse,
props: true
},
{
path: '/about',
name: 'about',
// route level code-splitting
// this generates a separate chunk (about.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import(/* webpackChunkName: "about" */ '../views/Home.vue')
}
]
const router = createRouter({
history: createWebHashHistory(),
routes
})
export default router

View File

@ -1,5 +0,0 @@
declare module '*.vue' {
import { defineComponent } from 'vue'
const component: ReturnType<typeof defineComponent>
export default component
}

View File

@ -1,51 +0,0 @@
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 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;
}
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

@ -1,17 +0,0 @@
export async function fetcher(key: string) {
const response = await fetch(key);
if (response.ok) {
return await response.json();
} else {
throw await response.text();
}
}
export function construct(query: string, args: string[]) {
let result = query;
args.forEach((arg) => {
arg = arg.replaceAll("\"", "\\\"");
result = result.replace("#", `"${arg}"`);
});
return result;
}

View File

@ -1,131 +0,0 @@
<template>
<div class="browser" ref="root">
<div
class="view"
v-for="(address, idx) in addresses"
:key="address"
: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"
@click="visit(idx)"
:disabled="addresses.length === 1"
/>
<sl-icon-button
class="close-button"
name="x-circle"
@click="close(idx)"
:disabled="addresses.length === 1"
/>
</header>
<Inspect :address="address" :editable="editable[idx] || false" />
</div>
</div>
</template>
<script lang="ts">
import router from "@/router";
import Inspect from "@/views/Inspect.vue";
import { defineComponent, nextTick, PropType, reactive, ref, watch } from "vue";
import { useRoute } from "vue-router";
export default defineComponent({
name: "Browse",
components: {
Inspect,
},
props: {
addresses: {
type: Array as PropType<string[]>,
required: true,
},
},
setup(props) {
const route = useRoute();
const root = ref<HTMLDivElement>();
const editable = reactive<boolean[]>([]);
function visit(idx: number) {
router.push({
params: { addresses: [route.params.addresses[idx]] },
});
}
function close(idx: number) {
const addresses = [...route.params.addresses];
addresses.splice(idx, 1);
router.push({
params: { addresses },
});
}
watch(
() => props.addresses,
(addresses: string[], prevAddresses: string[]) => {
if (addresses.length > prevAddresses.length) {
nextTick().then(() => {
root.value?.scrollTo({
left: root.value.scrollWidth,
behavior: "smooth",
});
});
}
}
);
return {
root,
visit,
close,
editable,
};
},
});
</script>
<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>

View File

@ -1,150 +0,0 @@
<template>
<div class="inspect">
<h2>
<Address
v-if="!fileNames"
:address="address"
:resolve="false"
/>
<template v-else>
<hash-badge :address="address" class="hash-badge" />
<a :href="`/api/raw/${address}`">
{{ fileNames.join(", ") }}
</a>
</template>
</h2>
<div v-if="!error">
<div class="preview" v-if="mimeType">
<template v-if="mimeType.startsWith('audio')">
<audio controls :src="`/api/raw/${address}`" />
</template>
<template v-if="mimeType.startsWith('image')">
<a :href="`/api/raw/${address}`">
<img :src="`/api/raw/${address}`" />
</a>
</template>
</div>
<template v-if="attributes.length">
<h3>Own attributes ({{ attributes.length }})</h3>
<table>
<tr>
<th>Attribute name</th>
<th>Value</th>
</tr>
<tr v-for="[id, entry] in attributes" :key="id">
<td>{{ entry.attribute }}</td>
<td>
<Address
link
v-if="entry.value.t === 'Address'"
:address="entry.value.c"
/>
<template v-else>
{{ entry.value.c }}
</template>
</td>
</tr>
</table>
</template>
<template v-if="backlinks.length">
<h3>Referred to ({{ backlinks.length }})</h3>
<table>
<tr>
<th>Entities</th>
<th>Attribute names</th>
</tr>
<tr v-for="[id, entry] in backlinks" :key="id">
<td>
<Address link :address="entry.entity" />
</td>
<td>
{{ entry.attribute }}
</td>
</tr>
</table>
</template>
</div>
<div v-else class="error">
{{ error }}
</div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent } from "vue";
import Address from "@/components/Address.vue";
import { useEntity } from "@/lib/entity";
import HashBadge from "@/components/HashBadge.vue";
export default defineComponent({
name: "File",
components: {
Address,
HashBadge,
},
props: {
address: {
type: String,
required: true,
},
},
data: () => {
return {
newEntryAttribute: "",
newEntryValue: "",
};
},
computed: {
mimeType(): string | undefined {
return this.attributes.find(([_, e]) => e.attribute === "FILE_MIME")?.[1]
.value.c;
},
},
setup(props) {
const { attributes, backlinks, error } = useEntity(props.address);
const fileNames = computed(() => {
const extantAddresses = backlinks.value
.filter(([_, e]) => e.attribute == "FILE_IS")
.map(([_, e]) => e.entity);
const result = new Set();
extantAddresses.forEach((address) => {
const { attributes } = useEntity(address);
result.add(
attributes.value.find(([_, e]) => e.attribute == "FILE_NAME")?.[1]
.value.c
);
});
return Array.from(result);
});
return {
attributes,
backlinks,
fileNames,
error,
};
},
});
</script>
<style scoped lang="scss">
h2 a {
text-decoration: none;
}
.preview {
img {
max-height: 256px;
}
}
.hash-badge {
margin-right: 0.5em;
}
.error {
color: red;
}
</style>

View File

@ -1,89 +0,0 @@
<template>
<div class="home">
<h1>
Welcome to
<em v-if="infoData?.name"> "{{ infoData.name }}" </em>
<template v-else> UpEnd </template>
</h1>
<section class="latest" v-if="latestFiles">
<h2>Most recently added files</h2>
<ul>
<li v-for="file in latestFiles" :key="file.hash">
<div class="file-added">{{ file.added }}</div>
<router-link
:to="{ name: 'browse', params: { addresses: [file.hash] } }"
>
<div class="file-path">{{ file.path }}</div>
</router-link>
</li>
</ul>
</section>
</div>
</template>
<script lang="ts">
import { IFile, VaultInfo } from "@/types/base";
import useSWRV from "swrv";
import { computed, defineComponent } from "vue";
import { fetcher } from "../utils";
import { formatRelative, parseISO } from "date-fns";
export default defineComponent({
name: "Home",
setup() {
const { data: infoData } = useSWRV<VaultInfo, unknown>(
"/api/info",
fetcher
);
const { data: latestFilesRaw } = useSWRV<IFile[], unknown>(
"/api/files/latest",
fetcher
);
const latestFiles = computed(() => {
if (latestFilesRaw?.value) {
return latestFilesRaw.value.map((file) => {
return {
...file,
added: formatRelative(parseISO(file.added), new Date()),
mtime: parseISO(file.added),
};
});
}
});
return {
infoData,
latestFiles,
};
},
});
</script>
<style lang="scss">
h1 {
text-align: center;
font-weight: normal;
}
.latest {
ul {
list-style: none;
padding: 0;
}
li {
display: flex;
& > * {
margin: .1em .25em;
}
}
.file-added {
opacity: .77;
}
}
</style>

View File

@ -1,195 +0,0 @@
<template>
<div class="inspect">
<h2>
<Address
:address="address"
:is-file="backlinks.some(([_, e]) => e.attribute === 'FILE_IS')"
/>
</h2>
<blob-preview :address="address" />
<div v-if="!error">
<AttributeView
v-for="(attributes, typeAddr) in typedAttributes"
:editable="editable"
:key="typeAddr"
:address="address"
:type="types[typeAddr]"
:attributes="attributes"
@edit="mutate()"
/>
<AttributeView
title="Other attributes"
v-if="untypedAttributes.length > 0 || editable"
editable
:address="address"
:attributes="untypedAttributes"
@change="mutate()"
/>
<AttributeView
v-if="backlinks.length"
:title="`Referred to (${backlinks.length})`"
:address="address"
:attributes="backlinks"
reverse
/>
</div>
<div v-else class="error">
{{ error }}
</div>
</div>
</template>
<script lang="ts">
import Address from "@/components/Address.vue";
import AttributeView from "@/components/AttributeView.vue";
import BlobPreview from "@/components/BlobPreview.vue";
import Marquee from "@/components/Marquee.vue";
import UpLink from "@/components/UpLink.vue";
import { query, useEntity } from "@/lib/entity";
import { UpType } from "@/lib/types";
import { IEntry } from "@/types/base";
import { computed, defineComponent, reactive, watch } from "vue";
export default defineComponent({
name: "Inspect",
components: {
Address,
BlobPreview,
Marquee,
AttributeView,
UpLink,
},
props: {
address: {
type: String,
required: true,
},
editable: {
type: Boolean,
default: false,
},
},
setup(props) {
const { error, mutate, attributes, backlinks } = useEntity(
() => props.address
);
const allTypeAddresses = computed(() => {
return attributes.value
.map(([_, attr]) => attr)
.filter((attr) => attr.attribute == "IS")
.map((attr) => attr.value.c);
});
const { result: allTypeEntries } = query(
() =>
`(matches (in ${allTypeAddresses.value
.map((addr) => `"${addr}"`)
.join(" ")}) ? ?)`,
() => allTypeAddresses.value.length > 0
);
const allTypes = computed(() => {
const result = {} as { [key: string]: UpType };
allTypeEntries.value.forEach(([_, entry]) => {
if (result[entry.entity] === undefined) {
result[entry.entity] = new UpType(entry.entity);
}
switch (entry.attribute) {
case "TYPE":
result[entry.entity].name = entry.value.c;
break;
case "TYPE_HAS":
case "TYPE_REQUIRES":
case "TYPE_ID":
result[entry.entity].attributes.push(entry.value.c);
break;
}
});
return result;
});
const typedAttributes = reactive(
{} as { [key: string]: [string, IEntry][] }
);
const untypedAttributes = reactive([] as [string, IEntry][]);
watch([attributes, allTypes], () => {
Object.keys(typedAttributes).forEach(
(key) => delete typedAttributes[key]
);
untypedAttributes.length = 0;
attributes.value.forEach(([entryAddr, entry]) => {
const entryTypes = Object.entries(allTypes.value).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]);
}
});
});
const filteredUntypedAttributes = computed(() => {
return untypedAttributes.filter(
([_, entry]) =>
entry.attribute !== "IS" ||
!Object.keys(typedAttributes).includes(entry.value.c)
);
});
return {
typedAttributes,
untypedAttributes: filteredUntypedAttributes,
backlinks,
error,
mutate,
types: allTypes,
};
},
});
</script>
<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

@ -1,68 +0,0 @@
<template>
<div class="search">
<ul class="search-results" v-if="results">
<li v-for="[identity, result] in Object.entries(results)" :key="identity">
<SearchResult class="result" :result="result" />
</li>
</ul>
</div>
</template>
<script lang="ts">
import SearchResult from "@/components/SearchResult.vue";
import { ListingResult } from "@/types/base";
import { construct } from "@/utils";
import useSWRV from "swrv";
import { defineComponent } from "vue";
import { useRoute } from "vue-router";
import { fetcher } from "../utils";
export default defineComponent({
name: "Search",
components: { SearchResult },
props: {
searchQuery: {
type: String,
required: true,
},
},
setup() {
const route = useRoute();
const { data } = useSWRV<ListingResult | unknown>(() => {
let query = route.query.q as string | undefined;
if (query && query !== "") {
const ueQuery = construct("(matches ? ? (contains #))", [query]);
return `/api/obj?query=${ueQuery}`;
}
return null;
}, fetcher);
return {
results: data,
};
},
});
</script>
<style lang="scss">
.search,
.search-results {
width: 100%;
}
.search-results {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0.5rem;
grid-auto-rows: minmax(100px, auto);
list-style: none;
li {
display: block;
width: calc(100vw / 7);
height: calc(100vh / 6);
margin: 1rem;
}
}
</style>

View File

@ -1,39 +1,6 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"strict": true,
"jsx": "preserve",
"importHelpers": true,
"moduleResolution": "node",
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"sourceMap": true,
"baseUrl": ".",
"types": [
"webpack-env"
],
"paths": {
"@/*": [
"src/*"
]
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue",
"tests/**/*.ts",
"tests/**/*.tsx"
],
"exclude": [
"node_modules"
]
}
"extends": "@tsconfig/svelte/tsconfig.json",
"include": ["src/**/*"],
"exclude": ["node_modules/*", "__sapper__/*", "public/*"]
}

View File

@ -1,32 +0,0 @@
const path = require("path");
const CopyPlugin = require("copy-webpack-plugin");
module.exports = {
lintOnSave: false,
chainWebpack: config => {
config.module
.rule('vue')
.use('vue-loader')
.tap(options => {
const compilerOptions = {
}
compilerOptions.isCustomElement = (tag) => tag.startsWith('sl-');
options.compilerOptions = compilerOptions;
return options
});
config.plugin("copy-icons").use(CopyPlugin, [
[
{
from: path.resolve(
__dirname,
"node_modules/@shoelace-style/shoelace/dist/assets/icons"
),
to: path.resolve(__dirname, "dist/assets/icons")
}
]
]);
config.devServer.proxy({
"/api": { target: "http://localhost:8093" }
});
}
};

13528
ui/yarn.lock

File diff suppressed because it is too large Load Diff