[ui] skeleton of the Svelte ui
parent
a5dace9e72
commit
929c89ef9a
|
@ -1,3 +0,0 @@
|
|||
> 1%
|
||||
last 2 versions
|
||||
not dead
|
|
@ -1,5 +0,0 @@
|
|||
[*.{js,jsx,ts,tsx,vue}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
|
@ -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
|
|
@ -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
|
@ -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
|
109
ui/README.md
109
ui/README.md
|
@ -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
|
||||
```
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
module.exports = {
|
||||
presets: [
|
||||
'@vue/cli-plugin-babel/preset'
|
||||
]
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 3.1 KiB |
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
|
@ -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>
|
119
ui/src/App.vue
119
ui/src/App.vue
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="svelte" />
|
|
@ -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;
|
|
@ -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
|
|
@ -1,5 +0,0 @@
|
|||
declare module '*.vue' {
|
||||
import { defineComponent } from 'vue'
|
||||
const component: ReturnType<typeof defineComponent>
|
||||
export default component
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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/*"]
|
||||
}
|
|
@ -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
13528
ui/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue