Compare commits
10 Commits
main
...
refactor/s
Author | SHA1 | Date |
---|---|---|
Tomáš Mládek | 5e7ff87960 | |
Tomáš Mládek | e5889dc883 | |
Tomáš Mládek | 728de4d363 | |
Tomáš Mládek | af38f22085 | |
Tomáš Mládek | 0937a67877 | |
Tomáš Mládek | 801c0d1440 | |
Tomáš Mládek | 1e9f2fd7de | |
Tomáš Mládek | a84f8daa55 | |
Tomáš Mládek | 9ae18310da | |
Tomáš Mládek | efb2cee7b2 |
|
@ -1,7 +1,8 @@
|
||||||
<component name="ProjectRunConfigurationManager">
|
<component name="ProjectRunConfigurationManager">
|
||||||
<configuration default="false" name="dev backend" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
|
<configuration default="false" name="dev backend" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
|
||||||
<option name="command" value="run --release -- serve ./example_vault --clean --no-browser --reinitialize" />
|
<option name="command" value="run -- serve ./example_vault --clean --no-browser --reinitialize" />
|
||||||
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
|
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
|
||||||
|
<envs />
|
||||||
<option name="emulateTerminal" value="true" />
|
<option name="emulateTerminal" value="true" />
|
||||||
<option name="channel" value="DEFAULT" />
|
<option name="channel" value="DEFAULT" />
|
||||||
<option name="requiredFeatures" value="true" />
|
<option name="requiredFeatures" value="true" />
|
||||||
|
@ -9,7 +10,6 @@
|
||||||
<option name="withSudo" value="false" />
|
<option name="withSudo" value="false" />
|
||||||
<option name="buildTarget" value="REMOTE" />
|
<option name="buildTarget" value="REMOTE" />
|
||||||
<option name="backtrace" value="SHORT" />
|
<option name="backtrace" value="SHORT" />
|
||||||
<envs />
|
|
||||||
<option name="isRedirectInput" value="false" />
|
<option name="isRedirectInput" value="false" />
|
||||||
<option name="redirectInputPath" value="" />
|
<option name="redirectInputPath" value="" />
|
||||||
<method v="2">
|
<method v="2">
|
||||||
|
|
|
@ -283,6 +283,56 @@ dependencies = [
|
||||||
"syn 1.0.109",
|
"syn 1.0.109",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "actix-web-lab"
|
||||||
|
version = "0.20.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7675c1a84eec1b179c844cdea8488e3e409d8e4984026e92fa96c87dd86f33c6"
|
||||||
|
dependencies = [
|
||||||
|
"actix-files",
|
||||||
|
"actix-http",
|
||||||
|
"actix-router",
|
||||||
|
"actix-service",
|
||||||
|
"actix-utils",
|
||||||
|
"actix-web",
|
||||||
|
"actix-web-lab-derive",
|
||||||
|
"ahash 0.8.3",
|
||||||
|
"arc-swap",
|
||||||
|
"async-trait",
|
||||||
|
"bytes",
|
||||||
|
"bytestring",
|
||||||
|
"csv",
|
||||||
|
"derive_more",
|
||||||
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"impl-more",
|
||||||
|
"itertools",
|
||||||
|
"local-channel",
|
||||||
|
"mediatype",
|
||||||
|
"mime",
|
||||||
|
"once_cell",
|
||||||
|
"pin-project-lite",
|
||||||
|
"regex",
|
||||||
|
"serde",
|
||||||
|
"serde_html_form",
|
||||||
|
"serde_json",
|
||||||
|
"tokio",
|
||||||
|
"tokio-stream",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "actix-web-lab-derive"
|
||||||
|
version = "0.20.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9aa0b287c8de4a76b691f29dbb5451e8dd5b79d777eaf87350c9b0cbfdb5e968"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.29",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "actix_derive"
|
name = "actix_derive"
|
||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
|
@ -431,6 +481,12 @@ version = "1.0.75"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6"
|
checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arc-swap"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bddcadddf5e9015d310179a59bb28c4d4b9920ad0f11e8e14dbadf654890c9a6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arrayref"
|
name = "arrayref"
|
||||||
version = "0.3.7"
|
version = "0.3.7"
|
||||||
|
@ -449,6 +505,17 @@ version = "0.10.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341"
|
checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-trait"
|
||||||
|
version = "0.1.76"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "531b97fb4cd3dfdce92c35dedbfdc1f0b9d8091c8ca943d6dae340ef5012d514"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.29",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
|
@ -752,6 +819,12 @@ version = "0.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f7144d30dcf0fafbce74250a3963025d8d52177934239851c917d29f1df280c2"
|
checksum = "f7144d30dcf0fafbce74250a3963025d8d52177934239851c917d29f1df280c2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "constcat"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7d5cd0c57ef83705837b1cb872c973eff82b070846d3e23668322b2c0f8246d0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "convert_case"
|
name = "convert_case"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
|
@ -889,6 +962,27 @@ dependencies = [
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "csv"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac574ff4d437a7b5ad237ef331c17ccca63c46479e5b5453eb8e10bb99a759fe"
|
||||||
|
dependencies = [
|
||||||
|
"csv-core",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "csv-core"
|
||||||
|
version = "0.1.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5efa2b3d7902f4b634a20cae3c9c4e6209dc4779feb6863329607560143efa70"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling"
|
name = "darling"
|
||||||
version = "0.14.4"
|
version = "0.14.4"
|
||||||
|
@ -1491,6 +1585,12 @@ dependencies = [
|
||||||
"num-traits",
|
"num-traits",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "impl-more"
|
||||||
|
version = "0.1.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "206ca75c9c03ba3d4ace2460e57b189f39f43de612c2f85836e65c929701bb2d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "1.9.3"
|
version = "1.9.3"
|
||||||
|
@ -1532,6 +1632,15 @@ dependencies = [
|
||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itertools"
|
||||||
|
version = "0.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "25db6b064527c5d482d0423354fcd07a89a2dfe07b67892e62411946db7f07b0"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.9"
|
version = "1.0.9"
|
||||||
|
@ -1757,6 +1866,12 @@ dependencies = [
|
||||||
"regex-automata 0.1.10",
|
"regex-automata 0.1.10",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mediatype"
|
||||||
|
version = "0.19.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "83a018c36a54f4e12c30464bbc59311f85d3f6f4d6c1b4fa4ea9db2b174ddefc"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.6.0"
|
version = "2.6.0"
|
||||||
|
@ -2598,6 +2713,19 @@ dependencies = [
|
||||||
"syn 2.0.29",
|
"syn 2.0.29",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_html_form"
|
||||||
|
version = "0.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "224e6a14f315852940f3ec103125aa6482f0e224732ed91ed3330ed633077c34"
|
||||||
|
dependencies = [
|
||||||
|
"form_urlencoded",
|
||||||
|
"indexmap 2.0.0",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_json"
|
name = "serde_json"
|
||||||
version = "1.0.105"
|
version = "1.0.105"
|
||||||
|
@ -2962,9 +3090,21 @@ dependencies = [
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"signal-hook-registry",
|
"signal-hook-registry",
|
||||||
"socket2 0.5.3",
|
"socket2 0.5.3",
|
||||||
|
"tokio-macros",
|
||||||
"windows-sys",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-macros"
|
||||||
|
version = "2.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.29",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-native-tls"
|
name = "tokio-native-tls"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
|
@ -2975,6 +3115,17 @@ dependencies = [
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-stream"
|
||||||
|
version = "0.1.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "397c988d37662c7dda6d2208364a706264bf3d6138b11d436cbac0ad38832842"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.8"
|
version = "0.7.8"
|
||||||
|
@ -3178,11 +3329,13 @@ dependencies = [
|
||||||
"actix-multipart",
|
"actix-multipart",
|
||||||
"actix-rt",
|
"actix-rt",
|
||||||
"actix-web",
|
"actix-web",
|
||||||
|
"actix-web-lab",
|
||||||
"actix_derive",
|
"actix_derive",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
|
"constcat",
|
||||||
"diesel",
|
"diesel",
|
||||||
"diesel_migrations",
|
"diesel_migrations",
|
||||||
"filebuffer",
|
"filebuffer",
|
||||||
|
|
|
@ -89,6 +89,8 @@ url = "2"
|
||||||
|
|
||||||
bytes = "1.4.0"
|
bytes = "1.4.0"
|
||||||
signal-hook = "0.3.15"
|
signal-hook = "0.3.15"
|
||||||
|
actix-web-lab = { version = "0.20.2", features = ["spa"] }
|
||||||
|
constcat = "0.4.1"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
shadow-rs = { version = "0.23", default-features = false }
|
shadow-rs = { version = "0.23", default-features = false }
|
||||||
|
|
|
@ -1,29 +1,17 @@
|
||||||
use std::{env::current_exe, path::PathBuf};
|
use constcat::concat;
|
||||||
|
|
||||||
use anyhow::{anyhow, Result};
|
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use shadow_rs::{is_debug, shadow};
|
use shadow_rs::shadow;
|
||||||
|
|
||||||
shadow!(build);
|
shadow!(build);
|
||||||
|
|
||||||
pub fn get_resource_path<S: AsRef<str>>(dir: S) -> Result<std::path::PathBuf> {
|
#[cfg(not(debug_assertions))]
|
||||||
let base_path = if is_debug() {
|
pub const RESOURCE_PATH: &str = "../share/upend";
|
||||||
let cwd = build::CARGO_MANIFEST_DIR.parse::<PathBuf>()?;
|
|
||||||
cwd.join("./tmp/resources")
|
|
||||||
} else {
|
|
||||||
current_exe()?
|
|
||||||
.parent()
|
|
||||||
.ok_or(anyhow!("couldn't locate resource path, binary in root"))?
|
|
||||||
.join("../share/upend")
|
|
||||||
};
|
|
||||||
|
|
||||||
let result = base_path.join(dir.as_ref());
|
#[cfg(debug_assertions)]
|
||||||
if result.exists() {
|
pub const RESOURCE_PATH: &str = "./tmp/resources";
|
||||||
Ok(result)
|
|
||||||
} else {
|
pub const WEBUI_PATH: &str = concat!(RESOURCE_PATH, "/webui");
|
||||||
Err(anyhow!("Path {result:?} doesn't exist."))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
static ref APP_USER_AGENT: String = format!("upend / {}", build::PKG_VERSION);
|
static ref APP_USER_AGENT: String = format!("upend / {}", build::PKG_VERSION);
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
#[macro_use]
|
#[macro_use]
|
||||||
extern crate upend_db;
|
extern crate upend_db;
|
||||||
|
|
||||||
use crate::common::{get_resource_path, REQWEST_ASYNC_CLIENT};
|
use crate::common::{REQWEST_ASYNC_CLIENT, WEBUI_PATH};
|
||||||
use crate::config::UpEndConfig;
|
use crate::config::UpEndConfig;
|
||||||
use actix_web::HttpServer;
|
use actix_web::HttpServer;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
@ -342,20 +342,20 @@ async fn main() -> Result<()> {
|
||||||
FsStore::from_path(args.store_path.unwrap_or_else(|| vault_path.clone())).unwrap(),
|
FsStore::from_path(args.store_path.unwrap_or_else(|| vault_path.clone())).unwrap(),
|
||||||
) as Box<dyn UpStore + Send + Sync>);
|
) as Box<dyn UpStore + Send + Sync>);
|
||||||
|
|
||||||
let ui_path = if args.no_ui {
|
let webui_enabled = if args.no_ui {
|
||||||
None
|
false
|
||||||
} else {
|
} else {
|
||||||
let ui_path = get_resource_path("webui");
|
let exists = Path::new(WEBUI_PATH).exists();
|
||||||
if ui_path.is_err() {
|
if !exists {
|
||||||
warn!(
|
warn!(
|
||||||
"Couldn't locate Web UI directory ({:?}), disabling...",
|
"Couldn't locate Web UI directory ({:?}), disabling...",
|
||||||
ui_path
|
WEBUI_PATH
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
ui_path.ok()
|
exists
|
||||||
};
|
};
|
||||||
|
|
||||||
let browser_enabled = !args.no_desktop && ui_path.is_some() && !args.no_browser;
|
let browser_enabled = !args.no_desktop && webui_enabled && !args.no_browser;
|
||||||
|
|
||||||
let preview_path = upend.path.join("previews");
|
let preview_path = upend.path.join("previews");
|
||||||
#[cfg(feature = "previews")]
|
#[cfg(feature = "previews")]
|
||||||
|
@ -425,11 +425,10 @@ async fn main() -> Result<()> {
|
||||||
let mut cnt = 0;
|
let mut cnt = 0;
|
||||||
let server = loop {
|
let server = loop {
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
let ui_path = ui_path.clone();
|
|
||||||
let allowed_origins = args.allow_host.clone();
|
let allowed_origins = args.allow_host.clone();
|
||||||
|
|
||||||
let server = HttpServer::new(move || {
|
let server = HttpServer::new(move || {
|
||||||
serve::get_app(ui_path.clone(), allowed_origins.clone(), state.clone())
|
serve::get_app(webui_enabled, allowed_origins.clone(), state.clone())
|
||||||
});
|
});
|
||||||
|
|
||||||
let bind_result = server.bind(&bind);
|
let bind_result = server.bind(&bind);
|
||||||
|
@ -475,7 +474,7 @@ async fn main() -> Result<()> {
|
||||||
|
|
||||||
#[cfg(feature = "desktop")]
|
#[cfg(feature = "desktop")]
|
||||||
{
|
{
|
||||||
if browser_enabled && ui_path.is_some() {
|
if browser_enabled {
|
||||||
let ui_result = webbrowser::open(&format!("http://localhost:{}", bind.port()));
|
let ui_result = webbrowser::open(&format!("http://localhost:{}", bind.port()));
|
||||||
if ui_result.is_err() {
|
if ui_result.is_err() {
|
||||||
warn!("Could not open UI in browser!");
|
warn!("Could not open UI in browser!");
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
use crate::routes;
|
use crate::routes;
|
||||||
use std::path::Path;
|
use actix_web_lab::web::spa;
|
||||||
|
|
||||||
pub fn get_app<P, S>(
|
pub fn get_app<S>(
|
||||||
ui_path: Option<P>,
|
ui_enabled: bool,
|
||||||
allowed_origins: S,
|
allowed_origins: S,
|
||||||
state: crate::routes::State,
|
state: crate::routes::State,
|
||||||
) -> actix_web::App<
|
) -> actix_web::App<
|
||||||
|
@ -15,7 +15,6 @@ pub fn get_app<P, S>(
|
||||||
>,
|
>,
|
||||||
>
|
>
|
||||||
where
|
where
|
||||||
P: AsRef<Path> + Clone,
|
|
||||||
S: IntoIterator<Item = String> + Clone,
|
S: IntoIterator<Item = String> + Clone,
|
||||||
{
|
{
|
||||||
let allowed_origins: Vec<String> = allowed_origins.into_iter().collect();
|
let allowed_origins: Vec<String> = allowed_origins.into_iter().collect();
|
||||||
|
@ -69,9 +68,13 @@ where
|
||||||
.service(routes::put_options)
|
.service(routes::put_options)
|
||||||
.service(routes::get_user_entries);
|
.service(routes::get_user_entries);
|
||||||
|
|
||||||
if let Some(ui_path) = ui_path {
|
if ui_enabled {
|
||||||
return app
|
return app.service(
|
||||||
.service(actix_files::Files::new("/", ui_path.as_ref()).index_file("index.html"));
|
spa()
|
||||||
|
.index_file(crate::common::WEBUI_PATH.to_owned() + "/index.html")
|
||||||
|
.static_resources_location(crate::common::WEBUI_PATH)
|
||||||
|
.finish(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[actix_web::get("/")]
|
#[actix_web::get("/")]
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
.DS_Store
|
||||||
|
node_modules
|
||||||
|
/build
|
||||||
|
/.svelte-kit
|
||||||
|
/package
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Ignore files for PNPM, NPM and YARN
|
||||||
|
pnpm-lock.yaml
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
|
@ -1,34 +1,48 @@
|
||||||
|
/** @type { import("eslint").Linter.Config } */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
env: {
|
root: true,
|
||||||
browser: true,
|
extends: [
|
||||||
es2021: true
|
'eslint:recommended',
|
||||||
},
|
'plugin:@typescript-eslint/recommended',
|
||||||
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:storybook/recommended"],
|
'plugin:svelte/recommended',
|
||||||
parser: "@typescript-eslint/parser",
|
'prettier'
|
||||||
parserOptions: {
|
],
|
||||||
ecmaVersion: "latest",
|
parser: '@typescript-eslint/parser',
|
||||||
sourceType: "module"
|
plugins: ['@typescript-eslint'],
|
||||||
},
|
parserOptions: {
|
||||||
plugins: ["svelte3", "@typescript-eslint"],
|
sourceType: 'module',
|
||||||
overrides: [{
|
ecmaVersion: 2020,
|
||||||
files: ["*.svelte"],
|
extraFileExtensions: ['.svelte']
|
||||||
processor: "svelte3/svelte3"
|
},
|
||||||
}],
|
env: {
|
||||||
rules: {
|
browser: true,
|
||||||
"@typescript-eslint/no-unused-vars": ["warn", {
|
es2017: true,
|
||||||
argsIgnorePattern: "^_",
|
node: true
|
||||||
varsIgnorePattern: "^_"
|
},
|
||||||
}],
|
overrides: [
|
||||||
"no-console": ["error", {
|
{
|
||||||
allow: ["debug", "warn", "error"]
|
files: ['*.svelte'],
|
||||||
}]
|
parser: 'svelte-eslint-parser',
|
||||||
},
|
parserOptions: {
|
||||||
settings: {
|
parser: '@typescript-eslint/parser'
|
||||||
"svelte3/typescript": true,
|
}
|
||||||
// load TypeScript as peer dependency
|
}
|
||||||
"svelte3/ignore-warnings": w => w.code == "unused-export-let"
|
],
|
||||||
},
|
rules: {
|
||||||
globals: {
|
'svelte/valid-compile': ['error', { ignoreWarnings: true }],
|
||||||
NodeJS: true
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
}
|
'@typescript-eslint/no-unused-vars': [
|
||||||
};
|
'warn',
|
||||||
|
{
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
varsIgnorePattern: '^_'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'no-console': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
allow: ['debug', 'warn', 'error']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
@ -1,39 +0,0 @@
|
||||||
public/assets/fonts/Inter-ExtraBoldItalic.woff filter=lfs diff=lfs merge=lfs -text
|
|
||||||
public/assets/fonts/Inter-ExtraLight.woff2 filter=lfs diff=lfs merge=lfs -text
|
|
||||||
public/assets/fonts/Inter-italic.var.woff2 filter=lfs diff=lfs merge=lfs -text
|
|
||||||
public/assets/fonts/Inter-Light.woff2 filter=lfs diff=lfs merge=lfs -text
|
|
||||||
public/assets/fonts/Inter-Bold.woff filter=lfs diff=lfs merge=lfs -text
|
|
||||||
public/assets/fonts/Inter-BoldItalic.woff2 filter=lfs diff=lfs merge=lfs -text
|
|
||||||
public/assets/fonts/Inter-Italic.woff2 filter=lfs diff=lfs merge=lfs -text
|
|
||||||
public/assets/fonts/Inter-Medium.woff2 filter=lfs diff=lfs merge=lfs -text
|
|
||||||
public/assets/fonts/Inter-MediumItalic.woff2 filter=lfs diff=lfs merge=lfs -text
|
|
||||||
public/assets/fonts/Inter-SemiBold.woff filter=lfs diff=lfs merge=lfs -text
|
|
||||||
public/assets/fonts/Inter-BlackItalic.woff2 filter=lfs diff=lfs merge=lfs -text
|
|
||||||
public/assets/fonts/Inter-ExtraBold.woff filter=lfs diff=lfs merge=lfs -text
|
|
||||||
public/assets/fonts/Inter-Regular.woff filter=lfs diff=lfs merge=lfs -text
|
|
||||||
public/assets/fonts/Inter-SemiBoldItalic.woff filter=lfs diff=lfs merge=lfs -text
|
|
||||||
public/assets/fonts/Inter-Thin.woff filter=lfs diff=lfs merge=lfs -text
|
|
||||||
public/assets/fonts/Inter-Black.woff filter=lfs diff=lfs merge=lfs -text
|
|
||||||
public/assets/fonts/Inter-Black.woff2 filter=lfs diff=lfs merge=lfs -text
|
|
||||||
public/assets/fonts/Inter-BlackItalic.woff filter=lfs diff=lfs merge=lfs -text
|
|
||||||
public/assets/fonts/Inter-ExtraBoldItalic.woff2 filter=lfs diff=lfs merge=lfs -text
|
|
||||||
public/assets/fonts/Inter-ExtraLightItalic.woff filter=lfs diff=lfs merge=lfs -text
|
|
||||||
public/assets/fonts/Inter-Light.woff filter=lfs diff=lfs merge=lfs -text
|
|
||||||
public/assets/fonts/Inter-Bold.woff2 filter=lfs diff=lfs merge=lfs -text
|
|
||||||
public/assets/fonts/Inter-ExtraBold.woff2 filter=lfs diff=lfs merge=lfs -text
|
|
||||||
public/assets/fonts/Inter-ExtraLightItalic.woff2 filter=lfs diff=lfs merge=lfs -text
|
|
||||||
public/assets/fonts/Inter-LightItalic.woff2 filter=lfs diff=lfs merge=lfs -text
|
|
||||||
public/assets/fonts/Inter-MediumItalic.woff filter=lfs diff=lfs merge=lfs -text
|
|
||||||
public/assets/fonts/Inter-ThinItalic.woff2 filter=lfs diff=lfs merge=lfs -text
|
|
||||||
public/assets/fonts/Inter.var.woff2 filter=lfs diff=lfs merge=lfs -text
|
|
||||||
public/assets/fonts/Inter-BoldItalic.woff filter=lfs diff=lfs merge=lfs -text
|
|
||||||
public/assets/fonts/Inter-ExtraLight.woff filter=lfs diff=lfs merge=lfs -text
|
|
||||||
public/assets/fonts/Inter-Medium.woff filter=lfs diff=lfs merge=lfs -text
|
|
||||||
public/assets/fonts/Inter-Regular.woff2 filter=lfs diff=lfs merge=lfs -text
|
|
||||||
public/assets/fonts/Inter-SemiBold.woff2 filter=lfs diff=lfs merge=lfs -text
|
|
||||||
public/assets/fonts/Inter-SemiBoldItalic.woff2 filter=lfs diff=lfs merge=lfs -text
|
|
||||||
public/assets/fonts/Inter-Thin.woff2 filter=lfs diff=lfs merge=lfs -text
|
|
||||||
public/assets/fonts/Inter-Italic.woff filter=lfs diff=lfs merge=lfs -text
|
|
||||||
public/assets/fonts/Inter-LightItalic.woff filter=lfs diff=lfs merge=lfs -text
|
|
||||||
public/assets/fonts/Inter-roman.var.woff2 filter=lfs diff=lfs merge=lfs -text
|
|
||||||
public/assets/fonts/Inter-ThinItalic.woff filter=lfs diff=lfs merge=lfs -text
|
|
|
@ -1,4 +1,12 @@
|
||||||
|
.DS_Store
|
||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
/dist
|
/dist
|
||||||
/public/vendor/
|
/.svelte-kit
|
||||||
|
/package
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
|
/static/vendor
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
engine-strict=true
|
|
@ -0,0 +1,4 @@
|
||||||
|
# Ignore files for PNPM, NPM and YARN
|
||||||
|
pnpm-lock.yaml
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
|
@ -1,3 +1,8 @@
|
||||||
{
|
{
|
||||||
"plugins": ["prettier-plugin-svelte"]
|
"useTabs": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"printWidth": 100,
|
||||||
|
"plugins": ["prettier-plugin-svelte"],
|
||||||
|
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,27 +0,0 @@
|
||||||
import type { StorybookConfig } from "@storybook/svelte-vite";
|
|
||||||
|
|
||||||
const config: StorybookConfig = {
|
|
||||||
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx|svelte)"],
|
|
||||||
addons: [
|
|
||||||
"@storybook/addon-links",
|
|
||||||
"@storybook/addon-essentials",
|
|
||||||
"@storybook/addon-interactions",
|
|
||||||
],
|
|
||||||
framework: {
|
|
||||||
name: "@storybook/svelte-vite",
|
|
||||||
options: {},
|
|
||||||
},
|
|
||||||
docs: {
|
|
||||||
autodocs: "tag",
|
|
||||||
},
|
|
||||||
viteFinal: (config) => {
|
|
||||||
config.server!.proxy = {
|
|
||||||
"/api": {
|
|
||||||
target: "http://localhost:8099/",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = config;
|
|
|
@ -1,3 +0,0 @@
|
||||||
<script>
|
|
||||||
window.global = window;
|
|
||||||
</script>
|
|
|
@ -1,11 +0,0 @@
|
||||||
export const parameters = {
|
|
||||||
actions: { argTypesRegex: "^on[A-Z].*" },
|
|
||||||
controls: {
|
|
||||||
matchers: {
|
|
||||||
color: /(background|color)$/i,
|
|
||||||
date: /Date$/,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
import "../src/styles/main.scss";
|
|
|
@ -1,6 +1,6 @@
|
||||||
*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.*
|
_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._
|
||||||
|
|
||||||
*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)*
|
_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)_
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@ -15,8 +15,7 @@ npx degit sveltejs/template svelte-app
|
||||||
cd svelte-app
|
cd svelte-app
|
||||||
```
|
```
|
||||||
|
|
||||||
*Note that you will need to have [Node.js](https://nodejs.org) installed.*
|
_Note that you will need to have [Node.js](https://nodejs.org) installed._
|
||||||
|
|
||||||
|
|
||||||
## Get started
|
## Get started
|
||||||
|
|
||||||
|
@ -49,12 +48,11 @@ npm run build
|
||||||
|
|
||||||
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).
|
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
|
## 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.
|
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:
|
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
|
```js
|
||||||
"start": "sirv public --single"
|
"start": "sirv public --single"
|
||||||
|
|
|
@ -1,20 +0,0 @@
|
||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
||||||
<meta name="application-name" content="UpEnd" />
|
|
||||||
|
|
||||||
<title>UpEnd</title>
|
|
||||||
|
|
||||||
<link rel="icon" type="image/png" href="assets/upend.svg" />
|
|
||||||
<meta
|
|
||||||
http-equiv="Cache-control"
|
|
||||||
content="no-cache, no-store, must-revalidate"
|
|
||||||
/>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
|
@ -1,81 +1,69 @@
|
||||||
{
|
{
|
||||||
"name": "upend-kestrel",
|
"name": "upend-kestrel",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --clearScreen=false",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"lint": "eslint src",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"clean": "rm -frv dist public/vendor",
|
"lint": "prettier --check . && eslint .",
|
||||||
"storybook": "npm-run-all -p -r storybook:serve storybook:upend",
|
"format": "prettier --write ."
|
||||||
"storybook:serve": "storybook dev -p 6006",
|
},
|
||||||
"storybook:upend": "cargo run --release -- serve ../example_vault --bind 127.0.0.1:8099 --no-browser --reinitialize --rescan-mode mirror",
|
"devDependencies": {
|
||||||
"build-storybook": "storybook build"
|
"@sveltejs/adapter-auto": "^3.0.0",
|
||||||
},
|
"@sveltejs/kit": "^2.0.0",
|
||||||
"devDependencies": {
|
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
||||||
"@storybook/addon-essentials": "^7.5.3",
|
"@types/eslint": "8.56.0",
|
||||||
"@storybook/addon-interactions": "^7.5.3",
|
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||||
"@storybook/addon-links": "^7.5.3",
|
"@typescript-eslint/parser": "^6.0.0",
|
||||||
"@storybook/blocks": "^7.5.3",
|
"eslint": "^8.56.0",
|
||||||
"@storybook/svelte": "^7.5.3",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"@storybook/svelte-vite": "^7.5.3",
|
"eslint-plugin-svelte": "^2.35.1",
|
||||||
"@storybook/testing-library": "^0.0.13",
|
"prettier": "^3.1.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^1.4.0",
|
"prettier-plugin-svelte": "^3.1.2",
|
||||||
"@tsconfig/svelte": "^3.0.0",
|
"svelte": "^4.2.7",
|
||||||
"@types/d3": "^7.4.0",
|
"svelte-check": "^3.6.0",
|
||||||
"@types/debug": "^4.1.8",
|
"tslib": "^2.4.1",
|
||||||
"@types/dompurify": "^2.4.0",
|
"typescript": "^5.0.0",
|
||||||
"@types/lodash": "^4.14.197",
|
"vite": "^5.0.3"
|
||||||
"@types/marked": "^4.3.1",
|
},
|
||||||
"@types/three": "^0.143.2",
|
"dependencies": {
|
||||||
"@types/wavesurfer.js": "^6.0.6",
|
"@ibm/plex": "^6.3.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
"@recogito/annotorious": "^2.7.11",
|
||||||
"@typescript-eslint/parser": "^5.62.0",
|
"@sveltejs/adapter-static": "^3.0.1",
|
||||||
"eslint": "^8.48.0",
|
"@types/d3": "^7.4.3",
|
||||||
"eslint-plugin-storybook": "^0.6.13",
|
"@types/debug": "^4.1.12",
|
||||||
"eslint-plugin-svelte3": "^4.0.0",
|
"@types/dompurify": "^3.0.5",
|
||||||
"npm-run-all": "^4.1.5",
|
"@types/lodash": "^4.14",
|
||||||
"prettier": "^3.0.2",
|
"@types/marked": "^4.3.2",
|
||||||
"prettier-plugin-svelte": "^3.0.3",
|
"@types/node": "^18.19.8",
|
||||||
"react": "^18.2.0",
|
"@types/three": "^0.160.0",
|
||||||
"react-dom": "^18.2.0",
|
"@types/wavesurfer.js": "^6.0.12",
|
||||||
"storybook": "^7.5.3",
|
"@upnd/upend": "file:../tools/upend_js",
|
||||||
"svelte": "^3.59.2",
|
"@upnd/wasm-web": "file:../tools/upend_wasm/pkg-web",
|
||||||
"svelte-check": "^2.10.3",
|
"boxicons": "^2.1.4",
|
||||||
"svelte-preprocess": "^5.0.4",
|
"d3": "^7.8.5",
|
||||||
"tslib": "^2.6.2",
|
"date-fns": "^2.30.0",
|
||||||
"typescript": "^4.9.5",
|
"debug": "^4.3.4",
|
||||||
"vite": "^4.4.9",
|
"dompurify": "^2.4.7",
|
||||||
"vite-plugin-static-copy": "^0.13.1"
|
"filesize": "^8.0.7",
|
||||||
},
|
"history": "^5.3.0",
|
||||||
"dependencies": {
|
"i18next": "^22.5.1",
|
||||||
"@ibm/plex": "^6.3.0",
|
"lodash": "^4.17.21",
|
||||||
"@recogito/annotorious": "^2.7.11",
|
"lru-cache": "^6.0.0",
|
||||||
"@upnd/upend": "file:../tools/upend_js",
|
"marked": "^4.3.0",
|
||||||
"@upnd/wasm-web": "file:../tools/upend_wasm/pkg-web",
|
"match-sorter": "^6.3.1",
|
||||||
"boxicons": "^2.1.4",
|
"mitt": "^3.0.1",
|
||||||
"d3": "^7.8.5",
|
"normalize.css": "^8.0.1",
|
||||||
"date-fns": "^2.30.0",
|
"sass": "^1.66.1",
|
||||||
"debug": "^4.3.4",
|
"sirv-cli": "^2.0.2",
|
||||||
"dompurify": "^2.4.7",
|
"sswr": "^1.11.0",
|
||||||
"filesize": "^8.0.7",
|
"svelte-i18next": "^1.2.2",
|
||||||
"history": "^5.3.0",
|
"three": "^0.147.0",
|
||||||
"i18next": "^22.5.1",
|
"vite-plugin-static-copy": "^0.13.1",
|
||||||
"lodash": "^4.17.21",
|
"wavesurfer.js": "^6.6.4"
|
||||||
"lru-cache": "^6.0.0",
|
}
|
||||||
"marked": "^4.3.0",
|
|
||||||
"match-sorter": "^6.3.1",
|
|
||||||
"mitt": "^3.0.1",
|
|
||||||
"normalize.css": "^8.0.1",
|
|
||||||
"sass": "^1.66.1",
|
|
||||||
"sirv-cli": "^2.0.2",
|
|
||||||
"sswr": "^1.11.0",
|
|
||||||
"svelte-i18next": "^1.2.2",
|
|
||||||
"svelte-navigator": "^3.2.2",
|
|
||||||
"three": "^0.147.0",
|
|
||||||
"wavesurfer.js": "^6.6.4"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
8177
webui/pnpm-lock.yaml
8177
webui/pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
@ -1,48 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { Router, Route, createHistory } from "svelte-navigator";
|
|
||||||
import createHashSource from "./util/history";
|
|
||||||
import Header from "./components/layout/Header.svelte";
|
|
||||||
import Footer from "./components/layout/Footer.svelte";
|
|
||||||
import Home from "./views/Home.svelte";
|
|
||||||
import Browse from "./views/Browse.svelte";
|
|
||||||
import Search from "./views/Search.svelte";
|
|
||||||
import DropPasteHandler from "./components/DropPasteHandler.svelte";
|
|
||||||
import AddModal from "./components/AddModal.svelte";
|
|
||||||
import Store from "./views/Store.svelte";
|
|
||||||
import Setup from "./views/Setup.svelte";
|
|
||||||
|
|
||||||
import "./styles/main.scss";
|
|
||||||
|
|
||||||
const history = createHistory(createHashSource());
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Router {history} primary={false}>
|
|
||||||
<Header />
|
|
||||||
<main>
|
|
||||||
<Route path="/">
|
|
||||||
<Home />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path="/browse/*addresses">
|
|
||||||
<Browse />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path="/search/:query" let:params>
|
|
||||||
<Search query={decodeURIComponent(params.query)} />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path="/store">
|
|
||||||
<Store />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path="/setup">
|
|
||||||
<Setup />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Footer />
|
|
||||||
|
|
||||||
<AddModal />
|
|
||||||
</main>
|
|
||||||
</Router>
|
|
||||||
|
|
||||||
<DropPasteHandler />
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
// See https://kit.svelte.dev/docs/types#app
|
||||||
|
// for information about these interfaces
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
// interface Error {}
|
||||||
|
// interface Locals {}
|
||||||
|
// interface PageData {}
|
||||||
|
// interface PageState {}
|
||||||
|
// interface Platform {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -1,167 +0,0 @@
|
||||||
<script context="module" lang="ts">
|
|
||||||
import mitt from "mitt";
|
|
||||||
|
|
||||||
export type AddEvents = {
|
|
||||||
files: File[];
|
|
||||||
urls: string[];
|
|
||||||
};
|
|
||||||
export const addEmitter = mitt<AddEvents>();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { useNavigate } from "svelte-navigator";
|
|
||||||
import Icon from "./utils/Icon.svelte";
|
|
||||||
import IconButton from "./utils/IconButton.svelte";
|
|
||||||
import api from "../lib/api";
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
let files: File[] = [];
|
|
||||||
let URLs: string[] = [];
|
|
||||||
let uploading = false;
|
|
||||||
|
|
||||||
$: visible = files.length + URLs.length > 0;
|
|
||||||
|
|
||||||
addEmitter.on("files", (ev) => {
|
|
||||||
ev.forEach((file) => {
|
|
||||||
if (
|
|
||||||
!files
|
|
||||||
.map((f) => `${f.name}${f.size}`)
|
|
||||||
.includes(`${file.name}${file.size}`)
|
|
||||||
) {
|
|
||||||
files.push(file);
|
|
||||||
}
|
|
||||||
files = files;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
async function upload() {
|
|
||||||
uploading = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const addresses = await Promise.all(
|
|
||||||
files.map(async (file) => api.putBlob(file)),
|
|
||||||
);
|
|
||||||
|
|
||||||
navigate(`/browse/${addresses.join(",")}`);
|
|
||||||
} catch (error) {
|
|
||||||
alert(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
uploading = false;
|
|
||||||
reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
if (!uploading) {
|
|
||||||
files = [];
|
|
||||||
URLs = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:body on:keydown={(ev) => ev.key === "Escape" && reset()} />
|
|
||||||
|
|
||||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
|
||||||
<div class="addmodal-container" class:visible class:uploading on:click={reset}>
|
|
||||||
<div class="addmodal" on:click|stopPropagation>
|
|
||||||
<div class="files">
|
|
||||||
{#each files as file}
|
|
||||||
<div class="file">
|
|
||||||
{#if file.type.startsWith("image")}
|
|
||||||
<img src={URL.createObjectURL(file)} alt="To be uploaded." />
|
|
||||||
{:else}
|
|
||||||
<div class="icon">
|
|
||||||
<Icon name="file" />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="label">{file.name}</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<div class="controls">
|
|
||||||
<IconButton name="upload" on:click={upload} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.addmodal-container {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
color: var(--foreground);
|
|
||||||
|
|
||||||
display: none;
|
|
||||||
&.visible {
|
|
||||||
display: unset;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.uploading {
|
|
||||||
cursor: progress;
|
|
||||||
|
|
||||||
.addmodal {
|
|
||||||
filter: brightness(0.5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.addmodal {
|
|
||||||
position: absolute;
|
|
||||||
left: 50%;
|
|
||||||
top: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
|
|
||||||
background: var(--background);
|
|
||||||
|
|
||||||
color: var(--foreground);
|
|
||||||
border: solid 2px var(--foreground);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.files {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1em;
|
|
||||||
|
|
||||||
padding: 0.5em;
|
|
||||||
|
|
||||||
overflow-y: auto;
|
|
||||||
max-height: 66vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
border: 1px solid var(--foreground);
|
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--background-lighter);
|
|
||||||
padding: 0.5em;
|
|
||||||
|
|
||||||
img {
|
|
||||||
max-height: 12em;
|
|
||||||
max-width: 12em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
font-size: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
flex-grow: 1;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 48px;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,90 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { createEventDispatcher } from "svelte";
|
|
||||||
|
|
||||||
import Icon from "./utils/Icon.svelte";
|
|
||||||
import Selector from "./utils/Selector.svelte";
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
let selector: Selector;
|
|
||||||
|
|
||||||
let editable = false;
|
|
||||||
$: {
|
|
||||||
if (editable) {
|
|
||||||
dispatch("editable");
|
|
||||||
setTimeout(() => dispatch("editable"), 500); // once animation has finished
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$: editable && selector && selector.focus();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="view"
|
|
||||||
class:editable
|
|
||||||
on:click={() => (editable = true)}
|
|
||||||
on:keydown={(ev) => {
|
|
||||||
if (["Space", "Enter"].includes(ev.key)) editable = true;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div class="icon">
|
|
||||||
<Icon name="plus-circle" />
|
|
||||||
</div>
|
|
||||||
{#if editable}
|
|
||||||
<div class="controls">
|
|
||||||
<Selector
|
|
||||||
bind:this={selector}
|
|
||||||
types={["Address", "NewAddress", "Attribute"]}
|
|
||||||
on:input={(ev) => {
|
|
||||||
dispatch("input", ev.detail);
|
|
||||||
editable = false;
|
|
||||||
}}
|
|
||||||
on:focus={(ev) => {
|
|
||||||
if (!ev.detail) editable = false;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.view {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
background: var(--background-lighter);
|
|
||||||
color: var(--foreground-lighter);
|
|
||||||
border: 1px solid var(--foreground-lightest);
|
|
||||||
border-radius: 0.5em;
|
|
||||||
padding: 1rem;
|
|
||||||
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
transition:
|
|
||||||
opacity 0.3s,
|
|
||||||
width 0.5s,
|
|
||||||
min-width 0.5s;
|
|
||||||
|
|
||||||
opacity: 0.4;
|
|
||||||
width: 48px;
|
|
||||||
min-width: 48px;
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&.editable {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.editable {
|
|
||||||
width: 18em;
|
|
||||||
min-width: 18em;
|
|
||||||
.icon {
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
font-size: 36px;
|
|
||||||
transition: opacity 0.3s;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,185 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { createEventDispatcher, onMount, setContext, tick } from "svelte";
|
|
||||||
import { normUrl } from "../util/history";
|
|
||||||
import IconButton from "./utils/IconButton.svelte";
|
|
||||||
import { selected } from "./EntitySelect.svelte";
|
|
||||||
import type { BrowseContext } from "../util/browse";
|
|
||||||
import { writable } from "svelte/store";
|
|
||||||
import { useParams } from "svelte-navigator";
|
|
||||||
import { i18n } from "../i18n";
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
const params = useParams();
|
|
||||||
|
|
||||||
export let address: string | undefined = undefined;
|
|
||||||
export let index: number;
|
|
||||||
export let only: boolean;
|
|
||||||
export let background = "var(--background-lighter)";
|
|
||||||
export let forceDetail = false;
|
|
||||||
let shifted = false;
|
|
||||||
let key = Math.random();
|
|
||||||
|
|
||||||
let detail = only || forceDetail;
|
|
||||||
let detailChanged = false;
|
|
||||||
$: if (!detailChanged) detail = only || forceDetail;
|
|
||||||
$: if (detailChanged) tick().then(() => dispatch("detail", detail));
|
|
||||||
|
|
||||||
let indexStore = writable(index);
|
|
||||||
$: $indexStore = index;
|
|
||||||
|
|
||||||
let addressesStore = writable([]);
|
|
||||||
$: $addressesStore = $params.addresses?.split(",") || [];
|
|
||||||
|
|
||||||
setContext("browse", {
|
|
||||||
index: indexStore,
|
|
||||||
addresses: addressesStore,
|
|
||||||
} as BrowseContext);
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
// Required to make detail mode detection work in Browse
|
|
||||||
dispatch("detail", detail);
|
|
||||||
});
|
|
||||||
$: if ($selected.length) {
|
|
||||||
detail = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function visit() {
|
|
||||||
window.open(normUrl(`/browse/${address}`), "_blank");
|
|
||||||
}
|
|
||||||
|
|
||||||
let width = 460;
|
|
||||||
if (window.innerWidth < 600) {
|
|
||||||
width = window.innerWidth - 6;
|
|
||||||
}
|
|
||||||
function drag(ev: MouseEvent) {
|
|
||||||
const startWidth = width;
|
|
||||||
const startX = ev.screenX;
|
|
||||||
|
|
||||||
function onMouseMove(ev: MouseEvent) {
|
|
||||||
width = startWidth + (ev.screenX - startX);
|
|
||||||
width = width < 300 ? 300 : width;
|
|
||||||
}
|
|
||||||
function onMouseUp(_: MouseEvent) {
|
|
||||||
window.removeEventListener("mousemove", onMouseMove);
|
|
||||||
window.removeEventListener("mouseup", onMouseUp);
|
|
||||||
}
|
|
||||||
window.addEventListener("mousemove", onMouseMove);
|
|
||||||
window.addEventListener("mouseup", onMouseUp);
|
|
||||||
}
|
|
||||||
|
|
||||||
function reload() {
|
|
||||||
key = Math.random();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="browse-column"
|
|
||||||
class:detail
|
|
||||||
style="--background-color: {background}"
|
|
||||||
on:mousemove={(ev) => (shifted = ev.shiftKey)}
|
|
||||||
>
|
|
||||||
<div class="view" style="--width: {width}px">
|
|
||||||
<header>
|
|
||||||
{#if address}
|
|
||||||
<IconButton name="link" on:click={() => visit()} disabled={only}>
|
|
||||||
{$i18n.t("Detach")}
|
|
||||||
</IconButton>
|
|
||||||
{/if}
|
|
||||||
{#if !forceDetail}
|
|
||||||
<IconButton
|
|
||||||
name={detail ? "zoom-out" : "zoom-in"}
|
|
||||||
on:click={() => {
|
|
||||||
detail = !detail;
|
|
||||||
detailChanged = true;
|
|
||||||
}}
|
|
||||||
active={detail}
|
|
||||||
>
|
|
||||||
{$i18n.t("Detail")}
|
|
||||||
</IconButton>
|
|
||||||
{:else}
|
|
||||||
<div class="noop"></div>
|
|
||||||
{/if}
|
|
||||||
{#if address}
|
|
||||||
<IconButton
|
|
||||||
name="intersect"
|
|
||||||
on:click={() => dispatch("combine", address)}
|
|
||||||
>
|
|
||||||
{$i18n.t("Combine")}
|
|
||||||
</IconButton>
|
|
||||||
{/if}
|
|
||||||
{#if !shifted}
|
|
||||||
<IconButton
|
|
||||||
name="x-circle"
|
|
||||||
on:click={() => dispatch("close")}
|
|
||||||
disabled={only}
|
|
||||||
>
|
|
||||||
{$i18n.t("Close")}
|
|
||||||
</IconButton>
|
|
||||||
{:else}
|
|
||||||
<IconButton name="refresh" on:click={() => reload()}>
|
|
||||||
{$i18n.t("Reload")}
|
|
||||||
</IconButton>
|
|
||||||
{/if}
|
|
||||||
</header>
|
|
||||||
{#key key}
|
|
||||||
<slot {detail} />
|
|
||||||
{/key}
|
|
||||||
</div>
|
|
||||||
<div class="resizeHandle" on:mousedown|preventDefault={drag} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.browse-column {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.browse-column.detail {
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
.view {
|
|
||||||
@media screen and (min-width: 600px) {
|
|
||||||
min-width: 85vw;
|
|
||||||
max-width: min(85vw, 1920px);
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.view {
|
|
||||||
min-width: var(--width);
|
|
||||||
max-width: var(--width);
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
background: var(--background-color);
|
|
||||||
color: var(--foreground-lighter);
|
|
||||||
border: 1px solid var(--foreground-lightest);
|
|
||||||
border-radius: 0.5em;
|
|
||||||
padding: 1rem;
|
|
||||||
|
|
||||||
// transition: min-width 0.2s, max-width 0.2s;
|
|
||||||
// TODO - container has nowhere to scroll, breaking `detail` scroll
|
|
||||||
|
|
||||||
header {
|
|
||||||
font-size: 20px;
|
|
||||||
position: relative;
|
|
||||||
min-height: 1em;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
|
|
||||||
flex: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.resizeHandle {
|
|
||||||
cursor: ew-resize;
|
|
||||||
height: 100%;
|
|
||||||
width: 0.5rem;
|
|
||||||
|
|
||||||
@media screen and (max-width: 600px) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,178 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { i18n } from "../i18n";
|
|
||||||
import EntitySetEditor from "./EntitySetEditor.svelte";
|
|
||||||
import EntryView from "./EntryView.svelte";
|
|
||||||
import Icon from "./utils/Icon.svelte";
|
|
||||||
import EntityList from "./widgets/EntityList.svelte";
|
|
||||||
import api from "../lib/api";
|
|
||||||
import { Query } from "@upnd/upend";
|
|
||||||
import { ATTR_IN } from "@upnd/upend/constants";
|
|
||||||
import { createEventDispatcher } from "svelte";
|
|
||||||
import { Any } from "@upnd/upend/query";
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
|
|
||||||
export let spec: string;
|
|
||||||
|
|
||||||
const individualSpecs = spec.split(/(?=[+=-])/);
|
|
||||||
let includedGroups = individualSpecs
|
|
||||||
.filter((s) => s.startsWith("+"))
|
|
||||||
.map((s) => s.slice(1));
|
|
||||||
let requiredGroups = individualSpecs
|
|
||||||
.filter((s) => s.startsWith("="))
|
|
||||||
.map((s) => s.slice(1));
|
|
||||||
let excludedGroups = individualSpecs
|
|
||||||
.filter((s) => s.startsWith("-"))
|
|
||||||
.map((s) => s.slice(1));
|
|
||||||
|
|
||||||
$: if (
|
|
||||||
includedGroups.length === 0 &&
|
|
||||||
requiredGroups.length === 0 &&
|
|
||||||
excludedGroups.length === 0
|
|
||||||
) {
|
|
||||||
dispatch("close");
|
|
||||||
}
|
|
||||||
|
|
||||||
const combinedWidgets = [
|
|
||||||
{
|
|
||||||
name: "List",
|
|
||||||
icon: "list-check",
|
|
||||||
components: ({ entities }) => [
|
|
||||||
{
|
|
||||||
component: EntityList,
|
|
||||||
props: {
|
|
||||||
entities,
|
|
||||||
thumbnails: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "EntityList",
|
|
||||||
icon: "image",
|
|
||||||
components: ({ entities }) => [
|
|
||||||
{
|
|
||||||
component: EntityList,
|
|
||||||
props: {
|
|
||||||
entities,
|
|
||||||
thumbnails: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
let resultEntities = [];
|
|
||||||
async function updateResultEntities(
|
|
||||||
includedGroups: string[],
|
|
||||||
requiredGroups: string[],
|
|
||||||
excludedGroups: string[],
|
|
||||||
) {
|
|
||||||
const included = includedGroups.length
|
|
||||||
? (
|
|
||||||
await api.query(
|
|
||||||
Query.matches(
|
|
||||||
Any,
|
|
||||||
ATTR_IN,
|
|
||||||
includedGroups.map((g) => `@${g}`),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
).objects
|
|
||||||
: [];
|
|
||||||
const required = requiredGroups.length
|
|
||||||
? (
|
|
||||||
await api.query(
|
|
||||||
Query.matches(
|
|
||||||
Any,
|
|
||||||
ATTR_IN,
|
|
||||||
requiredGroups.map((g) => `@${g}`),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
).objects
|
|
||||||
: [];
|
|
||||||
const excluded = excludedGroups.length
|
|
||||||
? (
|
|
||||||
await api.query(
|
|
||||||
Query.matches(
|
|
||||||
Any,
|
|
||||||
ATTR_IN,
|
|
||||||
excludedGroups.map((g) => `@${g}`),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
).objects
|
|
||||||
: [];
|
|
||||||
resultEntities = (
|
|
||||||
Object.keys(included).length
|
|
||||||
? Object.keys(included)
|
|
||||||
: Object.keys(required)
|
|
||||||
)
|
|
||||||
.filter(
|
|
||||||
(e) => !requiredGroups.length || Object.keys(required).includes(e),
|
|
||||||
)
|
|
||||||
.filter((e) => !Object.keys(excluded).includes(e));
|
|
||||||
}
|
|
||||||
$: updateResultEntities(includedGroups, requiredGroups, excludedGroups);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="view" data-address-multi={resultEntities}>
|
|
||||||
<h2>
|
|
||||||
<Icon plain name="intersect" />
|
|
||||||
{$i18n.t("Combine")}
|
|
||||||
</h2>
|
|
||||||
<div class="controls">
|
|
||||||
<EntitySetEditor
|
|
||||||
entities={includedGroups}
|
|
||||||
header={$i18n.t("Include")}
|
|
||||||
confirmRemoveMessage={null}
|
|
||||||
on:add={(ev) => (includedGroups = [...includedGroups, ev.detail])}
|
|
||||||
on:remove={(ev) =>
|
|
||||||
(includedGroups = includedGroups.filter((e) => e !== ev.detail))}
|
|
||||||
/>
|
|
||||||
<EntitySetEditor
|
|
||||||
entities={requiredGroups}
|
|
||||||
header={$i18n.t("Require")}
|
|
||||||
confirmRemoveMessage={null}
|
|
||||||
on:add={(ev) => (requiredGroups = [...requiredGroups, ev.detail])}
|
|
||||||
on:remove={(ev) =>
|
|
||||||
(requiredGroups = requiredGroups.filter((e) => e !== ev.detail))}
|
|
||||||
/>
|
|
||||||
<EntitySetEditor
|
|
||||||
entities={excludedGroups}
|
|
||||||
header={$i18n.t("Exclude")}
|
|
||||||
confirmRemoveMessage={null}
|
|
||||||
on:add={(ev) => (excludedGroups = [...excludedGroups, ev.detail])}
|
|
||||||
on:remove={(ev) =>
|
|
||||||
(excludedGroups = excludedGroups.filter((e) => e !== ev.detail))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="entities">
|
|
||||||
<EntryView
|
|
||||||
title={$i18n.t("Matching entities")}
|
|
||||||
entities={resultEntities}
|
|
||||||
widgets={combinedWidgets}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.view {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
text-align: center;
|
|
||||||
margin: 0;
|
|
||||||
margin-top: -0.66em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entities {
|
|
||||||
flex-grow: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,87 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { addEmitter } from "./AddModal.svelte";
|
|
||||||
import Icon from "./utils/Icon.svelte";
|
|
||||||
|
|
||||||
let dragging = false;
|
|
||||||
|
|
||||||
function onDrop(ev: DragEvent) {
|
|
||||||
if (ev.dataTransfer.files.length > 0) {
|
|
||||||
addEmitter.emit("files", Array.from(ev.dataTransfer.files));
|
|
||||||
} // TODO: else check for URLs
|
|
||||||
dragging = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDragEnter() {
|
|
||||||
// noop
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDragOver(ev: DragEvent) {
|
|
||||||
if (Array.from(ev.dataTransfer.items).some((it) => it.kind === "file")) {
|
|
||||||
dragging = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDragLeave() {
|
|
||||||
dragging = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPaste(ev: ClipboardEvent) {
|
|
||||||
if (ev.clipboardData.files.length > 0) {
|
|
||||||
addEmitter.emit("files", Array.from(ev.clipboardData.files));
|
|
||||||
} // TODO: else check for URLs
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:body
|
|
||||||
on:dragenter|preventDefault={onDragEnter}
|
|
||||||
on:dragover|preventDefault={onDragOver}
|
|
||||||
on:dragleave|preventDefault={onDragLeave}
|
|
||||||
on:drop|preventDefault={onDrop}
|
|
||||||
on:paste={onPaste} />
|
|
||||||
|
|
||||||
<div class="dropindicator" class:dragging>
|
|
||||||
<div class="content">
|
|
||||||
<div class="icon">
|
|
||||||
<Icon name="current-location" />
|
|
||||||
</div>
|
|
||||||
<p>Drop an URL, an image or a file here!</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.dropindicator {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
background: rgba(0, 0, 0, 0.75);
|
|
||||||
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dragging {
|
|
||||||
display: unset;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
position: absolute;
|
|
||||||
left: 50%;
|
|
||||||
top: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
|
|
||||||
background: rgba(0, 0, 0, 0.9);
|
|
||||||
|
|
||||||
color: var(--foreground);
|
|
||||||
border: solid 0.25em var(--foreground);
|
|
||||||
border-radius: 0.5em;
|
|
||||||
padding: 1.5em;
|
|
||||||
|
|
||||||
text-align: center;
|
|
||||||
font-size: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
font-size: 128px;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,167 +0,0 @@
|
||||||
<script lang="ts" context="module">
|
|
||||||
import { writable } from "svelte/store";
|
|
||||||
|
|
||||||
export const selected = writable<string[]>([]);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { i18n } from "../i18n";
|
|
||||||
|
|
||||||
let canvas: HTMLCanvasElement;
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
|
|
||||||
function resizeCanvas() {
|
|
||||||
canvas.width = window.innerWidth;
|
|
||||||
canvas.height = window.innerHeight;
|
|
||||||
}
|
|
||||||
window.addEventListener("resize", resizeCanvas);
|
|
||||||
resizeCanvas();
|
|
||||||
|
|
||||||
let selecting = false;
|
|
||||||
let selectAllArea: DOMRect | undefined = undefined;
|
|
||||||
let selectAllAddresses: string[] | undefined = undefined;
|
|
||||||
let addressesToRemove = new Set();
|
|
||||||
document.addEventListener("mousedown", (ev) => {
|
|
||||||
if (ev.ctrlKey || ev.metaKey) {
|
|
||||||
ev.preventDefault();
|
|
||||||
|
|
||||||
selecting = true;
|
|
||||||
addressesToRemove = new Set();
|
|
||||||
|
|
||||||
const el = document.elementFromPoint(
|
|
||||||
ev.clientX,
|
|
||||||
ev.clientY,
|
|
||||||
) as HTMLElement;
|
|
||||||
|
|
||||||
const multiElement = el.closest("[data-address-multi]") as
|
|
||||||
| HTMLElement
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
if (multiElement) {
|
|
||||||
const banner = multiElement.querySelector("h2");
|
|
||||||
|
|
||||||
if (banner) {
|
|
||||||
const rect = banner.getBoundingClientRect();
|
|
||||||
selectAllArea = rect;
|
|
||||||
selectAllAddresses = multiElement.dataset.addressMulti.split(",");
|
|
||||||
|
|
||||||
ctx.rect(rect.left, rect.top, rect.width, rect.height);
|
|
||||||
ctx.fillStyle = "#dc322f33";
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
ctx.fillStyle = "#dc322f77";
|
|
||||||
ctx.font = `bold ${rect.height / 2}px Inter`;
|
|
||||||
ctx.textAlign = "center";
|
|
||||||
ctx.textBaseline = "middle";
|
|
||||||
const fix = ctx.measureText("M").actualBoundingBoxDescent / 2;
|
|
||||||
ctx.fillText(
|
|
||||||
$i18n.t("Select All"),
|
|
||||||
rect.left + rect.width / 2,
|
|
||||||
rect.top + rect.height / 2 + fix,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.strokeStyle = "#dc322f77";
|
|
||||||
ctx.lineWidth = 7;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(ev.clientX, ev.clientY);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener("mousemove", (ev) => {
|
|
||||||
if (selecting) {
|
|
||||||
ev.preventDefault();
|
|
||||||
|
|
||||||
if (selectAllArea) {
|
|
||||||
if (
|
|
||||||
ev.clientX > selectAllArea.left &&
|
|
||||||
ev.clientX < selectAllArea.right &&
|
|
||||||
ev.clientY > selectAllArea.top &&
|
|
||||||
ev.clientY < selectAllArea.bottom
|
|
||||||
) {
|
|
||||||
selected.update((selected) => {
|
|
||||||
return [
|
|
||||||
...selected,
|
|
||||||
...selectAllAddresses.filter((a) => {
|
|
||||||
return !selected.includes(a);
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
});
|
|
||||||
stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const el = document.elementFromPoint(
|
|
||||||
ev.clientX,
|
|
||||||
ev.clientY,
|
|
||||||
) as HTMLElement;
|
|
||||||
|
|
||||||
const addressElement = el.closest("[data-address]") as
|
|
||||||
| HTMLElement
|
|
||||||
| undefined;
|
|
||||||
if (addressElement) {
|
|
||||||
const address = addressElement.dataset.address;
|
|
||||||
const selectMode = addressElement.dataset.selectMode;
|
|
||||||
if (selectMode === "add" || selectMode === undefined) {
|
|
||||||
selected.update((selected) => {
|
|
||||||
if (!selected.includes(address)) {
|
|
||||||
return [...selected, address];
|
|
||||||
} else {
|
|
||||||
return selected;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if (selectMode === "remove") {
|
|
||||||
addressesToRemove.add(address);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.lineTo(ev.clientX, ev.clientY);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(ev.clientX, ev.clientY);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener("mouseup", () => {
|
|
||||||
stop();
|
|
||||||
});
|
|
||||||
|
|
||||||
function stop() {
|
|
||||||
selectAllArea = undefined;
|
|
||||||
selectAllAddresses = undefined;
|
|
||||||
selecting = false;
|
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
||||||
for (const address of addressesToRemove) {
|
|
||||||
selected.update((selected) => {
|
|
||||||
return selected.filter((a) => a !== address);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="selectIndicator">
|
|
||||||
<canvas bind:this={canvas}></canvas>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.selectIndicator {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
pointer-events: none;
|
|
||||||
|
|
||||||
canvas {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,117 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import UpObjectDisplay from "./display/UpObject.svelte";
|
|
||||||
import Selector, { type SelectorValue } from "./utils/Selector.svelte";
|
|
||||||
import IconButton from "./utils/IconButton.svelte";
|
|
||||||
import { i18n } from "../i18n";
|
|
||||||
import LabelBorder from "./utils/LabelBorder.svelte";
|
|
||||||
import { createEventDispatcher } from "svelte";
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
|
|
||||||
export let entities: string[];
|
|
||||||
export let hide = false;
|
|
||||||
|
|
||||||
export let header = "";
|
|
||||||
export let confirmRemoveMessage: string | null = $i18n.t(
|
|
||||||
"Are you sure you want to remove this?",
|
|
||||||
);
|
|
||||||
export let emptyMessage = $i18n.t("Nothing to show.");
|
|
||||||
|
|
||||||
let adding = false;
|
|
||||||
let selector: Selector;
|
|
||||||
|
|
||||||
$: if (adding && selector) selector.focus();
|
|
||||||
|
|
||||||
async function add(ev: CustomEvent<SelectorValue>) {
|
|
||||||
if (ev.detail.t !== "Address") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
dispatch("add", ev.detail.c);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function remove(address: string) {
|
|
||||||
if (!confirmRemoveMessage || confirm(confirmRemoveMessage)) {
|
|
||||||
dispatch("remove", address);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<LabelBorder {hide}>
|
|
||||||
<span slot="header">{header}</span>
|
|
||||||
|
|
||||||
{#if adding}
|
|
||||||
<div class="selector">
|
|
||||||
<Selector
|
|
||||||
bind:this={selector}
|
|
||||||
types={["Address", "NewAddress"]}
|
|
||||||
on:input={add}
|
|
||||||
on:focus={(ev) => {
|
|
||||||
if (!ev.detail) adding = false;
|
|
||||||
}}
|
|
||||||
placeholder={$i18n.t("Choose an entity...")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="body">
|
|
||||||
<div class="group-list">
|
|
||||||
{#each entities as entity}
|
|
||||||
<div
|
|
||||||
class="group"
|
|
||||||
on:mouseenter={() => dispatch("highlighted", entity)}
|
|
||||||
on:mouseleave={() => dispatch("highlighted", undefined)}
|
|
||||||
>
|
|
||||||
<UpObjectDisplay address={entity} link />
|
|
||||||
<IconButton subdued name="x-circle" on:click={() => remove(entity)} />
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="no-groups">
|
|
||||||
{emptyMessage}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{#if !adding}
|
|
||||||
<div class="add-button">
|
|
||||||
<IconButton
|
|
||||||
outline
|
|
||||||
small
|
|
||||||
name="folder-plus"
|
|
||||||
on:click={() => (adding = true)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</LabelBorder>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.group-list {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.25rem 0.2rem;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.body {
|
|
||||||
display: flex;
|
|
||||||
align-items: start;
|
|
||||||
|
|
||||||
.group-list {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
padding-bottom: 0.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selector {
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-groups {
|
|
||||||
opacity: 0.66;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,152 +0,0 @@
|
||||||
<script lang="ts" context="module">
|
|
||||||
export interface WidgetComponent {
|
|
||||||
component: ComponentType;
|
|
||||||
props: { [key: string]: unknown };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Widget {
|
|
||||||
name: string;
|
|
||||||
icon?: string;
|
|
||||||
components: (input: {
|
|
||||||
entries: UpEntry[];
|
|
||||||
entities: string[];
|
|
||||||
group?: string;
|
|
||||||
address?: string;
|
|
||||||
}) => Array<WidgetComponent>;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import EntryList from "./widgets/EntryList.svelte";
|
|
||||||
import type { UpEntry } from "@upnd/upend";
|
|
||||||
import Icon from "./utils/Icon.svelte";
|
|
||||||
import IconButton from "./utils/IconButton.svelte";
|
|
||||||
import { createEventDispatcher, type ComponentType } from "svelte";
|
|
||||||
import UpObject from "./display/UpObject.svelte";
|
|
||||||
import LabelBorder from "./utils/LabelBorder.svelte";
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
|
|
||||||
export let entries: UpEntry[] = [];
|
|
||||||
export let entities: string[] = [];
|
|
||||||
export let widgets: Widget[] | undefined = undefined;
|
|
||||||
export let initialWidget: string | undefined = undefined;
|
|
||||||
export let title: string | undefined = undefined;
|
|
||||||
export let group: string | undefined = undefined;
|
|
||||||
export let address: string | undefined = undefined;
|
|
||||||
export let icon: string | undefined = undefined;
|
|
||||||
export let highlighted = false;
|
|
||||||
|
|
||||||
let currentWidget: string | undefined;
|
|
||||||
|
|
||||||
function switchWidget(widget: string) {
|
|
||||||
currentWidget = widget;
|
|
||||||
dispatch("widgetSwitched", currentWidget);
|
|
||||||
}
|
|
||||||
|
|
||||||
let availableWidgets: Widget[] = [];
|
|
||||||
$: {
|
|
||||||
availableWidgets = [];
|
|
||||||
|
|
||||||
if (entries.length) {
|
|
||||||
availableWidgets = [
|
|
||||||
...availableWidgets,
|
|
||||||
{
|
|
||||||
name: "Entry List",
|
|
||||||
icon: "table",
|
|
||||||
components: ({ entries }) => [
|
|
||||||
{
|
|
||||||
component: EntryList,
|
|
||||||
props: { entries, columns: "entity, attribute, value" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (widgets?.length) {
|
|
||||||
availableWidgets = [...widgets, ...availableWidgets];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (availableWidgets.map((w) => w.name).includes(initialWidget)) {
|
|
||||||
currentWidget = initialWidget;
|
|
||||||
} else {
|
|
||||||
currentWidget = availableWidgets[0].name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let components: WidgetComponent[] = [];
|
|
||||||
$: {
|
|
||||||
components = availableWidgets
|
|
||||||
.find((w) => w.name === currentWidget)
|
|
||||||
.components({ entries, entities, group, address });
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<LabelBorder hide={entries.length === 0 && entities.length === 0}>
|
|
||||||
<svelte:fragment slot="header-full">
|
|
||||||
<h3 class:highlighted>
|
|
||||||
{#if group}
|
|
||||||
{#if icon}
|
|
||||||
<div class="icon">
|
|
||||||
<Icon name={icon} />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<UpObject link address={group} labels={title ? [title] : undefined} />
|
|
||||||
{:else}
|
|
||||||
{#if icon}
|
|
||||||
<div class="icon">
|
|
||||||
<Icon name={icon} />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{title || ""}
|
|
||||||
{/if}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{#if currentWidget && availableWidgets.length > 1}
|
|
||||||
<div class="views">
|
|
||||||
{#each availableWidgets as widget (widget.name)}
|
|
||||||
<IconButton
|
|
||||||
name={widget.icon || "cube"}
|
|
||||||
title={widget.name}
|
|
||||||
active={widget.name === currentWidget}
|
|
||||||
--active-color="var(--foreground)"
|
|
||||||
on:click={() => switchWidget(widget.name)}
|
|
||||||
>
|
|
||||||
{widget.name}
|
|
||||||
</IconButton>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</svelte:fragment>
|
|
||||||
{#each components as component}
|
|
||||||
<svelte:component
|
|
||||||
this={component.component}
|
|
||||||
{...component.props || {}}
|
|
||||||
on:change
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</LabelBorder>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.icon {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 1.25em;
|
|
||||||
margin-top: -0.3em;
|
|
||||||
position: relative;
|
|
||||||
bottom: -2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 0;
|
|
||||||
transition: text-shadow 0.2s;
|
|
||||||
|
|
||||||
&.highlighted {
|
|
||||||
text-shadow: #cb4b16 0 0 0.5em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.views {
|
|
||||||
display: flex;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,176 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { ATTR_IN, ATTR_LABEL } from "@upnd/upend/constants";
|
|
||||||
import api from "../lib/api";
|
|
||||||
import { i18n } from "../i18n";
|
|
||||||
import Spinner from "./utils/Spinner.svelte";
|
|
||||||
import UpObject from "./display/UpObject.svelte";
|
|
||||||
|
|
||||||
const groups = (async () => {
|
|
||||||
const data = await api.query(`(matches ? "${ATTR_IN}" ?)`);
|
|
||||||
|
|
||||||
const addresses = data.entries
|
|
||||||
.filter((e) => e.value.t === "Address")
|
|
||||||
.map((e) => e.value.c) as string[];
|
|
||||||
|
|
||||||
const sortedAddresses = [...new Set(addresses)]
|
|
||||||
.map((address) => ({
|
|
||||||
address,
|
|
||||||
count: addresses.filter((a) => a === address).length,
|
|
||||||
}))
|
|
||||||
.sort((a, b) => b.count - a.count);
|
|
||||||
|
|
||||||
const addressesString = sortedAddresses
|
|
||||||
.map(({ address }) => `@${address}`)
|
|
||||||
.join(" ");
|
|
||||||
const labels = (
|
|
||||||
await api.query(`(matches (in ${addressesString}) "${ATTR_LABEL}" ? )`)
|
|
||||||
).entries.filter((e) => e.value.t === "String");
|
|
||||||
|
|
||||||
const display = sortedAddresses.map(({ address, count }) => ({
|
|
||||||
address,
|
|
||||||
labels: labels
|
|
||||||
.filter((e) => e.entity === address)
|
|
||||||
.map((e) => e.value.c)
|
|
||||||
.sort() as string[],
|
|
||||||
count,
|
|
||||||
}));
|
|
||||||
|
|
||||||
display
|
|
||||||
.sort((a, b) => (a.labels[0] || "").localeCompare(b.labels[0] || ""))
|
|
||||||
.sort((a, b) => b.count - a.count);
|
|
||||||
|
|
||||||
const labelsToGroups = new Map<string, string[]>();
|
|
||||||
labels.forEach((e) => {
|
|
||||||
const groups = labelsToGroups.get(e.value.c as string) || [];
|
|
||||||
if (!groups.includes(e.entity)) {
|
|
||||||
groups.push(e.entity);
|
|
||||||
}
|
|
||||||
labelsToGroups.set(e.value.c as string, groups);
|
|
||||||
});
|
|
||||||
const duplicates = [...labelsToGroups.entries()]
|
|
||||||
.filter(([_, groups]) => groups.length > 1)
|
|
||||||
.map(([label, groups]) => ({ label, groups }));
|
|
||||||
|
|
||||||
return {
|
|
||||||
groups: display,
|
|
||||||
total: sortedAddresses.length,
|
|
||||||
duplicateGroups: duplicates,
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
|
|
||||||
let clientWidth: number;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="groups" bind:clientWidth class:small={clientWidth < 600}>
|
|
||||||
<h2>{$i18n.t("Groups")}</h2>
|
|
||||||
<div class="main">
|
|
||||||
{#await groups}
|
|
||||||
<Spinner centered />
|
|
||||||
{:then data}
|
|
||||||
<ul>
|
|
||||||
{#each data.groups as group}
|
|
||||||
<li class="group" data-address={group.address}>
|
|
||||||
<UpObject link address={group.address} labels={group.labels} />
|
|
||||||
<div class="count">{group.count}</div>
|
|
||||||
</li>
|
|
||||||
{:else}
|
|
||||||
<li>No groups?</li>
|
|
||||||
{/each}
|
|
||||||
{#if data.groups && data.total > data.groups.length}
|
|
||||||
<li>+ {data.total - data.groups.length}...</li>
|
|
||||||
{/if}
|
|
||||||
</ul>
|
|
||||||
{#if data.duplicateGroups.length > 0}
|
|
||||||
<h3>{$i18n.t("Duplicate groups")}</h3>
|
|
||||||
<ul class="duplicate">
|
|
||||||
{#each data.duplicateGroups as { label, groups }}
|
|
||||||
<li class="duplicate-group">
|
|
||||||
<div class="label">{label}</div>
|
|
||||||
<ul>
|
|
||||||
{#each groups as group}
|
|
||||||
<li>
|
|
||||||
<UpObject link address={group} backpath={2} />
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
{/await}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
@use "../styles/colors";
|
|
||||||
|
|
||||||
.groups {
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
flex-grow: 1;
|
|
||||||
height: 0;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main {
|
|
||||||
overflow: hidden auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin-top: -0.66em;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5em;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.count {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 0.66em;
|
|
||||||
margin-left: 0.25em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.duplicate {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.duplicate-group {
|
|
||||||
flex-basis: 49%;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid var(--foreground);
|
|
||||||
padding: .5rem;
|
|
||||||
overflow-x: auto;
|
|
||||||
max-width: 100%;
|
|
||||||
|
|
||||||
ul {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.groups.small {
|
|
||||||
ul {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,633 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import EntryView, { type Widget } from "./EntryView.svelte";
|
|
||||||
import { useEntity } from "../lib/entity";
|
|
||||||
import UpObject from "./display/UpObject.svelte";
|
|
||||||
import { createEventDispatcher } from "svelte";
|
|
||||||
import { derived, type Readable } from "svelte/store";
|
|
||||||
import { Query, type UpEntry } from "@upnd/upend";
|
|
||||||
import Spinner from "./utils/Spinner.svelte";
|
|
||||||
import NotesEditor from "./utils/NotesEditor.svelte";
|
|
||||||
import type { WidgetChange } from "../types/base";
|
|
||||||
import type { Address, EntityInfo } from "@upnd/upend/types";
|
|
||||||
import IconButton from "./utils/IconButton.svelte";
|
|
||||||
import BlobViewer from "./display/BlobViewer.svelte";
|
|
||||||
import { i18n } from "../i18n";
|
|
||||||
import EntryList from "./widgets/EntryList.svelte";
|
|
||||||
import api from "../lib/api";
|
|
||||||
import EntityList from "./widgets/EntityList.svelte";
|
|
||||||
import {
|
|
||||||
ATTR_IN,
|
|
||||||
ATTR_KEY,
|
|
||||||
ATTR_LABEL,
|
|
||||||
ATTR_OF,
|
|
||||||
} from "@upnd/upend/constants";
|
|
||||||
import InspectGroups from "./InspectGroups.svelte";
|
|
||||||
import InspectTypeEditor from "./InspectTypeEditor.svelte";
|
|
||||||
import LabelBorder from "./utils/LabelBorder.svelte";
|
|
||||||
import { debug } from "debug";
|
|
||||||
import { Any } from "@upnd/upend/query";
|
|
||||||
|
|
||||||
const dbg = debug("kestrel:Inspect");
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
|
|
||||||
export let address: string;
|
|
||||||
export let detail: boolean;
|
|
||||||
let showAsEntries = false;
|
|
||||||
let highlightedType: string | undefined;
|
|
||||||
|
|
||||||
let blobHandled = false;
|
|
||||||
|
|
||||||
$: ({ entity, entityInfo, error, revalidate } = useEntity(address));
|
|
||||||
|
|
||||||
$: allTypes = derived(
|
|
||||||
entityInfo,
|
|
||||||
($entityInfo, set) => {
|
|
||||||
getAllTypes($entityInfo).then((allTypes) => {
|
|
||||||
set(allTypes);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{},
|
|
||||||
) as Readable<{
|
|
||||||
[key: string]: {
|
|
||||||
labels: string[];
|
|
||||||
attributes: string[];
|
|
||||||
};
|
|
||||||
}>;
|
|
||||||
$: sortedTypes = Object.entries($allTypes)
|
|
||||||
.sort(([a, _], [b, __]) => a.localeCompare(b))
|
|
||||||
.sort(([_, a], [__, b]) => a.attributes.length - b.attributes.length);
|
|
||||||
|
|
||||||
async function getAllTypes(entityInfo: EntityInfo) {
|
|
||||||
const allTypes = {};
|
|
||||||
|
|
||||||
if (!entityInfo) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const typeAddresses: string[] = [
|
|
||||||
await api.getAddress(entityInfo.t),
|
|
||||||
...($entity?.attr[ATTR_IN] || []).map((e) => e.value.c as string),
|
|
||||||
];
|
|
||||||
const typeAddressesIn = typeAddresses.map((addr) => `@${addr}`).join(" ");
|
|
||||||
|
|
||||||
const labelsQuery = await api.query(
|
|
||||||
`(matches (in ${typeAddressesIn}) "${ATTR_LABEL}" ?)`,
|
|
||||||
);
|
|
||||||
|
|
||||||
typeAddresses.forEach((address) => {
|
|
||||||
let labels = labelsQuery.getObject(address).identify();
|
|
||||||
|
|
||||||
let typeLabel: string | undefined;
|
|
||||||
if (typeLabel) {
|
|
||||||
labels.unshift(typeLabel);
|
|
||||||
}
|
|
||||||
|
|
||||||
allTypes[address] = {
|
|
||||||
labels,
|
|
||||||
attributes: [],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const attributes = await api.query(
|
|
||||||
`(matches ? "${ATTR_OF}" (in ${typeAddressesIn}))`,
|
|
||||||
);
|
|
||||||
await Promise.all(
|
|
||||||
typeAddresses.map(async (address) => {
|
|
||||||
allTypes[address].attributes = (
|
|
||||||
await Promise.all(
|
|
||||||
(attributes.getObject(address).attr[`~${ATTR_OF}`] || []).map(
|
|
||||||
async (e) => {
|
|
||||||
try {
|
|
||||||
const { t, c } = await api.addressToComponents(e.entity);
|
|
||||||
if (t == "Attribute") {
|
|
||||||
return c;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
).filter(Boolean);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = {};
|
|
||||||
Object.keys(allTypes).forEach((addr) => {
|
|
||||||
if (allTypes[addr].attributes.length > 0) {
|
|
||||||
result[addr] = allTypes[addr];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
let untypedProperties = [] as UpEntry[];
|
|
||||||
let untypedLinks = [] as UpEntry[];
|
|
||||||
|
|
||||||
$: {
|
|
||||||
untypedProperties = [];
|
|
||||||
untypedLinks = [];
|
|
||||||
|
|
||||||
($entity?.attributes || []).forEach((entry) => {
|
|
||||||
const entryTypes = Object.entries($allTypes || {}).filter(([_, t]) =>
|
|
||||||
t.attributes.includes(entry.attribute),
|
|
||||||
);
|
|
||||||
if (entryTypes.length === 0) {
|
|
||||||
if (entry.value.t === "Address") {
|
|
||||||
untypedLinks.push(entry);
|
|
||||||
} else {
|
|
||||||
untypedProperties.push(entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
untypedProperties = untypedProperties;
|
|
||||||
untypedLinks = untypedLinks;
|
|
||||||
}
|
|
||||||
|
|
||||||
$: filteredUntypedProperties = untypedProperties.filter(
|
|
||||||
(entry) =>
|
|
||||||
![
|
|
||||||
ATTR_LABEL,
|
|
||||||
ATTR_IN,
|
|
||||||
ATTR_KEY,
|
|
||||||
"NOTE",
|
|
||||||
"LAST_VISITED",
|
|
||||||
"NUM_VISITED",
|
|
||||||
"LAST_ATTRIBUTE_WIDGET",
|
|
||||||
].includes(entry.attribute),
|
|
||||||
);
|
|
||||||
|
|
||||||
$: currentUntypedProperties = filteredUntypedProperties;
|
|
||||||
|
|
||||||
$: filteredUntypedLinks = untypedLinks.filter(
|
|
||||||
(entry) => ![ATTR_IN, ATTR_OF].includes(entry.attribute),
|
|
||||||
);
|
|
||||||
|
|
||||||
$: currentUntypedLinks = filteredUntypedLinks;
|
|
||||||
|
|
||||||
$: currentBacklinks =
|
|
||||||
$entity?.backlinks.filter(
|
|
||||||
(entry) => ![ATTR_IN, ATTR_OF].includes(entry.attribute),
|
|
||||||
) || [];
|
|
||||||
|
|
||||||
$: tagged = $entity?.attr[`~${ATTR_IN}`] || [];
|
|
||||||
|
|
||||||
let attributesUsed: UpEntry[] = [];
|
|
||||||
$: {
|
|
||||||
if ($entityInfo?.t === "Attribute") {
|
|
||||||
api
|
|
||||||
.query(`(matches ? "${$entityInfo.c}" ?)`)
|
|
||||||
.then((result) => (attributesUsed = result.entries));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let correctlyTagged: Address[] | undefined;
|
|
||||||
let incorrectlyTagged: Address[] | undefined;
|
|
||||||
$: {
|
|
||||||
if ($entity?.attr[`~${ATTR_OF}`]) {
|
|
||||||
fetchCorrectlyTagged();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchCorrectlyTagged() {
|
|
||||||
const attributes = (
|
|
||||||
await Promise.all(
|
|
||||||
$entity?.attr[`~${ATTR_OF}`].map((e) =>
|
|
||||||
api.addressToComponents(e.entity),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.filter((ac) => ac.t == "Attribute")
|
|
||||||
.map((ac) => ac.c);
|
|
||||||
|
|
||||||
const attributeQuery = await api.query(
|
|
||||||
Query.matches(
|
|
||||||
tagged.map((t) => `@${t.entity}`),
|
|
||||||
attributes,
|
|
||||||
Any,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
correctlyTagged = [];
|
|
||||||
incorrectlyTagged = [];
|
|
||||||
|
|
||||||
for (const element of tagged) {
|
|
||||||
const entity = attributeQuery.getObject(element.entity);
|
|
||||||
if (attributes.every((attr) => entity.attr[attr])) {
|
|
||||||
correctlyTagged = [...correctlyTagged, element.entity];
|
|
||||||
} else {
|
|
||||||
incorrectlyTagged = [...incorrectlyTagged, element.entity];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onChange(ev: CustomEvent<WidgetChange>) {
|
|
||||||
dbg("onChange", ev.detail);
|
|
||||||
const change = ev.detail;
|
|
||||||
switch (change.type) {
|
|
||||||
case "create":
|
|
||||||
await api.putEntry({
|
|
||||||
entity: address,
|
|
||||||
attribute: change.attribute,
|
|
||||||
value: change.value,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case "delete":
|
|
||||||
await api.deleteEntry(change.address);
|
|
||||||
break;
|
|
||||||
case "update":
|
|
||||||
await api.putEntityAttribute(address, change.attribute, change.value);
|
|
||||||
break;
|
|
||||||
case "entry-add":
|
|
||||||
await api.putEntry({
|
|
||||||
entity: change.address,
|
|
||||||
attribute: ATTR_IN,
|
|
||||||
value: { t: "Address", c: address },
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case "entry-delete": {
|
|
||||||
const inEntry = $entity?.attr[`~${ATTR_IN}`].find(
|
|
||||||
(e) => e.entity === change.address,
|
|
||||||
);
|
|
||||||
if (inEntry) {
|
|
||||||
await api.deleteEntry(inEntry.address);
|
|
||||||
} else {
|
|
||||||
console.warn(
|
|
||||||
"Couldn't find IN entry for entity %s?!",
|
|
||||||
change.address,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
console.error("Unimplemented AttributeChange", change);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
revalidate();
|
|
||||||
}
|
|
||||||
|
|
||||||
let identities = [address];
|
|
||||||
|
|
||||||
function onResolved(ev: CustomEvent<string[]>) {
|
|
||||||
identities = ev.detail;
|
|
||||||
dispatch("resolved", ev.detail);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteObject() {
|
|
||||||
if (confirm(`${$i18n.t("Really delete")} "${identities.join(" | ")}"?`)) {
|
|
||||||
await api.deleteEntry(address);
|
|
||||||
dispatch("close");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const attributeWidgets: Widget[] = [
|
|
||||||
{
|
|
||||||
name: "List",
|
|
||||||
icon: "list-check",
|
|
||||||
components: ({ entries }) => [
|
|
||||||
{
|
|
||||||
component: EntryList,
|
|
||||||
props: {
|
|
||||||
entries,
|
|
||||||
columns: "attribute, value",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const linkWidgets: Widget[] = [
|
|
||||||
{
|
|
||||||
name: "List",
|
|
||||||
icon: "list-check",
|
|
||||||
components: ({ entries, group }) => [
|
|
||||||
{
|
|
||||||
component: EntryList,
|
|
||||||
props: {
|
|
||||||
entries,
|
|
||||||
columns: "attribute, value",
|
|
||||||
attributes: $allTypes[group]?.attributes || [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Entity List",
|
|
||||||
icon: "image",
|
|
||||||
components: ({ entries, address }) => [
|
|
||||||
{
|
|
||||||
component: EntityList,
|
|
||||||
props: {
|
|
||||||
address,
|
|
||||||
entities: entries
|
|
||||||
.filter((e) => e.value.t == "Address")
|
|
||||||
.map((e) => e.value.c),
|
|
||||||
thumbnails: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const taggedWidgets: Widget[] = [
|
|
||||||
{
|
|
||||||
name: "List",
|
|
||||||
icon: "list-check",
|
|
||||||
components: ({ entries, address }) => [
|
|
||||||
{
|
|
||||||
component: EntityList,
|
|
||||||
props: {
|
|
||||||
address,
|
|
||||||
entities: entries.map((e) => e.entity),
|
|
||||||
thumbnails: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "EntityList",
|
|
||||||
icon: "image",
|
|
||||||
components: ({ entries, address }) => [
|
|
||||||
{
|
|
||||||
component: EntityList,
|
|
||||||
props: {
|
|
||||||
address,
|
|
||||||
entities: entries.map((e) => e.entity),
|
|
||||||
thumbnails: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
$: entity.subscribe(async (object) => {
|
|
||||||
if (object && object.listing.entries.length) {
|
|
||||||
dbg("Updating visit stats for %o", object);
|
|
||||||
await api.putEntityAttribute(
|
|
||||||
object.address,
|
|
||||||
"LAST_VISITED",
|
|
||||||
{
|
|
||||||
t: "Number",
|
|
||||||
c: new Date().getTime() / 1000,
|
|
||||||
},
|
|
||||||
"IMPLICIT",
|
|
||||||
);
|
|
||||||
|
|
||||||
await api.putEntityAttribute(
|
|
||||||
object.address,
|
|
||||||
"NUM_VISITED",
|
|
||||||
{
|
|
||||||
t: "Number",
|
|
||||||
c: (parseInt(String(object.get("NUM_VISITED"))) || 0) + 1,
|
|
||||||
},
|
|
||||||
"IMPLICIT",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="inspect"
|
|
||||||
class:detail
|
|
||||||
class:blob={blobHandled}
|
|
||||||
data-address-multi={($entity?.attr["~IN"]?.map((e) => e.entity) || []).join(
|
|
||||||
",",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<header>
|
|
||||||
<h2>
|
|
||||||
{#if $entity}
|
|
||||||
<UpObject banner {address} on:resolved={onResolved} />
|
|
||||||
{:else}
|
|
||||||
<Spinner centered />
|
|
||||||
{/if}
|
|
||||||
</h2>
|
|
||||||
</header>
|
|
||||||
{#if !showAsEntries}
|
|
||||||
<div class="main-content">
|
|
||||||
<div class="detail-col">
|
|
||||||
<div class="blob-viewer">
|
|
||||||
<BlobViewer
|
|
||||||
{address}
|
|
||||||
{detail}
|
|
||||||
on:handled={(ev) => (blobHandled = ev.detail)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{#if !$error}
|
|
||||||
<InspectGroups
|
|
||||||
{entity}
|
|
||||||
on:highlighted={(ev) => (highlightedType = ev.detail)}
|
|
||||||
on:change={() => revalidate()}
|
|
||||||
/>
|
|
||||||
<div class="properties">
|
|
||||||
<NotesEditor {address} on:change={onChange} />
|
|
||||||
<InspectTypeEditor {entity} on:change={() => revalidate()} />
|
|
||||||
{#each sortedTypes as [typeAddr, { labels, attributes }]}
|
|
||||||
<EntryView
|
|
||||||
entries={($entity?.attributes || []).filter((e) =>
|
|
||||||
attributes.includes(e.attribute),
|
|
||||||
)}
|
|
||||||
widgets={linkWidgets}
|
|
||||||
on:change={onChange}
|
|
||||||
highlighted={highlightedType == typeAddr}
|
|
||||||
title={labels.join(" | ")}
|
|
||||||
group={typeAddr}
|
|
||||||
{address}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
{#if currentUntypedProperties.length > 0}
|
|
||||||
<EntryView
|
|
||||||
title={$i18n.t("Other Properties")}
|
|
||||||
widgets={attributeWidgets}
|
|
||||||
entries={currentUntypedProperties}
|
|
||||||
on:change={onChange}
|
|
||||||
{address}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if currentUntypedLinks.length > 0}
|
|
||||||
<EntryView
|
|
||||||
title={$i18n.t("Links")}
|
|
||||||
widgets={linkWidgets}
|
|
||||||
entries={currentUntypedLinks}
|
|
||||||
on:change={onChange}
|
|
||||||
{address}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if !correctlyTagged || !incorrectlyTagged}
|
|
||||||
<EntryView
|
|
||||||
title={`${$i18n.t("Members")}`}
|
|
||||||
widgets={taggedWidgets}
|
|
||||||
entries={tagged}
|
|
||||||
on:change={onChange}
|
|
||||||
{address}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<EntryView
|
|
||||||
title={`${$i18n.t("Typed Members")} (${
|
|
||||||
correctlyTagged.length
|
|
||||||
})`}
|
|
||||||
widgets={taggedWidgets}
|
|
||||||
entries={tagged.filter((e) =>
|
|
||||||
correctlyTagged.includes(e.entity),
|
|
||||||
)}
|
|
||||||
on:change={onChange}
|
|
||||||
{address}
|
|
||||||
/>
|
|
||||||
<EntryView
|
|
||||||
title={`${$i18n.t("Untyped members")} (${
|
|
||||||
incorrectlyTagged.length
|
|
||||||
})`}
|
|
||||||
widgets={taggedWidgets}
|
|
||||||
entries={tagged.filter((e) =>
|
|
||||||
incorrectlyTagged.includes(e.entity),
|
|
||||||
)}
|
|
||||||
on:change={onChange}
|
|
||||||
{address}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if currentBacklinks.length > 0}
|
|
||||||
<EntryView
|
|
||||||
title={`${$i18n.t("Referred to")} (${currentBacklinks.length})`}
|
|
||||||
entries={currentBacklinks}
|
|
||||||
on:change={onChange}
|
|
||||||
{address}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if $entityInfo?.t === "Attribute"}
|
|
||||||
<LabelBorder>
|
|
||||||
<span slot="header"
|
|
||||||
>{$i18n.t("Used")} ({attributesUsed.length})</span
|
|
||||||
>
|
|
||||||
<EntryList
|
|
||||||
columns="entity,value"
|
|
||||||
entries={attributesUsed}
|
|
||||||
orderByValue
|
|
||||||
/>
|
|
||||||
</LabelBorder>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="error">
|
|
||||||
{$error}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="entries">
|
|
||||||
<h2>{$i18n.t("Attributes")}</h2>
|
|
||||||
<EntryList
|
|
||||||
entries={$entity.attributes}
|
|
||||||
columns={detail
|
|
||||||
? "timestamp, provenance, attribute, value"
|
|
||||||
: "attribute, value"}
|
|
||||||
on:change={onChange}
|
|
||||||
/>
|
|
||||||
<h2>{$i18n.t("Backlinks")}</h2>
|
|
||||||
<EntryList
|
|
||||||
entries={$entity.backlinks}
|
|
||||||
columns={detail
|
|
||||||
? "timestamp, provenance, entity, attribute"
|
|
||||||
: "entity, attribute"}
|
|
||||||
on:change={onChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="footer">
|
|
||||||
<IconButton
|
|
||||||
name="detail"
|
|
||||||
title={$i18n.t("Show as entries")}
|
|
||||||
active={showAsEntries}
|
|
||||||
on:click={() => (showAsEntries = !showAsEntries)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<IconButton
|
|
||||||
name="trash"
|
|
||||||
outline
|
|
||||||
subdued
|
|
||||||
color="#dc322f"
|
|
||||||
on:click={deleteObject}
|
|
||||||
title={$i18n.t("Delete object")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
header h2 {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inspect,
|
|
||||||
.main-content {
|
|
||||||
flex: auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.properties {
|
|
||||||
flex: auto;
|
|
||||||
height: 0; // https://stackoverflow.com/a/14964944
|
|
||||||
min-height: 12em;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding-right: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (min-width: 1600px) {
|
|
||||||
.inspect.detail {
|
|
||||||
.main-content {
|
|
||||||
position: relative;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: end;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.blob {
|
|
||||||
.detail-col {
|
|
||||||
width: 33%;
|
|
||||||
flex-grow: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blob-viewer {
|
|
||||||
width: 65%;
|
|
||||||
height: 100%;
|
|
||||||
position: absolute;
|
|
||||||
left: 1%;
|
|
||||||
top: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-content .detail-col {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entries {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
margin-top: 2rem;
|
|
||||||
display: flex;
|
|
||||||
justify-content: end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buttons {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
color: red;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,44 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import api from "../lib/api";
|
|
||||||
import { ATTR_IN } from "@upnd/upend/constants";
|
|
||||||
import { createEventDispatcher } from "svelte";
|
|
||||||
import type { UpObject } from "@upnd/upend";
|
|
||||||
import type { Readable } from "svelte/store";
|
|
||||||
import EntitySetEditor from "./EntitySetEditor.svelte";
|
|
||||||
import { i18n } from "../i18n";
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
|
|
||||||
export let entity: Readable<UpObject>;
|
|
||||||
|
|
||||||
$: groups = Object.fromEntries(
|
|
||||||
($entity?.attr[ATTR_IN] || []).map((e) => [e.value.c as string, e.address]),
|
|
||||||
);
|
|
||||||
|
|
||||||
async function addGroup(address: string) {
|
|
||||||
await api.putEntry([
|
|
||||||
{
|
|
||||||
entity: $entity.address,
|
|
||||||
attribute: ATTR_IN,
|
|
||||||
value: {
|
|
||||||
t: "Address",
|
|
||||||
c: address,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
dispatch("change");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeGroup(address: string) {
|
|
||||||
await api.deleteEntry(groups[address]);
|
|
||||||
dispatch("change");
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<EntitySetEditor
|
|
||||||
entities={Object.keys(groups)}
|
|
||||||
header={$i18n.t("Groups")}
|
|
||||||
hide={Object.keys(groups).length === 0}
|
|
||||||
on:add={(e) => addGroup(e.detail)}
|
|
||||||
on:remove={(e) => removeGroup(e.detail)}
|
|
||||||
on:highlighted
|
|
||||||
/>
|
|
|
@ -1,134 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import UpObjectDisplay from "./display/UpObject.svelte";
|
|
||||||
import Selector, { type SelectorValue } from "./utils/Selector.svelte";
|
|
||||||
import IconButton from "./utils/IconButton.svelte";
|
|
||||||
import api from "../lib/api";
|
|
||||||
import { i18n } from "../i18n";
|
|
||||||
import type { UpObject, UpEntry } from "@upnd/upend";
|
|
||||||
import type { Readable } from "svelte/store";
|
|
||||||
import { ATTR_OF } from "@upnd/upend/constants";
|
|
||||||
import { createEventDispatcher } from "svelte";
|
|
||||||
import LabelBorder from "./utils/LabelBorder.svelte";
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
|
|
||||||
export let entity: Readable<UpObject>;
|
|
||||||
|
|
||||||
let adding = false;
|
|
||||||
let typeSelector: Selector;
|
|
||||||
|
|
||||||
$: if (adding && typeSelector) typeSelector.focus();
|
|
||||||
|
|
||||||
$: typeEntries = $entity?.attr[`~${ATTR_OF}`] || [];
|
|
||||||
|
|
||||||
async function add(ev: CustomEvent<SelectorValue>) {
|
|
||||||
if (ev.detail.t !== "Attribute") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await api.putEntry({
|
|
||||||
entity: {
|
|
||||||
t: "Attribute",
|
|
||||||
c: ev.detail.name,
|
|
||||||
},
|
|
||||||
attribute: ATTR_OF,
|
|
||||||
value: { t: "Address", c: $entity.address },
|
|
||||||
});
|
|
||||||
dispatch("change");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function remove(entry: UpEntry) {
|
|
||||||
let really = confirm(
|
|
||||||
$i18n.t('Really remove "{{attributeName}}" from "{{typeName}}"?', {
|
|
||||||
attributeName: (await api.addressToComponents(entry.entity)).c,
|
|
||||||
typeName: $entity.identify().join("/"),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (really) {
|
|
||||||
await api.deleteEntry(entry.address);
|
|
||||||
dispatch("change");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if typeEntries.length || $entity?.attr["~IN"]?.length}
|
|
||||||
<LabelBorder hide={typeEntries.length === 0}>
|
|
||||||
<span slot="header">{$i18n.t("Type Attributes")}</span>
|
|
||||||
{#if adding}
|
|
||||||
<div class="selector">
|
|
||||||
<Selector
|
|
||||||
bind:this={typeSelector}
|
|
||||||
types={["Attribute", "NewAttribute"]}
|
|
||||||
on:input={add}
|
|
||||||
placeholder={$i18n.t("Assign an attribute to this type...")}
|
|
||||||
on:focus={(ev) => {
|
|
||||||
if (!ev.detail) adding = false;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="body">
|
|
||||||
<ul class="attributes">
|
|
||||||
{#each typeEntries as typeEntry}
|
|
||||||
<li class="attribute">
|
|
||||||
<div class="label">
|
|
||||||
<UpObjectDisplay address={typeEntry.entity} link />
|
|
||||||
</div>
|
|
||||||
<div class="controls">
|
|
||||||
<IconButton name="x-circle" on:click={() => remove(typeEntry)} />
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
{:else}
|
|
||||||
<li class="no-attributes">
|
|
||||||
{$i18n.t("No attributes assigned to this type.")}
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
<div class="add-button">
|
|
||||||
<IconButton
|
|
||||||
outline
|
|
||||||
small
|
|
||||||
name="plus-circle"
|
|
||||||
on:click={() => (adding = true)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</LabelBorder>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.attributes {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.25em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attribute {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.body {
|
|
||||||
display: flex;
|
|
||||||
align-items: start;
|
|
||||||
|
|
||||||
.attributes {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.selector {
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-attributes {
|
|
||||||
opacity: 0.66;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,74 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import api from "../lib/api";
|
|
||||||
import { ATTR_IN } from "@upnd/upend/constants";
|
|
||||||
import { i18n } from "../i18n";
|
|
||||||
import { Query, UpListing } from "@upnd/upend";
|
|
||||||
import EntitySetEditor from "./EntitySetEditor.svelte";
|
|
||||||
import { Any } from "@upnd/upend/query";
|
|
||||||
|
|
||||||
export let entities: string[];
|
|
||||||
|
|
||||||
let groups = [];
|
|
||||||
let groupListing: UpListing | undefined = undefined;
|
|
||||||
async function updateGroups() {
|
|
||||||
const currentEntities = entities.concat();
|
|
||||||
const allGroups = await api.query(
|
|
||||||
Query.matches(
|
|
||||||
currentEntities.map((e) => `@${e}`),
|
|
||||||
ATTR_IN,
|
|
||||||
Any,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const commonGroups = new Set(
|
|
||||||
allGroups.values
|
|
||||||
.filter((v) => v.t == "Address")
|
|
||||||
.map((v) => v.c)
|
|
||||||
.filter((groupAddr) => {
|
|
||||||
return Object.values(allGroups.objects).every((obj) => {
|
|
||||||
return obj.attr[ATTR_IN].some((v) => v.value.c === groupAddr);
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (entities.toString() == currentEntities.toString()) {
|
|
||||||
groups = Array.from(commonGroups);
|
|
||||||
groupListing = allGroups;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$: entities && updateGroups();
|
|
||||||
|
|
||||||
async function addGroup(address: string) {
|
|
||||||
await api.putEntry(
|
|
||||||
entities.map((entity) => ({
|
|
||||||
entity,
|
|
||||||
attribute: ATTR_IN,
|
|
||||||
value: {
|
|
||||||
t: "Address",
|
|
||||||
c: address,
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
await updateGroups();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeGroup(address: string) {
|
|
||||||
await Promise.all(
|
|
||||||
entities.map((entity) =>
|
|
||||||
api.deleteEntry(
|
|
||||||
groupListing.objects[entity].attr[ATTR_IN].find(
|
|
||||||
(v) => v.value.c === address,
|
|
||||||
).address,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await updateGroups();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<EntitySetEditor
|
|
||||||
entities={groups}
|
|
||||||
header={$i18n.t("Common groups")}
|
|
||||||
on:add={(ev) => addGroup(ev.detail)}
|
|
||||||
on:remove={(ev) => removeGroup(ev.detail)}
|
|
||||||
/>
|
|
|
@ -1,76 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { i18n } from "../i18n";
|
|
||||||
import { selected } from "./EntitySelect.svelte";
|
|
||||||
import EntryView from "./EntryView.svelte";
|
|
||||||
import MultiGroupEditor from "./MultiGroupEditor.svelte";
|
|
||||||
import Icon from "./utils/Icon.svelte";
|
|
||||||
import EntityList from "./widgets/EntityList.svelte";
|
|
||||||
|
|
||||||
const selectedWidgets = [
|
|
||||||
{
|
|
||||||
name: "List",
|
|
||||||
icon: "list-check",
|
|
||||||
components: ({ entities }) => [
|
|
||||||
{
|
|
||||||
component: EntityList,
|
|
||||||
props: {
|
|
||||||
entities,
|
|
||||||
thumbnails: false,
|
|
||||||
select: "remove",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "EntityList",
|
|
||||||
icon: "image",
|
|
||||||
components: ({ entities }) => [
|
|
||||||
{
|
|
||||||
component: EntityList,
|
|
||||||
props: {
|
|
||||||
entities,
|
|
||||||
thumbnails: true,
|
|
||||||
select: "remove",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="view">
|
|
||||||
<h2>
|
|
||||||
<Icon plain name="select-multiple" />
|
|
||||||
{$i18n.t("Selected")}: {$selected.length}
|
|
||||||
</h2>
|
|
||||||
<div class="actions">
|
|
||||||
<MultiGroupEditor entities={$selected} />
|
|
||||||
</div>
|
|
||||||
<div class="entities">
|
|
||||||
<EntryView
|
|
||||||
title={$i18n.t("Selected entities")}
|
|
||||||
entities={$selected}
|
|
||||||
widgets={selectedWidgets}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.view {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
text-align: center;
|
|
||||||
margin: 0;
|
|
||||||
margin-top: -0.66em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entities {
|
|
||||||
flex-grow: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,457 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import UpObject from "./display/UpObject.svelte";
|
|
||||||
import api from "../lib/api";
|
|
||||||
import Selector, { type SelectorValue } from "./utils/Selector.svelte";
|
|
||||||
import { createEventDispatcher, onMount, tick } from "svelte";
|
|
||||||
import type { ZoomBehavior, ZoomTransform, Selection } from "d3";
|
|
||||||
import Spinner from "./utils/Spinner.svelte";
|
|
||||||
import UpObjectCard from "./display/UpObjectCard.svelte";
|
|
||||||
import BlobPreview from "./display/BlobPreview.svelte";
|
|
||||||
import SurfacePoint from "./display/SurfacePoint.svelte";
|
|
||||||
import { i18n } from "../i18n";
|
|
||||||
import debug from "debug";
|
|
||||||
import { Query } from "@upnd/upend";
|
|
||||||
import { Any } from "@upnd/upend/query";
|
|
||||||
const dbg = debug("kestrel:surface");
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
|
|
||||||
export let x: string | undefined = undefined;
|
|
||||||
export let y: string | undefined = undefined;
|
|
||||||
$: dispatch("updateAddress", { x, y });
|
|
||||||
|
|
||||||
let loaded = false;
|
|
||||||
|
|
||||||
let viewMode = "point";
|
|
||||||
|
|
||||||
let currentX = NaN;
|
|
||||||
let currentY = NaN;
|
|
||||||
|
|
||||||
let zoom: ZoomBehavior<Element, unknown> | undefined;
|
|
||||||
let autofit: () => void | undefined;
|
|
||||||
|
|
||||||
let view: Selection<HTMLElement, unknown, null, undefined>;
|
|
||||||
let viewEl: HTMLElement | undefined;
|
|
||||||
let viewHeight = 0;
|
|
||||||
let viewWidth = 0;
|
|
||||||
|
|
||||||
let selector: Selector | undefined;
|
|
||||||
|
|
||||||
$: if (selector) selector.focus();
|
|
||||||
|
|
||||||
$: {
|
|
||||||
if ((x && !y) || (!x && y)) findPerpendicular();
|
|
||||||
}
|
|
||||||
async function findPerpendicular() {
|
|
||||||
const presentAxis = x || y;
|
|
||||||
const presentAxisAddress = await api.componentsToAddress({
|
|
||||||
t: "Attribute",
|
|
||||||
c: presentAxis,
|
|
||||||
});
|
|
||||||
const result = await api.query(
|
|
||||||
Query.or(
|
|
||||||
Query.matches(`@${presentAxisAddress}`, "PERPENDICULAR", Any),
|
|
||||||
Query.matches(Any, "PERPENDICULAR", `@${presentAxisAddress}`),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const perpendicular = [
|
|
||||||
...result.entries.map((e) => e.entity),
|
|
||||||
...result.values
|
|
||||||
.filter((v) => v.t === "Address")
|
|
||||||
.map((v) => v.c as string),
|
|
||||||
].find((address) => address !== presentAxisAddress);
|
|
||||||
|
|
||||||
if (perpendicular) {
|
|
||||||
const perpendicularComponents =
|
|
||||||
await api.addressToComponents(perpendicular);
|
|
||||||
if (perpendicularComponents.t !== "Attribute") return;
|
|
||||||
const perpendicularName = perpendicularComponents.c;
|
|
||||||
|
|
||||||
if (x) {
|
|
||||||
y = perpendicularName;
|
|
||||||
} else {
|
|
||||||
x = perpendicularName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IPoint {
|
|
||||||
address: string;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
}
|
|
||||||
let points: IPoint[] = [];
|
|
||||||
async function loadPoints() {
|
|
||||||
points = [];
|
|
||||||
const result = await api.query(`(matches ? (in "${x}" "${y}") ?)`);
|
|
||||||
|
|
||||||
points = Object.entries(result.objects)
|
|
||||||
.map(([address, obj]) => {
|
|
||||||
let objX = parseInt(String(obj.get(x)));
|
|
||||||
let objY = parseInt(String(obj.get(y)));
|
|
||||||
|
|
||||||
if (objX && objY) {
|
|
||||||
return {
|
|
||||||
address,
|
|
||||||
x: objX,
|
|
||||||
y: objY,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
tick().then(() => {
|
|
||||||
autofit();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$: {
|
|
||||||
if (x && y) {
|
|
||||||
loadPoints();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let selectorCoords: [number, number] | null = null;
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
const d3 = await import("d3");
|
|
||||||
|
|
||||||
function init() {
|
|
||||||
viewWidth = viewEl.clientWidth;
|
|
||||||
viewHeight = viewEl.clientHeight;
|
|
||||||
|
|
||||||
dbg("Initializing Surface view: %dx%d", viewWidth, viewHeight);
|
|
||||||
view = d3.select(viewEl);
|
|
||||||
const svg = view.select("svg");
|
|
||||||
if (svg.empty()) {
|
|
||||||
throw new Error(
|
|
||||||
"Failed initializing Surface - couldn't locate SVG element",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
svg.selectAll("*").remove();
|
|
||||||
|
|
||||||
const xScale = d3
|
|
||||||
.scaleLinear()
|
|
||||||
.domain([0, viewWidth])
|
|
||||||
.range([0, viewWidth]);
|
|
||||||
|
|
||||||
const yScale = d3
|
|
||||||
.scaleLinear()
|
|
||||||
.domain([0, viewHeight])
|
|
||||||
.range([viewHeight, 0]);
|
|
||||||
|
|
||||||
let xTicks = 10;
|
|
||||||
let yTicks = viewHeight / (viewWidth / xTicks);
|
|
||||||
|
|
||||||
const xAxis = d3
|
|
||||||
.axisBottom(xScale)
|
|
||||||
.ticks(xTicks)
|
|
||||||
.tickSize(viewHeight)
|
|
||||||
.tickPadding(5 - viewHeight);
|
|
||||||
|
|
||||||
const yAxis = d3
|
|
||||||
.axisRight(yScale)
|
|
||||||
.ticks(yTicks)
|
|
||||||
.tickSize(viewWidth)
|
|
||||||
.tickPadding(5 - viewWidth);
|
|
||||||
|
|
||||||
const gX = svg.append("g").call(xAxis);
|
|
||||||
const gY = svg.append("g").call(yAxis);
|
|
||||||
|
|
||||||
zoom = d3.zoom().on("zoom", zoomed);
|
|
||||||
|
|
||||||
function zoomed({ transform }: { transform: ZoomTransform }) {
|
|
||||||
const points = view.select(".content");
|
|
||||||
points.style(
|
|
||||||
"transform",
|
|
||||||
`translate(${transform.x}px, ${transform.y}px) scale(${transform.k})`,
|
|
||||||
);
|
|
||||||
const allPoints = view.selectAll(".point");
|
|
||||||
allPoints.style("transform", `scale(${1 / transform.k})`);
|
|
||||||
|
|
||||||
gX.call(xAxis.scale(transform.rescaleX(xScale)));
|
|
||||||
gY.call(yAxis.scale(transform.rescaleY(yScale)));
|
|
||||||
|
|
||||||
updateStyles();
|
|
||||||
}
|
|
||||||
|
|
||||||
autofit = () => {
|
|
||||||
zoom.translateTo(view, 0, viewHeight);
|
|
||||||
|
|
||||||
if (points.length) {
|
|
||||||
zoom.scaleTo(
|
|
||||||
view,
|
|
||||||
Math.min(
|
|
||||||
viewWidth / 2 / Math.max(...points.map((p) => Math.abs(p.x))) -
|
|
||||||
0.3,
|
|
||||||
viewHeight / 2 / Math.max(...points.map((p) => Math.abs(p.y))) -
|
|
||||||
0.3,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function updateStyles() {
|
|
||||||
svg
|
|
||||||
.selectAll(".tick line")
|
|
||||||
.attr("stroke-width", (d: number) => {
|
|
||||||
return d === 0 ? 2 : 1;
|
|
||||||
})
|
|
||||||
.attr("stroke", function (d: number) {
|
|
||||||
return d === 0
|
|
||||||
? "var(--foreground-lightest)"
|
|
||||||
: "var(--foreground-lighter)";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// function reset() {
|
|
||||||
// svg.transition().duration(750).call(zoom.transform, d3.zoomIdentity);
|
|
||||||
// }
|
|
||||||
|
|
||||||
view.on("mousemove", (ev: MouseEvent) => {
|
|
||||||
// not using offsetXY because `translate` transforms on .inner mess it up
|
|
||||||
const viewBBox = (view.node() as HTMLElement).getBoundingClientRect();
|
|
||||||
const [x, y] = d3
|
|
||||||
.zoomTransform(view.select(".content").node() as HTMLElement)
|
|
||||||
.invert([ev.clientX - viewBBox.left, ev.clientY - viewBBox.top]);
|
|
||||||
|
|
||||||
currentX = xScale.invert(x);
|
|
||||||
currentY = yScale.invert(y);
|
|
||||||
});
|
|
||||||
|
|
||||||
d3.select(viewEl)
|
|
||||||
.call(zoom)
|
|
||||||
.on("dblclick.zoom", (_ev: MouseEvent) => {
|
|
||||||
selectorCoords = [currentX, currentY];
|
|
||||||
});
|
|
||||||
|
|
||||||
autofit();
|
|
||||||
|
|
||||||
loaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
|
||||||
tick().then(() => init());
|
|
||||||
});
|
|
||||||
resizeObserver.observe(viewEl);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function onSelectorInput(ev: CustomEvent<SelectorValue>) {
|
|
||||||
const value = ev.detail;
|
|
||||||
if (value.t !== "Address") return;
|
|
||||||
const address = value.c;
|
|
||||||
|
|
||||||
const [xValue, yValue] = selectorCoords;
|
|
||||||
selectorCoords = null;
|
|
||||||
await Promise.all(
|
|
||||||
[
|
|
||||||
[x, xValue],
|
|
||||||
[y, yValue],
|
|
||||||
].map(([axis, value]: [string, number]) =>
|
|
||||||
api.putEntityAttribute(address, axis, {
|
|
||||||
t: "Number",
|
|
||||||
c: value,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await loadPoints();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="surface">
|
|
||||||
<div class="header ui">
|
|
||||||
<div class="axis-selector">
|
|
||||||
<div class="label">X</div>
|
|
||||||
<Selector
|
|
||||||
types={["Attribute", "NewAttribute"]}
|
|
||||||
initial={x ? { t: "Attribute", name: x } : undefined}
|
|
||||||
on:input={(ev) => {
|
|
||||||
if (ev.detail.t === "Attribute") x = ev.detail.name;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div class="value">
|
|
||||||
{(Math.round(currentX * 100) / 100).toLocaleString("en", {
|
|
||||||
useGrouping: false,
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="axis-selector">
|
|
||||||
<div class="label">Y</div>
|
|
||||||
<Selector
|
|
||||||
types={["Attribute", "NewAttribute"]}
|
|
||||||
initial={y ? { t: "Attribute", name: y } : undefined}
|
|
||||||
on:input={(ev) => {
|
|
||||||
if (ev.detail.t === "Attribute") y = ev.detail.name;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div class="value">
|
|
||||||
{(Math.round(currentY * 100) / 100).toLocaleString("en", {
|
|
||||||
useGrouping: false,
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="view" class:loaded bind:this={viewEl}>
|
|
||||||
<div class="ui view-mode-selector">
|
|
||||||
<div class="label">
|
|
||||||
{$i18n.t("View as")}
|
|
||||||
</div>
|
|
||||||
<select bind:value={viewMode}>
|
|
||||||
<option value="point">{$i18n.t("Point")}</option>
|
|
||||||
<option value="link">{$i18n.t("Link")}</option>
|
|
||||||
<option value="card">{$i18n.t("Card")}</option>
|
|
||||||
<!-- <option value="preview">{$i18n.t("Preview")}</option> -->
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{#if !loaded}
|
|
||||||
<div class="loading">
|
|
||||||
<Spinner centered="absolute" />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="content">
|
|
||||||
{#if selectorCoords !== null}
|
|
||||||
<div
|
|
||||||
class="point selector"
|
|
||||||
style="
|
|
||||||
left: {selectorCoords[0]}px;
|
|
||||||
top: {viewHeight - selectorCoords[1]}px"
|
|
||||||
>
|
|
||||||
<Selector
|
|
||||||
types={["Address", "NewAddress"]}
|
|
||||||
on:input={onSelectorInput}
|
|
||||||
on:focus={(ev) => {
|
|
||||||
if (!ev.detail) selectorCoords = null;
|
|
||||||
}}
|
|
||||||
bind:this={selector}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#each points as point}
|
|
||||||
<div
|
|
||||||
class="point"
|
|
||||||
style="left: {point.x}px; top: {viewHeight - point.y}px"
|
|
||||||
>
|
|
||||||
<div class="inner">
|
|
||||||
{#if viewMode == "link"}
|
|
||||||
<UpObject link address={point.address} />
|
|
||||||
{:else if viewMode == "card"}
|
|
||||||
<UpObjectCard address={point.address} />
|
|
||||||
{:else if viewMode == "preview"}
|
|
||||||
<BlobPreview address={point.address} />
|
|
||||||
{:else if viewMode == "point"}
|
|
||||||
<SurfacePoint address={point.address} />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<svg />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.surface {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1em;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin: 0.5em 0;
|
|
||||||
|
|
||||||
.axis-selector {
|
|
||||||
display: flex;
|
|
||||||
gap: 1em;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.label {
|
|
||||||
font-size: 1rem;
|
|
||||||
&::after {
|
|
||||||
content: ":";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.view {
|
|
||||||
flex-grow: 1;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
:global(svg) {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.tick text) {
|
|
||||||
color: var(--foreground-lightest);
|
|
||||||
font-size: 1rem;
|
|
||||||
text-shadow: 0 0 0.25em var(--background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
transform-origin: 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.point {
|
|
||||||
position: absolute;
|
|
||||||
transform-origin: 0 0;
|
|
||||||
|
|
||||||
.inner {
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
z-index: 99;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-mode-selector {
|
|
||||||
position: absolute;
|
|
||||||
top: 2rem;
|
|
||||||
right: 1.5em;
|
|
||||||
padding: 0.66em;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid var(--foreground-lighter);
|
|
||||||
background: var(--background);
|
|
||||||
opacity: 0.66;
|
|
||||||
transition: opacity 0.25s;
|
|
||||||
&:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:not(.loaded) {
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-mode-selector {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5em;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ui {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: rgba(0, 0, 0, 0.7);
|
|
||||||
z-index: 99;
|
|
||||||
transform: scale(3);
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,208 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { useEntity } from "../../lib/entity";
|
|
||||||
import Spinner from "../utils/Spinner.svelte";
|
|
||||||
import FragmentViewer from "./blobs/FragmentViewer.svelte";
|
|
||||||
import ModelViewer from "./blobs/ModelViewer.svelte";
|
|
||||||
import VideoViewer from "./blobs/VideoViewer.svelte";
|
|
||||||
import HashBadge from "./HashBadge.svelte";
|
|
||||||
import api from "../../lib/api";
|
|
||||||
import { createEventDispatcher } from "svelte";
|
|
||||||
import { getTypes } from "../../util/mediatypes";
|
|
||||||
import { concurrentImage } from "../imageQueue";
|
|
||||||
import { ATTR_IN } from "@upnd/upend/constants";
|
|
||||||
import AudioPreview from "./blobs/AudioPreview.svelte";
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
|
|
||||||
export let address: string;
|
|
||||||
export let recurse = 3;
|
|
||||||
|
|
||||||
$: ({ entity, entityInfo } = useEntity(address));
|
|
||||||
$: types = $entity && getTypes($entity, $entityInfo);
|
|
||||||
|
|
||||||
$: handled =
|
|
||||||
types &&
|
|
||||||
(!$entity ||
|
|
||||||
types.audio ||
|
|
||||||
types.video ||
|
|
||||||
types.image ||
|
|
||||||
types.text ||
|
|
||||||
types.model ||
|
|
||||||
types.web ||
|
|
||||||
types.fragment ||
|
|
||||||
(types.group && recurse > 0));
|
|
||||||
|
|
||||||
$: dispatch("handled", handled);
|
|
||||||
|
|
||||||
let loaded = null;
|
|
||||||
$: dispatch("loaded", Boolean(loaded));
|
|
||||||
|
|
||||||
let failedChildren: string[] = [];
|
|
||||||
let loadedChildren: string[] = [];
|
|
||||||
$: groupChildren = $entity?.backlinks
|
|
||||||
.filter((e) => e.attribute === ATTR_IN)
|
|
||||||
.map((e) => String(e.entity))
|
|
||||||
.filter(
|
|
||||||
(addr) =>
|
|
||||||
!failedChildren
|
|
||||||
.slice(
|
|
||||||
0,
|
|
||||||
$entity?.backlinks.filter((e) => e.attribute === ATTR_IN).length -
|
|
||||||
4,
|
|
||||||
)
|
|
||||||
.includes(addr),
|
|
||||||
)
|
|
||||||
.slice(0, 4);
|
|
||||||
|
|
||||||
$: if (groupChildren)
|
|
||||||
loaded = groupChildren.every(
|
|
||||||
(addr) => loadedChildren.includes(addr) || failedChildren.includes(addr),
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="preview">
|
|
||||||
{#if handled}
|
|
||||||
<div class="inner">
|
|
||||||
{#if !loaded}
|
|
||||||
<Spinner centered="absolute" />
|
|
||||||
{/if}
|
|
||||||
{#if types.group}
|
|
||||||
<ul class="group">
|
|
||||||
{#each groupChildren as address (address)}
|
|
||||||
<li>
|
|
||||||
<svelte:self
|
|
||||||
{address}
|
|
||||||
recurse={recurse - 1}
|
|
||||||
on:handled={(ev) => {
|
|
||||||
if (!ev.detail && !failedChildren.includes(address))
|
|
||||||
failedChildren = [...failedChildren, address];
|
|
||||||
}}
|
|
||||||
on:loaded={(ev) => {
|
|
||||||
if (ev.detail && !loadedChildren.includes(address))
|
|
||||||
loadedChildren = [...loadedChildren, address];
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{:else if types.model}
|
|
||||||
<ModelViewer
|
|
||||||
lookonly
|
|
||||||
src="{api.apiUrl}/raw/{address}"
|
|
||||||
on:loaded={() => (loaded = address)}
|
|
||||||
/>
|
|
||||||
{:else if types.web}
|
|
||||||
<img
|
|
||||||
alt="OpenGraph image for {$entityInfo?.t == 'Url' && $entityInfo?.c}"
|
|
||||||
use:concurrentImage={String($entity?.get("OG_IMAGE"))}
|
|
||||||
on:load={() => (loaded = address)}
|
|
||||||
on:error={() => (handled = false)}
|
|
||||||
/>
|
|
||||||
{:else if types.fragment}
|
|
||||||
<FragmentViewer
|
|
||||||
{address}
|
|
||||||
detail={false}
|
|
||||||
on:loaded={() => (loaded = address)}
|
|
||||||
/>
|
|
||||||
{:else if types.audio}
|
|
||||||
<AudioPreview
|
|
||||||
{address}
|
|
||||||
on:loaded={() => (loaded = address)}
|
|
||||||
on:error={() => (handled = false)}
|
|
||||||
/>
|
|
||||||
{:else if types.video}
|
|
||||||
<VideoViewer
|
|
||||||
{address}
|
|
||||||
detail={false}
|
|
||||||
on:loaded={() => (loaded = address)}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div class="image" class:loaded={loaded == address || !handled}>
|
|
||||||
<img
|
|
||||||
class:loaded={loaded == address}
|
|
||||||
alt="Thumbnail for {address}..."
|
|
||||||
use:concurrentImage={`${api.apiUrl}/${
|
|
||||||
types.mimeType?.includes("svg+xml") ? "raw" : "thumb"
|
|
||||||
}/${address}?size=512&quality=75`}
|
|
||||||
on:load={() => (loaded = address)}
|
|
||||||
on:error={() => (handled = false)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="hashbadge">
|
|
||||||
<HashBadge {address} />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.preview {
|
|
||||||
flex-grow: 1;
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
.inner {
|
|
||||||
display: flex;
|
|
||||||
min-height: 0;
|
|
||||||
flex-grow: 1;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hashbadge {
|
|
||||||
font-size: 48px;
|
|
||||||
opacity: 0.25;
|
|
||||||
text-align: center;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image {
|
|
||||||
display: flex;
|
|
||||||
min-height: 0;
|
|
||||||
min-width: 0;
|
|
||||||
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
img {
|
|
||||||
max-width: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
|
|
||||||
&:not(.loaded) {
|
|
||||||
flex-grow: 1;
|
|
||||||
height: 6rem;
|
|
||||||
max-height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.group {
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
flex-grow: 1;
|
|
||||||
min-height: 0;
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
grid-template-rows: repeat(2, 1fr);
|
|
||||||
|
|
||||||
padding: 0.25rem;
|
|
||||||
gap: 0.25rem;
|
|
||||||
border: 1px solid var(--foreground);
|
|
||||||
border-radius: 4px;
|
|
||||||
|
|
||||||
li {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: end;
|
|
||||||
list-style: none;
|
|
||||||
min-height: 0;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,121 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { useEntity } from "../../lib/entity";
|
|
||||||
import Spinner from "../utils/Spinner.svelte";
|
|
||||||
import AudioViewer from "./blobs/AudioViewer.svelte";
|
|
||||||
import FragmentViewer from "./blobs/FragmentViewer.svelte";
|
|
||||||
import ImageViewer from "./blobs/ImageViewer.svelte";
|
|
||||||
import ModelViewer from "./blobs/ModelViewer.svelte";
|
|
||||||
import TextViewer from "./blobs/TextViewer.svelte";
|
|
||||||
import VideoViewer from "./blobs/VideoViewer.svelte";
|
|
||||||
import UpLink from "./UpLink.svelte";
|
|
||||||
import api from "../../lib/api";
|
|
||||||
import { createEventDispatcher } from "svelte";
|
|
||||||
import { getTypes } from "../../util/mediatypes";
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
|
|
||||||
export let address: string;
|
|
||||||
export let detail: boolean;
|
|
||||||
|
|
||||||
let handled = false;
|
|
||||||
|
|
||||||
$: ({ entity, entityInfo } = useEntity(address));
|
|
||||||
$: types = $entity && getTypes($entity, $entityInfo);
|
|
||||||
|
|
||||||
$: handled =
|
|
||||||
types &&
|
|
||||||
(types.audio ||
|
|
||||||
types.video ||
|
|
||||||
types.image ||
|
|
||||||
types.text ||
|
|
||||||
types.pdf ||
|
|
||||||
types.model ||
|
|
||||||
types.web ||
|
|
||||||
types.fragment);
|
|
||||||
|
|
||||||
$: dispatch("handled", handled);
|
|
||||||
|
|
||||||
let imageLoaded = null;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if handled}
|
|
||||||
<div class="preview" class:detail>
|
|
||||||
{#if types.text}
|
|
||||||
<div class="text-viewer">
|
|
||||||
<TextViewer {address} />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if types.audio}
|
|
||||||
<AudioViewer {address} {detail} />
|
|
||||||
{/if}
|
|
||||||
{#if types.video}
|
|
||||||
<VideoViewer detail {address} />
|
|
||||||
{/if}
|
|
||||||
{#if types.image}
|
|
||||||
<ImageViewer {address} {detail} />
|
|
||||||
{/if}
|
|
||||||
{#if types.pdf}
|
|
||||||
<iframe
|
|
||||||
src="{api.apiUrl}/raw/{address}?inline"
|
|
||||||
title="PDF document of {address}"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{#if types.model}
|
|
||||||
<ModelViewer src="{api.apiUrl}/raw/{address}" />
|
|
||||||
{/if}
|
|
||||||
{#if types.web}
|
|
||||||
{#if imageLoaded != address}
|
|
||||||
<Spinner />
|
|
||||||
{/if}
|
|
||||||
<img
|
|
||||||
src={String($entity?.get("OG_IMAGE"))}
|
|
||||||
alt="OpenGraph image for {$entityInfo?.t == 'Url' && $entityInfo?.c}"
|
|
||||||
on:load={() => (imageLoaded = address)}
|
|
||||||
on:error={() => (handled = false)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{#if types.fragment}
|
|
||||||
<UpLink passthrough to={{ entity: String($entity.get("ANNOTATES")) }}>
|
|
||||||
<FragmentViewer {address} {detail} />
|
|
||||||
</UpLink>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.preview {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-direction: column;
|
|
||||||
// min-height: 33vh;
|
|
||||||
max-height: 50vh;
|
|
||||||
|
|
||||||
&.detail {
|
|
||||||
height: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
flex-grow: 1;
|
|
||||||
// min-height: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
img,
|
|
||||||
.text-viewer {
|
|
||||||
width: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
iframe {
|
|
||||||
width: 99%;
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-viewer {
|
|
||||||
display: flex;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,61 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
|
|
||||||
const BADGE_HEIGHT = 3;
|
|
||||||
export let address: string;
|
|
||||||
|
|
||||||
let canvas: HTMLCanvasElement | undefined;
|
|
||||||
let width = 0;
|
|
||||||
|
|
||||||
const bytes = [...address].map((c) => c.charCodeAt(0));
|
|
||||||
while (bytes.length % (3 * BADGE_HEIGHT) !== 0) {
|
|
||||||
bytes.push(bytes[bytes.length - 1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
width = Math.ceil(bytes.length / 3 / BADGE_HEIGHT);
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
const ctx = canvas?.getContext("2d");
|
|
||||||
if (!ctx) {
|
|
||||||
console.warn("Couldn't initialize canvas!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hueRange = 120;
|
|
||||||
const hueCenter = 90 + bytes.length * 2.5;
|
|
||||||
|
|
||||||
let idx = 0;
|
|
||||||
function draw() {
|
|
||||||
const tmp = [];
|
|
||||||
while (bytes.length > 0 && tmp.length < 3) {
|
|
||||||
tmp.push(bytes.shift());
|
|
||||||
}
|
|
||||||
while (tmp.length < 3) {
|
|
||||||
tmp.push(tmp[tmp.length - 1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const h = (tmp[0] / 128) * hueRange + hueCenter - hueRange / 2;
|
|
||||||
const s = (tmp[1] / 128) * 100;
|
|
||||||
const l = (tmp[2] / 128) * 100;
|
|
||||||
ctx.fillStyle = `hsl(${h},${s}%,${l}%)`;
|
|
||||||
ctx.fillRect(Math.floor(idx / BADGE_HEIGHT), idx % BADGE_HEIGHT, 1, 1);
|
|
||||||
idx++;
|
|
||||||
if (bytes.length > 0) {
|
|
||||||
requestAnimationFrame(draw);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
requestAnimationFrame(draw);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<canvas bind:this={canvas} {width} height="3" title={address} />
|
|
||||||
|
|
||||||
<style>
|
|
||||||
canvas {
|
|
||||||
display: inline-block;
|
|
||||||
height: 1em;
|
|
||||||
image-rendering: optimizeSpeed;
|
|
||||||
image-rendering: pixelated;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,50 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { Address } from "@upnd/upend/types";
|
|
||||||
import UpObject from "./UpObject.svelte";
|
|
||||||
import UpLink from "./UpLink.svelte";
|
|
||||||
|
|
||||||
export let address: Address;
|
|
||||||
let popup = false;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- svelte-ignore a11y-mouse-events-have-key-events -->
|
|
||||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
|
||||||
<UpLink passthrough to={{ entity: address }}>
|
|
||||||
<div
|
|
||||||
class="surface-point"
|
|
||||||
on:mouseover={() => (popup = true)}
|
|
||||||
on:mouseleave={() => (popup = false)}
|
|
||||||
>
|
|
||||||
{#if popup}
|
|
||||||
<div class="popup-inner">
|
|
||||||
<UpObject {address} />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</UpLink>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
@use "../../styles/colors.scss";
|
|
||||||
|
|
||||||
.surface-point {
|
|
||||||
display: relative;
|
|
||||||
|
|
||||||
width: 0.75rem;
|
|
||||||
height: 0.75rem;
|
|
||||||
border-radius: 25%;
|
|
||||||
background: colors.$red;
|
|
||||||
box-shadow: 0 0 0 1px darken(colors.$red, 20%);
|
|
||||||
|
|
||||||
cursor: pointer;
|
|
||||||
&:hover {
|
|
||||||
background: lighten(colors.$red, 20%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.popup-inner {
|
|
||||||
position: relative;
|
|
||||||
top: 1rem;
|
|
||||||
display: inline-block;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,67 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { UpEntry } from "@upnd/upend";
|
|
||||||
import { attributeLabels } from "../../util/labels";
|
|
||||||
import UpObject from "./UpObject.svelte";
|
|
||||||
export let resolve = true;
|
|
||||||
|
|
||||||
export let entry: UpEntry;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="entry">
|
|
||||||
<div class="entity">
|
|
||||||
<UpObject
|
|
||||||
plain
|
|
||||||
link
|
|
||||||
address={entry.entity}
|
|
||||||
labels={resolve ? undefined : []}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="attribute" title={entry.attribute}>
|
|
||||||
{$attributeLabels[entry.attribute] || entry.attribute}
|
|
||||||
</div>
|
|
||||||
<div class="value value-{entry.value.t.toLowerCase()}">
|
|
||||||
{#if entry.value.t === "Address"}
|
|
||||||
<UpObject
|
|
||||||
link
|
|
||||||
address={entry.value.c}
|
|
||||||
labels={resolve ? undefined : []}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
{entry.value.c}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.entry {
|
|
||||||
border: 1px solid var(--foreground);
|
|
||||||
background: var(--background-lighter);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.1em 0.5em 0.1em 0.25em;
|
|
||||||
display: flex;
|
|
||||||
align-content: center;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1em;
|
|
||||||
|
|
||||||
& > * {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.attribute {
|
|
||||||
flex-grow: 1;
|
|
||||||
text-align: center;
|
|
||||||
font-weight: 300;
|
|
||||||
&::before {
|
|
||||||
content: "→\00a0";
|
|
||||||
}
|
|
||||||
&::after {
|
|
||||||
content: "\00a0→";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.value-value) {
|
|
||||||
font-family: var(--monospace-font);
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,88 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { getContext } from "svelte";
|
|
||||||
import { useNavigate, useLocation } from "svelte-navigator";
|
|
||||||
import { readable } from "svelte/store";
|
|
||||||
import type { Address, VALUE_TYPE } from "@upnd/upend/types";
|
|
||||||
import type { BrowseContext } from "../../util/browse";
|
|
||||||
import api from "../../lib/api";
|
|
||||||
const location = useLocation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
export let passthrough = false;
|
|
||||||
export let title: string | undefined = undefined;
|
|
||||||
export let text = false;
|
|
||||||
export let to: {
|
|
||||||
entity?: Address;
|
|
||||||
attribute?: string;
|
|
||||||
surfaceAttribute?: string;
|
|
||||||
value?: { t: VALUE_TYPE; c: string };
|
|
||||||
};
|
|
||||||
|
|
||||||
const NOOP = "#";
|
|
||||||
let targetHref = NOOP;
|
|
||||||
$: {
|
|
||||||
if (to.entity) {
|
|
||||||
targetHref = to.entity;
|
|
||||||
} else if (to.attribute) {
|
|
||||||
api
|
|
||||||
.componentsToAddress({ t: "Attribute", c: to.attribute })
|
|
||||||
.then((address) => {
|
|
||||||
targetHref = address;
|
|
||||||
});
|
|
||||||
} else if (to.surfaceAttribute) {
|
|
||||||
targetHref = `surface:${to.surfaceAttribute}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const context = getContext("browse") as BrowseContext | undefined;
|
|
||||||
const index = context ? context.index : readable(0);
|
|
||||||
const addresses = context ? context.addresses : readable([]);
|
|
||||||
|
|
||||||
function onClick(ev: MouseEvent) {
|
|
||||||
if ($location.pathname.startsWith("/browse")) {
|
|
||||||
let newAddresses = $addresses.concat();
|
|
||||||
|
|
||||||
// Shift to append to the end instead of replacing
|
|
||||||
if (ev.shiftKey) {
|
|
||||||
newAddresses = newAddresses.concat([targetHref]);
|
|
||||||
} else {
|
|
||||||
if ($addresses[$index] !== targetHref) {
|
|
||||||
newAddresses = newAddresses.slice(0, $index + 1).concat([targetHref]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
navigate("/browse/" + newAddresses.join(","));
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
navigate(`/browse/${targetHref}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<a
|
|
||||||
class="uplink"
|
|
||||||
class:text
|
|
||||||
class:passthrough
|
|
||||||
class:unresolved={targetHref === NOOP}
|
|
||||||
href="/#/browse/{targetHref}"
|
|
||||||
on:click|preventDefault={onClick}
|
|
||||||
{title}
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
:global(.uplink) {
|
|
||||||
text-decoration: none;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
:global(.uplink.text) {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
:global(.uplink.passthrough) {
|
|
||||||
display: contents;
|
|
||||||
}
|
|
||||||
:global(.uplink.unresolved) {
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,388 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { createEventDispatcher, getContext } from "svelte";
|
|
||||||
|
|
||||||
import HashBadge from "./HashBadge.svelte";
|
|
||||||
import UpObjectLabel from "./UpObjectLabel.svelte";
|
|
||||||
import UpLink from "./UpLink.svelte";
|
|
||||||
import Icon from "../utils/Icon.svelte";
|
|
||||||
import { readable, type Readable, writable } from "svelte/store";
|
|
||||||
import { notify, UpNotification } from "../../notifications";
|
|
||||||
import IconButton from "../utils/IconButton.svelte";
|
|
||||||
import { vaultInfo } from "../../util/info";
|
|
||||||
import type { BrowseContext } from "../../util/browse";
|
|
||||||
import { Query, type UpObject } from "@upnd/upend";
|
|
||||||
import type { ADDRESS_TYPE, EntityInfo } from "@upnd/upend/types";
|
|
||||||
import { useEntity } from "../../lib/entity";
|
|
||||||
import { i18n } from "../../i18n";
|
|
||||||
import api from "../../lib/api";
|
|
||||||
import { ATTR_IN, ATTR_LABEL, HIER_ROOT_ADDR } from "@upnd/upend/constants";
|
|
||||||
import { selected } from "../EntitySelect.svelte";
|
|
||||||
import { Any } from "@upnd/upend/query";
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
|
|
||||||
export let address: string;
|
|
||||||
export let labels: string[] | undefined = undefined;
|
|
||||||
export let link = false;
|
|
||||||
export let banner = false;
|
|
||||||
export let resolve = !(labels || []).length || banner;
|
|
||||||
export let backpath = 0;
|
|
||||||
export let select = true;
|
|
||||||
export let plain = false;
|
|
||||||
|
|
||||||
let entity: Readable<UpObject> = readable(undefined);
|
|
||||||
let entityInfo: Readable<EntityInfo> = writable(undefined);
|
|
||||||
$: if (resolve) ({ entity, entityInfo } = useEntity(address));
|
|
||||||
$: if (!resolve)
|
|
||||||
entityInfo = readable(undefined, (set) => {
|
|
||||||
api.addressToComponents(address).then((info) => {
|
|
||||||
set(info);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
let hasFile = false;
|
|
||||||
$: {
|
|
||||||
if ($entityInfo?.t == "Hash" && banner) {
|
|
||||||
fetch(api.getRaw(address), {
|
|
||||||
method: "HEAD",
|
|
||||||
}).then((response) => {
|
|
||||||
hasFile = response.ok;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Identification
|
|
||||||
let inferredIds: string[] = [];
|
|
||||||
$: inferredIds = $entity?.identify() || [];
|
|
||||||
let addressIds: string[] = [];
|
|
||||||
$: resolving = inferredIds.concat(labels || []).length == 0 && !$entity;
|
|
||||||
|
|
||||||
$: fetchAddressLabels(address);
|
|
||||||
|
|
||||||
async function fetchAddressLabels(address: string) {
|
|
||||||
addressIds = [];
|
|
||||||
await Promise.all(
|
|
||||||
(["Hash", "Uuid", "Attribute", "Url"] as ADDRESS_TYPE[]).map(
|
|
||||||
async (t) => {
|
|
||||||
if ((await api.getAddress(t)) == address) {
|
|
||||||
addressIds.push(`∈ ${t}`);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
addressIds = addressIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
let displayLabel = address;
|
|
||||||
$: {
|
|
||||||
const allLabels = []
|
|
||||||
.concat(inferredIds)
|
|
||||||
.concat(addressIds)
|
|
||||||
.concat(labels || []);
|
|
||||||
displayLabel = Array.from(new Set(allLabels)).join(" | ");
|
|
||||||
|
|
||||||
if (!displayLabel && $entityInfo?.t === "Attribute") {
|
|
||||||
displayLabel = `${$entityInfo.c}`;
|
|
||||||
}
|
|
||||||
displayLabel = displayLabel || address;
|
|
||||||
}
|
|
||||||
|
|
||||||
$: dispatch("resolved", inferredIds);
|
|
||||||
|
|
||||||
// Resolved backpath
|
|
||||||
let resolvedBackpath: string[] = [];
|
|
||||||
$: if (backpath) resolveBackpath();
|
|
||||||
|
|
||||||
async function resolveBackpath() {
|
|
||||||
resolvedBackpath = [];
|
|
||||||
let levels = 0;
|
|
||||||
let current = address;
|
|
||||||
while (levels < backpath && current !== HIER_ROOT_ADDR) {
|
|
||||||
const parent = await api.query(
|
|
||||||
Query.matches(`@${current}`, ATTR_IN, Any),
|
|
||||||
);
|
|
||||||
if (parent.entries.length) {
|
|
||||||
current = parent.entries[0].value.c as string;
|
|
||||||
const label = await api.query(
|
|
||||||
Query.matches(`@${current}`, ATTR_LABEL, Any),
|
|
||||||
);
|
|
||||||
if (label.entries.length) {
|
|
||||||
resolvedBackpath = [
|
|
||||||
label.entries[0].value.c as string,
|
|
||||||
...resolvedBackpath,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
levels++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigation highlights
|
|
||||||
const context = getContext("browse") as BrowseContext | undefined;
|
|
||||||
const index = context?.index || undefined;
|
|
||||||
const addresses = context?.addresses || readable([]);
|
|
||||||
|
|
||||||
// Native open
|
|
||||||
function nativeOpen() {
|
|
||||||
notify.emit(
|
|
||||||
"notification",
|
|
||||||
new UpNotification(
|
|
||||||
$i18n.t("Opening {{identity}} in a default native application...", {
|
|
||||||
identity: inferredIds[0] || address,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
api
|
|
||||||
.nativeOpen(address)
|
|
||||||
.then(async (response) => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`${response.statusText} - ${await response.text()}`);
|
|
||||||
}
|
|
||||||
if (response.headers.has("warning")) {
|
|
||||||
const warningText = response.headers
|
|
||||||
.get("warning")
|
|
||||||
.split(" ")
|
|
||||||
.slice(2)
|
|
||||||
.join(" ");
|
|
||||||
notify.emit(
|
|
||||||
"notification",
|
|
||||||
new UpNotification(warningText, "warning"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
notify.emit(
|
|
||||||
"notification",
|
|
||||||
new UpNotification(
|
|
||||||
$i18n.t("Failed to open in native application! ({{err}})", { err }),
|
|
||||||
"error",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="upobject"
|
|
||||||
class:left-active={address == $addresses[$index - 1]}
|
|
||||||
class:right-active={address == $addresses[$index + 1]}
|
|
||||||
class:selected={select && $selected.includes(address)}
|
|
||||||
class:plain
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="address"
|
|
||||||
class:identified={inferredIds.length || addressIds.length || labels?.length}
|
|
||||||
class:banner
|
|
||||||
class:show-type={$entityInfo?.t === "Url" && !addressIds.length}
|
|
||||||
>
|
|
||||||
<HashBadge {address} />
|
|
||||||
<div class="separator" />
|
|
||||||
<div class="label" class:resolving title={displayLabel}>
|
|
||||||
<div class="label-inner">
|
|
||||||
{#if banner && hasFile}
|
|
||||||
<UpObjectLabel label={displayLabel} backpath={resolvedBackpath} />
|
|
||||||
{:else if link}
|
|
||||||
<UpLink to={{ entity: address }}>
|
|
||||||
<UpObjectLabel label={displayLabel} backpath={resolvedBackpath} />
|
|
||||||
</UpLink>
|
|
||||||
{:else}
|
|
||||||
<UpObjectLabel label={displayLabel} backpath={resolvedBackpath} />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if $entity?.get("KEY")}
|
|
||||||
<div class="key">{$entity.get("KEY")}</div>
|
|
||||||
{/if}
|
|
||||||
<div class="secondary">
|
|
||||||
<div class="type">
|
|
||||||
{$entityInfo?.t}
|
|
||||||
{#if $entityInfo?.t === "Url" || $entityInfo?.t === "Attribute"}
|
|
||||||
— {$entityInfo.c}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{#if banner}
|
|
||||||
{#if $entityInfo?.t === "Attribute"}
|
|
||||||
<div class="icon">
|
|
||||||
<UpLink
|
|
||||||
to={{ surfaceAttribute: $entityInfo.c }}
|
|
||||||
title={$i18n.t("Open on surface")}
|
|
||||||
>
|
|
||||||
<Icon name="cross" />
|
|
||||||
</UpLink>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if $entityInfo?.t == "Hash"}
|
|
||||||
<div
|
|
||||||
class="icon"
|
|
||||||
title={hasFile
|
|
||||||
? $i18n.t("Download as file")
|
|
||||||
: $i18n.t("File not present in vault")}
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
class="link-button"
|
|
||||||
class:disabled={!hasFile}
|
|
||||||
href="{api.apiUrl}/raw/{address}"
|
|
||||||
download={inferredIds[0]}
|
|
||||||
>
|
|
||||||
<Icon name="download" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{#if $vaultInfo?.desktop && hasFile}
|
|
||||||
<div class="icon">
|
|
||||||
<IconButton
|
|
||||||
name="window-alt"
|
|
||||||
on:click={nativeOpen}
|
|
||||||
title={$i18n.t("Open in default application...")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
@use "../../styles/colors";
|
|
||||||
|
|
||||||
.upobject {
|
|
||||||
border-radius: 4px;
|
|
||||||
|
|
||||||
&.left-active {
|
|
||||||
background: linear-gradient(90deg, colors.$orange 0%, transparent 100%);
|
|
||||||
padding: 2px 0 2px 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.right-active {
|
|
||||||
background: linear-gradient(90deg, transparent 0%, colors.$orange 100%);
|
|
||||||
padding: 2px 2px 2px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.plain .address {
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.address {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
padding: 0.1em 0.25em;
|
|
||||||
|
|
||||||
font-family: var(--monospace-font);
|
|
||||||
line-break: anywhere;
|
|
||||||
|
|
||||||
background: var(--background-lighter);
|
|
||||||
border: 0.1em solid var(--foreground-lighter);
|
|
||||||
border-radius: 0.2em;
|
|
||||||
|
|
||||||
&.banner {
|
|
||||||
border: 0.12em solid var(--foreground);
|
|
||||||
padding: 0.5em 0.25em;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.identified {
|
|
||||||
font-family: var(--default-font);
|
|
||||||
font-size: 0.95em;
|
|
||||||
line-break: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: baseline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label-inner {
|
|
||||||
max-width: 100%;
|
|
||||||
margin-right: 0.25em;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.banner .label {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.secondary {
|
|
||||||
font-size: 0.66em;
|
|
||||||
display: none;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.key {
|
|
||||||
font-family: var(--monospace-font);
|
|
||||||
color: colors.$yellow;
|
|
||||||
opacity: 0.8;
|
|
||||||
|
|
||||||
&:before {
|
|
||||||
content: "⌘";
|
|
||||||
margin-right: 0.1em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.banner .key {
|
|
||||||
font-size: 0.66em;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:not(.banner) .key {
|
|
||||||
flex-grow: 1;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.show-type .secondary,
|
|
||||||
&.banner .secondary {
|
|
||||||
display: unset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
flex-grow: 1;
|
|
||||||
min-width: 0;
|
|
||||||
|
|
||||||
:global(a) {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.separator {
|
|
||||||
width: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
margin: 0 0.1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resolving {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-button {
|
|
||||||
opacity: 0.66;
|
|
||||||
transition:
|
|
||||||
opacity 0.2s,
|
|
||||||
color 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
opacity: 1;
|
|
||||||
color: var(--active-color, var(--primary));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.upobject {
|
|
||||||
transition:
|
|
||||||
margin 0.2s ease,
|
|
||||||
box-shadow 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected {
|
|
||||||
margin: 0.12rem;
|
|
||||||
box-shadow: 0 0 0.1rem 0.11rem colors.$red;
|
|
||||||
}
|
|
||||||
|
|
||||||
.disabled {
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,59 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import HashBadge from "./HashBadge.svelte";
|
|
||||||
import UpLink from "./UpLink.svelte";
|
|
||||||
import BlobPreview from "./BlobPreview.svelte";
|
|
||||||
import UpObject from "./UpObject.svelte";
|
|
||||||
|
|
||||||
export let address: string;
|
|
||||||
export let labels: string[] | undefined = undefined;
|
|
||||||
export let thumbnail = true;
|
|
||||||
export let banner = true;
|
|
||||||
export let select = true;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="upobjectcard">
|
|
||||||
<UpLink to={{ entity: address }} passthrough>
|
|
||||||
<div class="inner">
|
|
||||||
{#if thumbnail}
|
|
||||||
<BlobPreview {address} />
|
|
||||||
{:else}
|
|
||||||
<div class="badge">
|
|
||||||
<HashBadge {address} />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="label">
|
|
||||||
<UpObject {address} {labels} {banner} {select} on:resolved />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UpLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.upobjectcard {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-end;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
.inner {
|
|
||||||
border: 1px solid var(--foreground-lighter);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.25rem;
|
|
||||||
max-height: 420px;
|
|
||||||
min-height: 0;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
font-size: 3rem;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,48 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import Ellipsis from "../utils/Ellipsis.svelte";
|
|
||||||
|
|
||||||
export let label: string;
|
|
||||||
export let backpath: string[] = [];
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="upobject-label">
|
|
||||||
<Ellipsis value={label}>
|
|
||||||
{#if backpath.length}
|
|
||||||
<span class="backpath">
|
|
||||||
{#each backpath as component}
|
|
||||||
<span class="component">
|
|
||||||
{component}
|
|
||||||
</span>
|
|
||||||
{/each}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
<span class="label">
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
</Ellipsis>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.upobject-label {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.backpath {
|
|
||||||
opacity: 0.66;
|
|
||||||
margin-right: 0.25em;
|
|
||||||
|
|
||||||
.component::after {
|
|
||||||
content: "∋";
|
|
||||||
margin-left: 0.2em;
|
|
||||||
margin-right: 0.4em;
|
|
||||||
font-size: 0.66em;
|
|
||||||
font-weight: bold;
|
|
||||||
position: relative;
|
|
||||||
top: -0.125em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.component:last-child::after {
|
|
||||||
content: "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,75 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { useEntity } from "../../../lib/entity";
|
|
||||||
import api from "../../../lib/api";
|
|
||||||
import { createEventDispatcher } from "svelte";
|
|
||||||
import { formatDuration } from "../../../util/fragments/time";
|
|
||||||
import { concurrentImage } from "../../imageQueue";
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
|
|
||||||
export let address: string;
|
|
||||||
|
|
||||||
$: ({ entity } = useEntity(address));
|
|
||||||
|
|
||||||
let loaded = null;
|
|
||||||
let handled = true;
|
|
||||||
$: dispatch("handled", handled);
|
|
||||||
$: dispatch("loaded", Boolean(loaded));
|
|
||||||
|
|
||||||
let clientHeight = 0;
|
|
||||||
let clientWidth = 0;
|
|
||||||
$: fontSize = Math.min(clientHeight, clientWidth) * 0.66;
|
|
||||||
let mediaDuration = "";
|
|
||||||
$: {
|
|
||||||
let duration = $entity?.get("MEDIA_DURATION") as number | undefined;
|
|
||||||
if (duration) {
|
|
||||||
mediaDuration = formatDuration(duration);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="audiopreview" bind:clientWidth bind:clientHeight>
|
|
||||||
<img
|
|
||||||
class:loaded={loaded === address}
|
|
||||||
alt="Thumbnail for {address}"
|
|
||||||
use:concurrentImage={`${api.apiUrl}/thumb/${address}?mime=audio`}
|
|
||||||
on:load={() => (loaded = address)}
|
|
||||||
on:error
|
|
||||||
/>
|
|
||||||
{#if mediaDuration}
|
|
||||||
<div class="duration" style="--font-size: {fontSize}px">
|
|
||||||
{mediaDuration}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.audiopreview {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
&:not(.loaded) {
|
|
||||||
flex-grow: 1;
|
|
||||||
height: 6rem;
|
|
||||||
max-height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.duration {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
|
|
||||||
font-size: var(--font-size);
|
|
||||||
font-weight: bold;
|
|
||||||
color: var(--foreground-lightest);
|
|
||||||
text-shadow: 0px 0px 0.2em var(--background-lighter);
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,448 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { debounce, throttle } from "lodash";
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import type { IValue } from "@upnd/upend/types";
|
|
||||||
import type WaveSurfer from "wavesurfer.js";
|
|
||||||
import type { Region, RegionParams } from "wavesurfer.js/src/plugin/regions";
|
|
||||||
import api from "../../../lib/api";
|
|
||||||
import { TimeFragment } from "../../../util/fragments/time";
|
|
||||||
import Icon from "../../utils/Icon.svelte";
|
|
||||||
import Selector from "../../utils/Selector.svelte";
|
|
||||||
import UpObject from "../../display/UpObject.svelte";
|
|
||||||
import Spinner from "../../utils/Spinner.svelte";
|
|
||||||
import IconButton from "../../../components/utils/IconButton.svelte";
|
|
||||||
import LabelBorder from "../../../components/utils/LabelBorder.svelte";
|
|
||||||
import { i18n } from "../../../i18n";
|
|
||||||
import { ATTR_LABEL } from "@upnd/upend/constants";
|
|
||||||
import debug from "debug";
|
|
||||||
const dbg = debug("kestrel:AudioViewer");
|
|
||||||
|
|
||||||
export let address: string;
|
|
||||||
export let detail: boolean;
|
|
||||||
|
|
||||||
let editable = false;
|
|
||||||
|
|
||||||
let containerEl: HTMLDivElement;
|
|
||||||
let timelineEl: HTMLDivElement;
|
|
||||||
let loaded = false;
|
|
||||||
|
|
||||||
let wavesurfer: WaveSurfer;
|
|
||||||
|
|
||||||
// Zoom handling
|
|
||||||
let zoom = 1;
|
|
||||||
const setZoom = throttle((level: number) => {
|
|
||||||
wavesurfer.zoom(level);
|
|
||||||
}, 250);
|
|
||||||
$: if (zoom && wavesurfer) setZoom(zoom);
|
|
||||||
|
|
||||||
// Annotations
|
|
||||||
const DEFAULT_ANNOTATION_COLOR = "#cb4b16";
|
|
||||||
|
|
||||||
type UpRegion = Region & { data: IValue };
|
|
||||||
let currentAnnotation: UpRegion | undefined;
|
|
||||||
|
|
||||||
async function loadAnnotations() {
|
|
||||||
const entity = await api.fetchEntity(address);
|
|
||||||
entity.backlinks
|
|
||||||
.filter((e) => e.attribute == "ANNOTATES")
|
|
||||||
.forEach(async (e) => {
|
|
||||||
const annotation = await api.fetchEntity(e.entity);
|
|
||||||
if (annotation.get("W3C_FRAGMENT_SELECTOR")) {
|
|
||||||
const fragment = TimeFragment.parse(
|
|
||||||
String(annotation.get("W3C_FRAGMENT_SELECTOR")),
|
|
||||||
);
|
|
||||||
if (fragment) {
|
|
||||||
wavesurfer.addRegion({
|
|
||||||
id: `ws-region-${e.entity}`,
|
|
||||||
color: annotation.get("COLOR") || DEFAULT_ANNOTATION_COLOR,
|
|
||||||
attributes: {
|
|
||||||
"upend-address": annotation.address,
|
|
||||||
label: annotation.get(ATTR_LABEL),
|
|
||||||
},
|
|
||||||
data: (annotation.attr["NOTE"] || [])[0]?.value,
|
|
||||||
...fragment,
|
|
||||||
} as RegionParams);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if (wavesurfer) {
|
|
||||||
if (editable) {
|
|
||||||
wavesurfer.enableDragSelection({ color: DEFAULT_ANNOTATION_COLOR });
|
|
||||||
} else {
|
|
||||||
wavesurfer.disableDragSelection();
|
|
||||||
}
|
|
||||||
Object.values(wavesurfer.regions.list).forEach((region) => {
|
|
||||||
region.update({ drag: editable, resize: editable });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateAnnotation(region: Region) {
|
|
||||||
dbg("Updating annotation %o", region);
|
|
||||||
let entity = region.attributes["upend-address"];
|
|
||||||
|
|
||||||
// Newly created
|
|
||||||
if (!entity) {
|
|
||||||
let [_, newEntity] = await api.putEntry({
|
|
||||||
entity: {
|
|
||||||
t: "Uuid",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
entity = newEntity;
|
|
||||||
|
|
||||||
const nextAnnotationIndex = Object.values(wavesurfer.regions.list).length;
|
|
||||||
const label = `Annotation #${nextAnnotationIndex}`;
|
|
||||||
|
|
||||||
region.update({
|
|
||||||
attributes: { label },
|
|
||||||
// incorrect types, `update()` does take `attributes`
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (region.attributes["label"]) {
|
|
||||||
await api.putEntityAttribute(entity, ATTR_LABEL, {
|
|
||||||
t: "String",
|
|
||||||
c: region.attributes["label"],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await api.putEntityAttribute(entity, "ANNOTATES", {
|
|
||||||
t: "Address",
|
|
||||||
c: address,
|
|
||||||
});
|
|
||||||
|
|
||||||
await api.putEntityAttribute(entity, "W3C_FRAGMENT_SELECTOR", {
|
|
||||||
t: "String",
|
|
||||||
c: new TimeFragment(region.start, region.end).toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (region.color !== DEFAULT_ANNOTATION_COLOR) {
|
|
||||||
await api.putEntityAttribute(entity, "COLOR", {
|
|
||||||
t: "String",
|
|
||||||
c: region.color,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.values(region.data).length) {
|
|
||||||
await api.putEntityAttribute(entity, "NOTE", region.data as IValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
region.update({
|
|
||||||
attributes: {
|
|
||||||
"upend-address": entity,
|
|
||||||
},
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} as any);
|
|
||||||
}
|
|
||||||
const updateAnnotationDebounced = debounce(updateAnnotation, 250);
|
|
||||||
|
|
||||||
async function deleteAnnotation(region: Region) {
|
|
||||||
if (region.attributes["upend-address"]) {
|
|
||||||
await api.deleteEntry(region.attributes["upend-address"]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let rootEl: HTMLElement;
|
|
||||||
onMount(async () => {
|
|
||||||
const WaveSurfer = await import("wavesurfer.js");
|
|
||||||
const TimelinePlugin = await import("wavesurfer.js/src/plugin/timeline");
|
|
||||||
const RegionsPlugin = await import("wavesurfer.js/src/plugin/regions");
|
|
||||||
const timelineColor = getComputedStyle(
|
|
||||||
document.documentElement,
|
|
||||||
).getPropertyValue("--foreground");
|
|
||||||
|
|
||||||
wavesurfer = WaveSurfer.default.create({
|
|
||||||
container: containerEl,
|
|
||||||
waveColor: "#dc322f",
|
|
||||||
progressColor: "#991c1a",
|
|
||||||
responsive: true,
|
|
||||||
backend: "MediaElement",
|
|
||||||
mediaControls: true,
|
|
||||||
normalize: true,
|
|
||||||
xhr: { cache: "force-cache" },
|
|
||||||
plugins: [
|
|
||||||
TimelinePlugin.default.create({
|
|
||||||
container: timelineEl,
|
|
||||||
primaryColor: timelineColor,
|
|
||||||
primaryFontColor: timelineColor,
|
|
||||||
secondaryColor: timelineColor,
|
|
||||||
secondaryFontColor: timelineColor,
|
|
||||||
}),
|
|
||||||
RegionsPlugin.default.create({}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
wavesurfer.on("ready", () => {
|
|
||||||
dbg("wavesurfer ready");
|
|
||||||
|
|
||||||
loaded = true;
|
|
||||||
loadAnnotations();
|
|
||||||
});
|
|
||||||
|
|
||||||
wavesurfer.on("region-created", async (region: UpRegion) => {
|
|
||||||
dbg("wavesurfer region-created", region);
|
|
||||||
|
|
||||||
// Updating here, because if `drag` and `resize` are passed during adding,
|
|
||||||
// updating no longer works.
|
|
||||||
region.update({ drag: editable, resize: editable });
|
|
||||||
|
|
||||||
// If the region was created from the UI
|
|
||||||
if (!region.attributes["upend-address"]) {
|
|
||||||
await updateAnnotation(region);
|
|
||||||
// currentAnnotation = region;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
wavesurfer.on("region-updated", (region: UpRegion) => {
|
|
||||||
// dbg("wavesurfer region-updated", region);
|
|
||||||
|
|
||||||
currentAnnotation = region;
|
|
||||||
});
|
|
||||||
|
|
||||||
wavesurfer.on("region-update-end", (region: UpRegion) => {
|
|
||||||
dbg("wavesurfer region-update-end", region);
|
|
||||||
|
|
||||||
updateAnnotation(region);
|
|
||||||
currentAnnotation = region;
|
|
||||||
});
|
|
||||||
|
|
||||||
wavesurfer.on("region-removed", (region: UpRegion) => {
|
|
||||||
dbg("wavesurfer region-removed", region);
|
|
||||||
|
|
||||||
currentAnnotation = null;
|
|
||||||
deleteAnnotation(region);
|
|
||||||
});
|
|
||||||
|
|
||||||
// wavesurfer.on("region-in", (region: UpRegion) => {
|
|
||||||
// dbg("wavesurfer region-in", region);
|
|
||||||
|
|
||||||
// currentAnnotation = region;
|
|
||||||
// });
|
|
||||||
|
|
||||||
// wavesurfer.on("region-out", (region: UpRegion) => {
|
|
||||||
// dbg("wavesurfer region-out", region);
|
|
||||||
|
|
||||||
// if (currentAnnotation.id === region.id) {
|
|
||||||
// currentAnnotation = undefined;
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
|
|
||||||
wavesurfer.on("region-click", (region: UpRegion, _ev: MouseEvent) => {
|
|
||||||
dbg("wavesurfer region-click", region);
|
|
||||||
|
|
||||||
currentAnnotation = region;
|
|
||||||
});
|
|
||||||
|
|
||||||
wavesurfer.on("region-dblclick", (region: UpRegion, _ev: MouseEvent) => {
|
|
||||||
dbg("wavesurfer region-dblclick", region);
|
|
||||||
|
|
||||||
currentAnnotation = region;
|
|
||||||
setTimeout(() => wavesurfer.setCurrentTime(region.start));
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const peaksReq = await fetch(
|
|
||||||
`${api.apiUrl}/thumb/${address}?mime=audio&type=json`,
|
|
||||||
);
|
|
||||||
const peaks = await peaksReq.json();
|
|
||||||
wavesurfer.load(`${api.apiUrl}/raw/${address}`, peaks.data);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`Failed to load peaks: ${e}`);
|
|
||||||
const entity = await api.fetchEntity(address);
|
|
||||||
if (
|
|
||||||
(parseInt(String(entity.get("FILE_SIZE"))) || 0) < 20_000_000 ||
|
|
||||||
confirm(
|
|
||||||
$i18n.t(
|
|
||||||
"File is large (>20 MiB) and UpEnd failed to load waveform from server. Generating the waveform locally may slow down your browser. Do you wish to proceed anyway?",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
console.warn(
|
|
||||||
`Failed to load peaks, falling back to client-side render...`,
|
|
||||||
);
|
|
||||||
|
|
||||||
wavesurfer.load(`${api.apiUrl}/raw/${address}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const drawBufferThrottled = throttle(() => wavesurfer.drawBuffer(), 200);
|
|
||||||
const resizeObserver = new ResizeObserver((_entries) => {
|
|
||||||
drawBufferThrottled();
|
|
||||||
});
|
|
||||||
resizeObserver.observe(rootEl);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="audio" class:editable bind:this={rootEl}>
|
|
||||||
{#if !loaded}
|
|
||||||
<Spinner centered />
|
|
||||||
{/if}
|
|
||||||
{#if loaded}
|
|
||||||
<header>
|
|
||||||
<IconButton
|
|
||||||
name="edit"
|
|
||||||
title={$i18n.t("Toggle Edit Mode")}
|
|
||||||
on:click={() => (editable = !editable)}
|
|
||||||
active={editable}
|
|
||||||
>
|
|
||||||
{$i18n.t("Annotate")}
|
|
||||||
</IconButton>
|
|
||||||
<div class="zoom">
|
|
||||||
<Icon name="zoom-out" />
|
|
||||||
<input type="range" min="1" max="50" bind:value={zoom} />
|
|
||||||
<Icon name="zoom-in" />
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
{/if}
|
|
||||||
<div
|
|
||||||
class="wavesurfer-timeline"
|
|
||||||
bind:this={timelineEl}
|
|
||||||
class:hidden={!detail}
|
|
||||||
/>
|
|
||||||
<div class="wavesurfer" bind:this={containerEl} />
|
|
||||||
{#if currentAnnotation}
|
|
||||||
<LabelBorder>
|
|
||||||
<span slot="header">{$i18n.t("Annotation")}</span>
|
|
||||||
{#if currentAnnotation.attributes["upend-address"]}
|
|
||||||
<UpObject
|
|
||||||
link
|
|
||||||
address={currentAnnotation.attributes["upend-address"]}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
<div class="baseControls">
|
|
||||||
<div class="regionControls">
|
|
||||||
<div class="start">
|
|
||||||
Start: <input
|
|
||||||
type="number"
|
|
||||||
value={Math.round(currentAnnotation.start * 100) / 100}
|
|
||||||
disabled={!editable}
|
|
||||||
on:input={(ev) => {
|
|
||||||
currentAnnotation.update({
|
|
||||||
start: parseInt(ev.currentTarget.value),
|
|
||||||
});
|
|
||||||
updateAnnotationDebounced(currentAnnotation);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="end">
|
|
||||||
End: <input
|
|
||||||
type="number"
|
|
||||||
value={Math.round(currentAnnotation.end * 100) / 100}
|
|
||||||
disabled={!editable}
|
|
||||||
on:input={(ev) => {
|
|
||||||
currentAnnotation.update({
|
|
||||||
end: parseInt(ev.currentTarget.value),
|
|
||||||
});
|
|
||||||
updateAnnotationDebounced(currentAnnotation);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="color">
|
|
||||||
Color: <input
|
|
||||||
type="color"
|
|
||||||
value={currentAnnotation.color || DEFAULT_ANNOTATION_COLOR}
|
|
||||||
disabled={!editable}
|
|
||||||
on:input={(ev) => {
|
|
||||||
currentAnnotation.update({ color: ev.currentTarget.value });
|
|
||||||
updateAnnotation(currentAnnotation);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{#if editable}
|
|
||||||
<div class="existControls">
|
|
||||||
<IconButton
|
|
||||||
outline
|
|
||||||
name="trash"
|
|
||||||
on:click={() => currentAnnotation.remove()}
|
|
||||||
/>
|
|
||||||
<!-- <div class="button">
|
|
||||||
<Icon name="check" />
|
|
||||||
</div> -->
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
{#key currentAnnotation}
|
|
||||||
<Selector
|
|
||||||
types={["String", "Address"]}
|
|
||||||
initial={currentAnnotation.data}
|
|
||||||
disabled={!editable}
|
|
||||||
on:input={(ev) => {
|
|
||||||
currentAnnotation.update({ data: ev.detail });
|
|
||||||
updateAnnotation(currentAnnotation);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/key}
|
|
||||||
</div>
|
|
||||||
</LabelBorder>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
@use "../../../styles/colors";
|
|
||||||
|
|
||||||
.audio {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
& > * {
|
|
||||||
flex-basis: 50%;
|
|
||||||
}
|
|
||||||
.zoom {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
input {
|
|
||||||
flex-grow: 1;
|
|
||||||
margin: 0 0.5em 1em 0.5em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.baseControls,
|
|
||||||
.content {
|
|
||||||
margin: 0.5em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.baseControls,
|
|
||||||
.regionControls,
|
|
||||||
.existControls {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.baseControls {
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regionControls div {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25em;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="number"] {
|
|
||||||
width: 6em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.audio:not(.editable) .wavesurfer-handle) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.wavesurfer-handle) {
|
|
||||||
background: var(--foreground-lightest) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.wavesurfer-region) {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,63 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { useEntity } from "../../../lib/entity";
|
|
||||||
import Spinner from "../../utils/Spinner.svelte";
|
|
||||||
export let address: string;
|
|
||||||
export let detail: boolean;
|
|
||||||
import { xywh } from "../../../util/fragments/xywh";
|
|
||||||
import { createEventDispatcher } from "svelte";
|
|
||||||
import api from "../../../lib/api";
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
|
|
||||||
const { entity } = useEntity(address);
|
|
||||||
|
|
||||||
$: objectAddress = String($entity?.get("ANNOTATES") || "");
|
|
||||||
|
|
||||||
$: imageFragment = String($entity?.get("W3C_FRAGMENT_SELECTOR")).includes(
|
|
||||||
"xywh="
|
|
||||||
);
|
|
||||||
|
|
||||||
let imageLoaded = false;
|
|
||||||
$: imageLoaded && dispatch("loaded");
|
|
||||||
$: if ($entity && !imageFragment) imageLoaded = true;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="fragment-viewer">
|
|
||||||
{#if !imageLoaded}
|
|
||||||
<Spinner />
|
|
||||||
{/if}
|
|
||||||
{#if $entity}
|
|
||||||
{#if imageFragment}
|
|
||||||
<img
|
|
||||||
class="preview-image"
|
|
||||||
class:imageLoaded
|
|
||||||
src="{api.apiUrl}/{detail ? 'raw' : 'thumb'}/{objectAddress}#{$entity?.get(
|
|
||||||
'W3C_FRAGMENT_SELECTOR'
|
|
||||||
)}"
|
|
||||||
use:xywh
|
|
||||||
alt={address}
|
|
||||||
on:load={() => (imageLoaded = true)}
|
|
||||||
draggable="false"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
@use "../../../styles/colors";
|
|
||||||
|
|
||||||
.fragment-viewer {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
max-width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
min-height: 0;
|
|
||||||
&.imageLoaded {
|
|
||||||
border: 2px dashed colors.$yellow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,322 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { IEntry } from "@upnd/upend/types";
|
|
||||||
|
|
||||||
import api from "../../../lib/api";
|
|
||||||
import { useEntity } from "../../../lib/entity";
|
|
||||||
import IconButton from "../../utils/IconButton.svelte";
|
|
||||||
import Spinner from "../../utils/Spinner.svelte";
|
|
||||||
import UpObject from "../UpObject.svelte";
|
|
||||||
import { ATTR_LABEL } from "@upnd/upend/constants";
|
|
||||||
import { i18n } from "../../../i18n";
|
|
||||||
|
|
||||||
export let address: string;
|
|
||||||
export let detail: boolean;
|
|
||||||
|
|
||||||
let editable = false;
|
|
||||||
|
|
||||||
const { entity } = useEntity(address);
|
|
||||||
|
|
||||||
let imageLoaded = false;
|
|
||||||
let imageEl: HTMLImageElement;
|
|
||||||
|
|
||||||
$: svg = Boolean($entity?.get("FILE_MIME")?.toString().includes("svg+xml"));
|
|
||||||
|
|
||||||
interface Annotorious {
|
|
||||||
addAnnotation: (a: W3cAnnotation) => void;
|
|
||||||
on: ((
|
|
||||||
e: "createAnnotation" | "deleteAnnotation",
|
|
||||||
c: (a: W3cAnnotation) => void,
|
|
||||||
) => void) &
|
|
||||||
((
|
|
||||||
e: "updateAnnotation",
|
|
||||||
c: (a: W3cAnnotation, b: W3cAnnotation) => void,
|
|
||||||
) => void);
|
|
||||||
clearAnnotations: () => void;
|
|
||||||
readOnly: boolean;
|
|
||||||
destroy: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface W3cAnnotation {
|
|
||||||
type: "Annotation";
|
|
||||||
body: Array<{ type: "TextualBody"; value: string; purpose: "commenting" }>;
|
|
||||||
target: {
|
|
||||||
selector: {
|
|
||||||
type: "FragmentSelector";
|
|
||||||
conformsTo: "http://www.w3.org/TR/media-frags/";
|
|
||||||
value: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
"@context": "http://www.w3.org/ns/anno.jsonld";
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let anno: Annotorious;
|
|
||||||
$: if (anno) anno.readOnly = !editable;
|
|
||||||
$: if (anno) {
|
|
||||||
anno.clearAnnotations();
|
|
||||||
$entity?.backlinks
|
|
||||||
.filter((e) => e.attribute == "ANNOTATES")
|
|
||||||
.forEach(async (e) => {
|
|
||||||
const annotation = await api.fetchEntity(e.entity);
|
|
||||||
if (annotation.get("W3C_FRAGMENT_SELECTOR")) {
|
|
||||||
anno.addAnnotation({
|
|
||||||
type: "Annotation",
|
|
||||||
body: annotation.attr[ATTR_LABEL].map((e) => {
|
|
||||||
return {
|
|
||||||
type: "TextualBody",
|
|
||||||
value: String(e.value.c),
|
|
||||||
purpose: "commenting",
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
target: {
|
|
||||||
selector: {
|
|
||||||
type: "FragmentSelector",
|
|
||||||
conformsTo: "http://www.w3.org/TR/media-frags/",
|
|
||||||
value: String(annotation.get("W3C_FRAGMENT_SELECTOR")),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"@context": "http://www.w3.org/ns/anno.jsonld",
|
|
||||||
id: e.entity,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
$: hasAnnotations = $entity?.backlinks.some(
|
|
||||||
(e) => e.attribute === "ANNOTATES",
|
|
||||||
);
|
|
||||||
|
|
||||||
let a8sLinkTarget: HTMLDivElement;
|
|
||||||
let a8sLinkAddress: string;
|
|
||||||
|
|
||||||
async function loaded() {
|
|
||||||
const { Annotorious } = await import("@recogito/annotorious");
|
|
||||||
|
|
||||||
if (anno) {
|
|
||||||
anno.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
anno = new Annotorious({
|
|
||||||
image: imageEl,
|
|
||||||
drawOnSingleClick: true,
|
|
||||||
fragmentUnit: "percent",
|
|
||||||
widgets: [
|
|
||||||
"COMMENT",
|
|
||||||
(info: { annotation: W3cAnnotation }) => {
|
|
||||||
a8sLinkAddress = info.annotation?.id;
|
|
||||||
return a8sLinkTarget;
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
anno.on("createAnnotation", async (annotation) => {
|
|
||||||
const [_, uuid] = await api.putEntry({
|
|
||||||
entity: {
|
|
||||||
t: "Uuid",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
annotation.id = uuid;
|
|
||||||
|
|
||||||
await api.putEntry([
|
|
||||||
{
|
|
||||||
entity: uuid,
|
|
||||||
attribute: "ANNOTATES",
|
|
||||||
value: {
|
|
||||||
t: "Address",
|
|
||||||
c: address,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
entity: uuid,
|
|
||||||
attribute: "W3C_FRAGMENT_SELECTOR",
|
|
||||||
value: {
|
|
||||||
t: "String",
|
|
||||||
c: annotation.target.selector.value,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...annotation.body.map((body) => {
|
|
||||||
return {
|
|
||||||
entity: uuid,
|
|
||||||
attribute: ATTR_LABEL,
|
|
||||||
value: {
|
|
||||||
t: "String",
|
|
||||||
c: body.value,
|
|
||||||
},
|
|
||||||
} as IEntry;
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
anno.on("updateAnnotation", async (annotation) => {
|
|
||||||
const annotationObject = await api.fetchEntity(annotation.id);
|
|
||||||
await Promise.all(
|
|
||||||
annotationObject.attr[ATTR_LABEL].concat(
|
|
||||||
annotationObject.attr["W3C_FRAGMENT_SELECTOR"],
|
|
||||||
).map(async (e) => api.deleteEntry(e.address)),
|
|
||||||
);
|
|
||||||
await api.putEntry([
|
|
||||||
{
|
|
||||||
entity: annotation.id,
|
|
||||||
attribute: "W3C_FRAGMENT_SELECTOR",
|
|
||||||
value: {
|
|
||||||
t: "String",
|
|
||||||
c: annotation.target.selector.value,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...annotation.body.map((body) => {
|
|
||||||
return {
|
|
||||||
entity: annotation.id,
|
|
||||||
attribute: ATTR_LABEL,
|
|
||||||
value: {
|
|
||||||
t: "String",
|
|
||||||
c: body.value,
|
|
||||||
},
|
|
||||||
} as IEntry;
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
anno.on("deleteAnnotation", async (annotation) => {
|
|
||||||
await api.deleteEntry(annotation.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
imageLoaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clicked() {
|
|
||||||
if (!document.fullscreenElement) {
|
|
||||||
if (!editable && !hasAnnotations) {
|
|
||||||
imageEl.requestFullscreen();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
document.exitFullscreen();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let brightnesses = [0.5, 0.75, 1, 1.25, 1.5, 2, 2.5];
|
|
||||||
let brightnessIdx = 2;
|
|
||||||
function cycleBrightness() {
|
|
||||||
brightnessIdx++;
|
|
||||||
brightnessIdx = brightnessIdx % brightnesses.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
let contrasts = [0.5, 0.75, 1, 1.25, 1.5];
|
|
||||||
let contrastsIdx = 2;
|
|
||||||
function cycleContrast() {
|
|
||||||
contrastsIdx++;
|
|
||||||
contrastsIdx = contrastsIdx % contrasts.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
$: {
|
|
||||||
if (imageEl) {
|
|
||||||
const brightness = brightnesses[brightnessIdx];
|
|
||||||
const contrast = contrasts[contrastsIdx];
|
|
||||||
imageEl.style.filter = `brightness(${brightness}) contrast(${contrast})`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="image-viewer">
|
|
||||||
{#if !imageLoaded}
|
|
||||||
<Spinner centered />
|
|
||||||
{/if}
|
|
||||||
{#if imageLoaded}
|
|
||||||
<div class="toolbar">
|
|
||||||
<IconButton
|
|
||||||
name="edit"
|
|
||||||
on:click={() => (editable = !editable)}
|
|
||||||
active={editable}
|
|
||||||
>
|
|
||||||
{$i18n.t("Annotate")}
|
|
||||||
</IconButton>
|
|
||||||
<div class="image-controls">
|
|
||||||
<IconButton name="brightness-half" on:click={cycleBrightness}>
|
|
||||||
{$i18n.t("Brightness")}
|
|
||||||
</IconButton>
|
|
||||||
<IconButton name="tone" on:click={cycleContrast}>
|
|
||||||
{$i18n.t("Contrast")}
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div
|
|
||||||
class="image"
|
|
||||||
class:zoomable={!editable && !hasAnnotations}
|
|
||||||
on:click={clicked}
|
|
||||||
on:keydown={(ev) => {
|
|
||||||
if (ev.key === "Enter") clicked();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
class="preview-image"
|
|
||||||
src="{api.apiUrl}/{detail || svg ? 'raw' : 'thumb'}/{address}"
|
|
||||||
alt={address}
|
|
||||||
on:load={loaded}
|
|
||||||
bind:this={imageEl}
|
|
||||||
draggable="false"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="a8sUpLink" bind:this={a8sLinkTarget}>
|
|
||||||
{#if a8sLinkAddress}
|
|
||||||
<div class="link">
|
|
||||||
<UpObject link address={a8sLinkAddress} />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style global lang="scss">
|
|
||||||
@use "@recogito/annotorious/dist/annotorious.min.css";
|
|
||||||
|
|
||||||
.image-viewer {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 0;
|
|
||||||
|
|
||||||
.image {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 0;
|
|
||||||
|
|
||||||
& > *,
|
|
||||||
img {
|
|
||||||
min-width: 0;
|
|
||||||
max-width: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
max-height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
margin: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
|
|
||||||
.image-controls {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.zoomable {
|
|
||||||
cursor: zoom-in;
|
|
||||||
}
|
|
||||||
|
|
||||||
img:fullscreen {
|
|
||||||
cursor: zoom-out;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.r6o-editor {
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.a8sUpLink {
|
|
||||||
text-align: initial;
|
|
||||||
|
|
||||||
.link {
|
|
||||||
margin: 0.5em 1em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,86 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { createEventDispatcher, onMount } from "svelte";
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
|
|
||||||
export let src: string;
|
|
||||||
export let lookonly = false;
|
|
||||||
|
|
||||||
let root: HTMLElement;
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
root.style.height = `${root.clientWidth}px`;
|
|
||||||
|
|
||||||
const THREE = await import("three");
|
|
||||||
const THREE_OC = await import("three/examples/jsm/controls/OrbitControls");
|
|
||||||
const THREE_STL = await import("three/examples/jsm/loaders/STLLoader");
|
|
||||||
|
|
||||||
const camera = new THREE.PerspectiveCamera(
|
|
||||||
70,
|
|
||||||
root.clientWidth / root.clientHeight,
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
|
||||||
renderer.setSize(root.clientWidth, root.clientHeight);
|
|
||||||
root.appendChild(renderer.domElement);
|
|
||||||
|
|
||||||
const controls = new THREE_OC.OrbitControls(camera, renderer.domElement);
|
|
||||||
controls.enableDamping = true;
|
|
||||||
controls.dampingFactor = 0.1;
|
|
||||||
controls.enableZoom = true;
|
|
||||||
controls.autoRotate = true;
|
|
||||||
controls.autoRotateSpeed = 3;
|
|
||||||
|
|
||||||
const scene = new THREE.Scene();
|
|
||||||
scene.add(new THREE.HemisphereLight(0xffffff, 1.5));
|
|
||||||
|
|
||||||
const loader = new THREE_STL.STLLoader();
|
|
||||||
loader.load(src, (geometry) => {
|
|
||||||
const material = new THREE.MeshPhongMaterial({
|
|
||||||
color: 0xdc322f,
|
|
||||||
specular: 100,
|
|
||||||
shininess: 70,
|
|
||||||
});
|
|
||||||
const mesh = new THREE.Mesh(geometry, material);
|
|
||||||
scene.add(mesh);
|
|
||||||
|
|
||||||
const middle = new THREE.Vector3();
|
|
||||||
geometry.computeBoundingBox();
|
|
||||||
geometry.boundingBox.getCenter(middle);
|
|
||||||
mesh.geometry.applyMatrix4(
|
|
||||||
new THREE.Matrix4().makeTranslation(-middle.x, -middle.y, -middle.z),
|
|
||||||
);
|
|
||||||
mesh.geometry.applyMatrix4(
|
|
||||||
new THREE.Matrix4().makeRotationX(-Math.PI / 2),
|
|
||||||
);
|
|
||||||
|
|
||||||
const largestDimension = Math.max(
|
|
||||||
geometry.boundingBox.max.x,
|
|
||||||
geometry.boundingBox.max.y,
|
|
||||||
geometry.boundingBox.max.z,
|
|
||||||
);
|
|
||||||
camera.position.z = largestDimension * 2;
|
|
||||||
});
|
|
||||||
|
|
||||||
function animate() {
|
|
||||||
controls.update();
|
|
||||||
renderer.render(scene, camera);
|
|
||||||
requestAnimationFrame(animate);
|
|
||||||
}
|
|
||||||
animate();
|
|
||||||
|
|
||||||
dispatch("loaded");
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="modelviewer" class:lookonly bind:this={root} />
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.modelviewer {
|
|
||||||
width: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modelviewer.lookonly {
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,117 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import api from "../../../lib/api";
|
|
||||||
import IconButton from "../../utils/IconButton.svelte";
|
|
||||||
import Spinner from "../../utils/Spinner.svelte";
|
|
||||||
export let address: string;
|
|
||||||
|
|
||||||
let mode: "preview" | "full" | "markdown" = "preview";
|
|
||||||
|
|
||||||
$: textContent = (async () => {
|
|
||||||
const response = await api.fetchRaw(address, mode == "preview");
|
|
||||||
const text = await response.text();
|
|
||||||
if (mode === "markdown") {
|
|
||||||
const { marked } = await import("marked");
|
|
||||||
const DOMPurify = await import("dompurify");
|
|
||||||
return DOMPurify.default.sanitize(marked.parse(text));
|
|
||||||
} else {
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
["image", "preview", "Preview"],
|
|
||||||
["shape-circle", "full", "Full"],
|
|
||||||
["edit", "markdown", "Markdown"],
|
|
||||||
] as [string, typeof mode, string][];
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="text-preview">
|
|
||||||
<header class="text-header">
|
|
||||||
{#each tabs as [icon, targetMode, label]}
|
|
||||||
<div
|
|
||||||
class="tab"
|
|
||||||
class:active={mode == targetMode}
|
|
||||||
on:click={() => (mode = targetMode)}
|
|
||||||
on:keydown={(ev) => {
|
|
||||||
if (ev.key === "Enter") {
|
|
||||||
mode = targetMode;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
name={icon}
|
|
||||||
active={mode == targetMode}
|
|
||||||
on:click={() => (mode = targetMode)}
|
|
||||||
/>
|
|
||||||
<div class="label">{label}</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</header>
|
|
||||||
<div class="text" class:markdown={mode === "markdown"}>
|
|
||||||
{#await textContent}
|
|
||||||
<Spinner centered />
|
|
||||||
{:then text}
|
|
||||||
{#if mode === "markdown"}
|
|
||||||
{@html text}
|
|
||||||
{:else}
|
|
||||||
{text}{#if mode === "preview"}…{/if}
|
|
||||||
{/if}
|
|
||||||
{/await}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.text-preview {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text {
|
|
||||||
background: var(--background);
|
|
||||||
padding: 0.5em;
|
|
||||||
height: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
|
|
||||||
overflow: auto;
|
|
||||||
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid var(--foreground);
|
|
||||||
|
|
||||||
white-space: pre-wrap;
|
|
||||||
|
|
||||||
&.markdown {
|
|
||||||
white-space: unset;
|
|
||||||
|
|
||||||
:global(img) {
|
|
||||||
max-width: 75%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
|
|
||||||
.tab {
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
border: 1px solid var(--foreground);
|
|
||||||
border-bottom: 0;
|
|
||||||
border-top-left-radius: 4px;
|
|
||||||
border-top-right-radius: 4px;
|
|
||||||
|
|
||||||
padding: 0.15em;
|
|
||||||
margin: 0 0.1em;
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background: var(--background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
margin-right: 0.5em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,263 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { throttle } from "lodash";
|
|
||||||
import Spinner from "../../utils/Spinner.svelte";
|
|
||||||
import Icon from "../../utils/Icon.svelte";
|
|
||||||
import { useEntity } from "../../../lib/entity";
|
|
||||||
import { i18n } from "../../../i18n";
|
|
||||||
import { createEventDispatcher } from "svelte";
|
|
||||||
import api from "../../../lib/api";
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
|
|
||||||
export let address: string;
|
|
||||||
export let detail: boolean;
|
|
||||||
|
|
||||||
const { entity } = useEntity(address);
|
|
||||||
|
|
||||||
enum State {
|
|
||||||
LOADING = "loading",
|
|
||||||
PREVIEW = "preview",
|
|
||||||
PREVIEWING = "previewing",
|
|
||||||
PLAYING = "playing",
|
|
||||||
ERRORED = "errored",
|
|
||||||
}
|
|
||||||
let state = State.LOADING;
|
|
||||||
let supported = true;
|
|
||||||
|
|
||||||
$: if (state == State.PREVIEW) dispatch("loaded");
|
|
||||||
|
|
||||||
$: {
|
|
||||||
if ($entity && videoEl) {
|
|
||||||
const mime = $entity.get("FILE_MIME");
|
|
||||||
if (mime) {
|
|
||||||
supported = Boolean(videoEl.canPlayType(mime as string));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let videoEl: HTMLVideoElement;
|
|
||||||
let currentTime: number;
|
|
||||||
|
|
||||||
let timeCodeWidth: number;
|
|
||||||
let timeCodeLeft: string;
|
|
||||||
let timeCodeSize: string;
|
|
||||||
|
|
||||||
const seek = throttle((progress: number) => {
|
|
||||||
if (state === State.PREVIEWING && videoEl.duration) {
|
|
||||||
currentTime = videoEl.duration * progress;
|
|
||||||
|
|
||||||
if (timeCodeWidth) {
|
|
||||||
let timeCodeLeftPx = Math.min(
|
|
||||||
Math.max(videoEl.clientWidth * progress, timeCodeWidth / 2),
|
|
||||||
videoEl.clientWidth - timeCodeWidth / 2
|
|
||||||
);
|
|
||||||
timeCodeLeft = `${timeCodeLeftPx}px`;
|
|
||||||
timeCodeSize = `${videoEl.clientHeight / 9}px`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
function updatePreviewPosition(ev: MouseEvent) {
|
|
||||||
if (state === State.PREVIEW || state === State.PREVIEWING) {
|
|
||||||
state = State.PREVIEWING;
|
|
||||||
const bcr = videoEl.getBoundingClientRect();
|
|
||||||
const progress = (ev.clientX - bcr.x) / bcr.width;
|
|
||||||
seek(progress);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetPreview() {
|
|
||||||
if (state === State.PREVIEWING) {
|
|
||||||
state = State.PREVIEW;
|
|
||||||
videoEl.load();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startPlaying() {
|
|
||||||
if (detail) {
|
|
||||||
state = State.PLAYING;
|
|
||||||
videoEl.play();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="video-viewer {state}" class:detail class:unsupported={!supported}>
|
|
||||||
<div class="player" style="--icon-size: {detail ? 100 : 32}px">
|
|
||||||
{#if state === State.LOADING}
|
|
||||||
<Spinner />
|
|
||||||
{/if}
|
|
||||||
{#if state === State.LOADING || (!detail && state === State.PREVIEW)}
|
|
||||||
<!-- svelte-ignore a11y-mouse-events-have-key-events -->
|
|
||||||
<img
|
|
||||||
class="thumb"
|
|
||||||
src="{api.apiUrl}/thumb/{address}?mime=video"
|
|
||||||
alt="Preview for {address}"
|
|
||||||
loading="lazy"
|
|
||||||
on:load={() => (state = State.PREVIEW)}
|
|
||||||
on:mouseover={() => (state = State.PREVIEWING)}
|
|
||||||
on:error={() => (state = State.ERRORED)}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<!-- svelte-ignore a11y-media-has-caption -->
|
|
||||||
<video
|
|
||||||
preload={detail ? "auto" : "metadata"}
|
|
||||||
src="{api.apiUrl}/raw/{address}"
|
|
||||||
poster="{api.apiUrl}/thumb/{address}?mime=video"
|
|
||||||
on:mousemove={updatePreviewPosition}
|
|
||||||
on:mouseleave={resetPreview}
|
|
||||||
on:click|preventDefault={startPlaying}
|
|
||||||
controls={state === State.PLAYING}
|
|
||||||
bind:this={videoEl}
|
|
||||||
bind:currentTime
|
|
||||||
/>
|
|
||||||
{#if !supported}
|
|
||||||
<div class="unsupported-message">
|
|
||||||
<div class="label">
|
|
||||||
{$i18n.t("UNSUPPORTED FORMAT")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
<div class="play-icon">
|
|
||||||
<Icon plain border name="play" />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="timecode"
|
|
||||||
bind:clientWidth={timeCodeWidth}
|
|
||||||
style:left={timeCodeLeft}
|
|
||||||
style:font-size={timeCodeSize}
|
|
||||||
>
|
|
||||||
{#if videoEl?.duration && currentTime}
|
|
||||||
{#if videoEl.duration > 3600}{String(
|
|
||||||
Math.floor(currentTime / 3600)
|
|
||||||
).padStart(2, "0")}:{/if}{String(
|
|
||||||
Math.floor((currentTime % 3600) / 60)
|
|
||||||
).padStart(2, "0")}:{String(
|
|
||||||
Math.floor((currentTime % 3600) % 60)
|
|
||||||
).padStart(2, "0")}
|
|
||||||
{:else if supported}
|
|
||||||
<Spinner />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.video-viewer {
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
|
|
||||||
&,
|
|
||||||
.player {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 0;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
img,
|
|
||||||
video {
|
|
||||||
width: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
object-fit: contain;
|
|
||||||
|
|
||||||
// background: rgba(128, 128, 128, 128);
|
|
||||||
|
|
||||||
transition: filter 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-icon {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
|
|
||||||
color: white;
|
|
||||||
font-size: var(--icon-size);
|
|
||||||
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timecode {
|
|
||||||
display: none;
|
|
||||||
pointer-events: none;
|
|
||||||
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: var(--left);
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
|
|
||||||
font-feature-settings: "tnum", "zero";
|
|
||||||
font-weight: bold;
|
|
||||||
color: white;
|
|
||||||
opacity: 0.66;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.unsupported.detail {
|
|
||||||
.play-icon {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.unsupported-message {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: rgba(1, 1, 1, 0.7);
|
|
||||||
pointer-events: none;
|
|
||||||
|
|
||||||
.label {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 0;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
font-weight: bold;
|
|
||||||
|
|
||||||
color: darkred;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.loading {
|
|
||||||
.player > * {
|
|
||||||
position: absolute;
|
|
||||||
left: 50%;
|
|
||||||
top: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.standby,
|
|
||||||
&.preview {
|
|
||||||
img,
|
|
||||||
video {
|
|
||||||
filter: brightness(0.75);
|
|
||||||
}
|
|
||||||
.play-icon {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.previewing {
|
|
||||||
.timecode {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
video {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,120 +0,0 @@
|
||||||
import debug from "debug";
|
|
||||||
import { DEBUG } from "../lib/debug";
|
|
||||||
const dbg = debug("kestrel:imageQueue");
|
|
||||||
|
|
||||||
class ImageQueue {
|
|
||||||
concurrency: number;
|
|
||||||
queue: {
|
|
||||||
element: HTMLElement;
|
|
||||||
id: string;
|
|
||||||
callback: () => Promise<void>;
|
|
||||||
check?: () => boolean;
|
|
||||||
}[] = [];
|
|
||||||
active = 0;
|
|
||||||
|
|
||||||
constructor(concurrency: number) {
|
|
||||||
this.concurrency = concurrency;
|
|
||||||
}
|
|
||||||
|
|
||||||
public add(
|
|
||||||
element: HTMLImageElement,
|
|
||||||
id: string,
|
|
||||||
callback: () => Promise<void>,
|
|
||||||
check?: () => boolean,
|
|
||||||
) {
|
|
||||||
this.queue = this.queue.filter((e) => e.element !== element);
|
|
||||||
this.queue.push({ element, id, callback, check });
|
|
||||||
this.update();
|
|
||||||
}
|
|
||||||
|
|
||||||
private update() {
|
|
||||||
this.queue.sort((a, b) => {
|
|
||||||
const aBox = a.element.getBoundingClientRect();
|
|
||||||
const bBox = b.element.getBoundingClientRect();
|
|
||||||
const topDifference = aBox.top - bBox.top;
|
|
||||||
if (topDifference !== 0) {
|
|
||||||
return topDifference;
|
|
||||||
} else {
|
|
||||||
return aBox.left - bBox.left;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
while (this.active < this.concurrency && this.queue.length) {
|
|
||||||
const nextIdx = this.queue.findIndex((e) => e.check()) || 0;
|
|
||||||
const next = this.queue.splice(nextIdx, 1)[0];
|
|
||||||
dbg(`Getting ${next.id}...`);
|
|
||||||
this.active += 1;
|
|
||||||
next.element.classList.add("image-loading");
|
|
||||||
if (DEBUG.imageQueueHalt) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
next
|
|
||||||
.callback()
|
|
||||||
.then(() => {
|
|
||||||
dbg(`Loaded ${next.id}`);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
dbg(`Failed to load ${next.id}...`);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.active -= 1;
|
|
||||||
next.element.classList.remove("image-loading");
|
|
||||||
this.update();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
dbg(
|
|
||||||
"Active: %d, Queue: %O",
|
|
||||||
this.active,
|
|
||||||
this.queue.map((e) => [e.element, e.id]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageQueue = new ImageQueue(2);
|
|
||||||
|
|
||||||
export function concurrentImage(element: HTMLImageElement, src: string) {
|
|
||||||
const bbox = element.getBoundingClientRect();
|
|
||||||
let visible =
|
|
||||||
bbox.top >= 0 &&
|
|
||||||
bbox.left >= 0 &&
|
|
||||||
bbox.bottom <= window.innerHeight &&
|
|
||||||
bbox.right <= window.innerWidth;
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver((entries) => {
|
|
||||||
visible = entries.some((e) => e.isIntersecting);
|
|
||||||
});
|
|
||||||
observer.observe(element);
|
|
||||||
|
|
||||||
function queueSelf() {
|
|
||||||
element.classList.add("image-queued");
|
|
||||||
const loadSelf = () => {
|
|
||||||
element.classList.remove("image-queued");
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
if (element.src === src) {
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
element.addEventListener("load", () => {
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
element.addEventListener("error", () => {
|
|
||||||
reject();
|
|
||||||
});
|
|
||||||
element.src = src;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
imageQueue.add(element, src, loadSelf, () => visible);
|
|
||||||
}
|
|
||||||
queueSelf();
|
|
||||||
|
|
||||||
return {
|
|
||||||
update(_src: string) {
|
|
||||||
queueSelf();
|
|
||||||
},
|
|
||||||
destroy() {
|
|
||||||
observer.disconnect();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -1,106 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import Icon from "../utils/Icon.svelte";
|
|
||||||
import Jobs from "./Jobs.svelte";
|
|
||||||
import Notifications from "./Notifications.svelte";
|
|
||||||
import { i18n } from "../../i18n";
|
|
||||||
|
|
||||||
let hidden = true;
|
|
||||||
let activeJobs: number;
|
|
||||||
$: togglable = activeJobs > 0 || !hidden;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<footer id="footer" class:hidden>
|
|
||||||
<div class="notifications">
|
|
||||||
<Notifications />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="status"
|
|
||||||
class:togglable
|
|
||||||
on:click={() => (hidden = !hidden)}
|
|
||||||
on:keydown={(ev) => {
|
|
||||||
if (["Space", "Enter"].includes(ev.key)) hidden = !hidden;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div class="info">
|
|
||||||
{#if activeJobs > 0}
|
|
||||||
{$i18n.t("Active jobs:")} {activeJobs}
|
|
||||||
{:else}
|
|
||||||
{$i18n.t("No active jobs.")}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="icons">
|
|
||||||
<Icon name="{hidden ? 'up' : 'down'}-arrow" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="jobs">
|
|
||||||
<Jobs bind:active={activeJobs} />
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
footer {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
& > * {
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
background: var(--background);
|
|
||||||
border-top: 1px solid var(--foreground-lighter);
|
|
||||||
|
|
||||||
transition: 0.7s bottom ease;
|
|
||||||
|
|
||||||
--height: calc(100vh / 6);
|
|
||||||
}
|
|
||||||
|
|
||||||
footer.hidden {
|
|
||||||
bottom: calc(var(--height) * -1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
height: 2rem;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:not(.togglable) {
|
|
||||||
cursor: unset;
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0.66;
|
|
||||||
}
|
|
||||||
|
|
||||||
transition: opacity 0.7s ease;
|
|
||||||
|
|
||||||
.info {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.notifications,
|
|
||||||
.jobs {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.notifications > *:first-child) {
|
|
||||||
padding-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.jobs {
|
|
||||||
overflow-y: scroll;
|
|
||||||
height: var(--height);
|
|
||||||
|
|
||||||
padding-top: 0.5rem;
|
|
||||||
|
|
||||||
background: var(--background-lighter);
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,163 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { Link, useNavigate } from "svelte-navigator";
|
|
||||||
// import { useMatch } from "svelte-navigator";
|
|
||||||
import { addEmitter } from "../AddModal.svelte";
|
|
||||||
import Icon from "../utils/Icon.svelte";
|
|
||||||
import { jobsEmitter } from "./Jobs.svelte";
|
|
||||||
import api from "../../lib/api";
|
|
||||||
import Selector, { type SelectorValue } from "../utils/Selector.svelte";
|
|
||||||
import { i18n } from "../../i18n";
|
|
||||||
const navigate = useNavigate();
|
|
||||||
// const location = useLocation();
|
|
||||||
|
|
||||||
// const searchMatch = useMatch("/search/:query");
|
|
||||||
|
|
||||||
// let searchQuery = $searchMatch?.params.query
|
|
||||||
// ? decodeURIComponent($searchMatch?.params.query)
|
|
||||||
// : "";
|
|
||||||
// $: if (!$location.pathname.includes("search")) searchQuery = "";
|
|
||||||
|
|
||||||
let selector: Selector;
|
|
||||||
|
|
||||||
let lastSearched: SelectorValue[] = [];
|
|
||||||
|
|
||||||
function addLastSearched(value: SelectorValue) {
|
|
||||||
switch (value.t) {
|
|
||||||
case "Address":
|
|
||||||
lastSearched = lastSearched.filter(
|
|
||||||
(v) => v.t !== "Address" || v.c !== value.c,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case "Attribute":
|
|
||||||
lastSearched = lastSearched.filter(
|
|
||||||
(v) => v.t !== "Attribute" || v.name !== value.name,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
lastSearched.unshift(value);
|
|
||||||
lastSearched = lastSearched.slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onInput(event: CustomEvent<SelectorValue>) {
|
|
||||||
const value = event.detail;
|
|
||||||
if (!value) return;
|
|
||||||
|
|
||||||
switch (value.t) {
|
|
||||||
case "Address":
|
|
||||||
addLastSearched(value);
|
|
||||||
navigate(`/browse/${value.c}`);
|
|
||||||
break;
|
|
||||||
case "Attribute":
|
|
||||||
addLastSearched(value);
|
|
||||||
{
|
|
||||||
const attributeAddress = await api.componentsToAddress({
|
|
||||||
t: "Attribute",
|
|
||||||
c: value.name,
|
|
||||||
});
|
|
||||||
navigate(`/browse/${attributeAddress}`);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
selector.reset();
|
|
||||||
|
|
||||||
// searchQuery = event.detail;
|
|
||||||
|
|
||||||
// if (searchQuery.length > 0) {
|
|
||||||
// navigate(`/search/${encodeURIComponent(searchQuery)}`, {
|
|
||||||
// replace: $location.pathname.includes("search"),
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
let fileInput: HTMLInputElement;
|
|
||||||
function onFileChange() {
|
|
||||||
if (fileInput.files.length > 0) {
|
|
||||||
addEmitter.emit("files", Array.from(fileInput.files));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rescan() {
|
|
||||||
await api.refreshVault();
|
|
||||||
jobsEmitter.emit("reload");
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="header">
|
|
||||||
<h1>
|
|
||||||
<Link to="/">
|
|
||||||
<img class="logo" src="assets/upend.svg" alt="UpEnd logo" />
|
|
||||||
<div class="name">UpEnd</div>
|
|
||||||
</Link>
|
|
||||||
</h1>
|
|
||||||
<div class="input">
|
|
||||||
<Selector
|
|
||||||
types={["Address", "NewAddress", "Attribute"]}
|
|
||||||
placeholder={$i18n.t("Search or add")}
|
|
||||||
on:input={onInput}
|
|
||||||
bind:this={selector}
|
|
||||||
emptyOptions={lastSearched}
|
|
||||||
>
|
|
||||||
<Icon name="search" slot="prefix" />
|
|
||||||
</Selector>
|
|
||||||
</div>
|
|
||||||
<button class="button" on:click={() => fileInput.click()}>
|
|
||||||
<Icon name="upload" />
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
bind:this={fileInput}
|
|
||||||
on:change={onFileChange}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<button class="button" on:click={() => rescan()} title="Rescan vault">
|
|
||||||
<Icon name="refresh" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.5rem;
|
|
||||||
height: 3.5rem;
|
|
||||||
border-bottom: 1px solid var(--foreground);
|
|
||||||
|
|
||||||
background: var(--background);
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 16pt;
|
|
||||||
font-weight: normal;
|
|
||||||
margin: 0;
|
|
||||||
|
|
||||||
:global(a) {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
color: var(--foreground-lightest);
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
margin-right: 0.5em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
display: inline-block;
|
|
||||||
height: 1.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input {
|
|
||||||
flex-grow: 1;
|
|
||||||
min-width: 3rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 600px) {
|
|
||||||
.name {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,81 +0,0 @@
|
||||||
<script lang="ts" context="module">
|
|
||||||
import mitt from "mitt";
|
|
||||||
|
|
||||||
export type JobsEvents = {
|
|
||||||
reload: undefined;
|
|
||||||
};
|
|
||||||
export const jobsEmitter = mitt<JobsEvents>();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import type { IJob } from "@upnd/upend/types";
|
|
||||||
import { fade } from "svelte/transition";
|
|
||||||
import ProgessBar from "../utils/ProgessBar.svelte";
|
|
||||||
import api from "../../lib/api";
|
|
||||||
import { DEBUG } from "../../lib/debug";
|
|
||||||
|
|
||||||
interface JobWithId extends IJob {
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let jobs: IJob[] = [];
|
|
||||||
let activeJobs: JobWithId[] = [];
|
|
||||||
|
|
||||||
export let active = 0;
|
|
||||||
$: active = activeJobs.length;
|
|
||||||
|
|
||||||
let timeout: NodeJS.Timeout;
|
|
||||||
async function updateJobs() {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
if (!DEBUG.mockJobs) {
|
|
||||||
jobs = await api.fetchJobs();
|
|
||||||
} else {
|
|
||||||
jobs = Array(DEBUG.mockJobs)
|
|
||||||
.fill(0)
|
|
||||||
.map((_, i) => ({
|
|
||||||
id: i.toString(),
|
|
||||||
title: `Job ${i}`,
|
|
||||||
job_type: `JobType ${i}`,
|
|
||||||
state: "InProgress",
|
|
||||||
progress: Math.floor(Math.random() * 100),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
activeJobs = Object.entries(jobs)
|
|
||||||
.filter(([_, job]) => job.state == "InProgress")
|
|
||||||
.map(([id, job]) => {
|
|
||||||
return { id, ...job };
|
|
||||||
})
|
|
||||||
.sort((j1, j2) => j1.id.localeCompare(j2.id))
|
|
||||||
.sort((j1, j2) => (j2.job_type || "").localeCompare(j1.job_type || ""));
|
|
||||||
|
|
||||||
if (activeJobs.length) {
|
|
||||||
timeout = setTimeout(updateJobs, 500);
|
|
||||||
} else {
|
|
||||||
timeout = setTimeout(updateJobs, 5000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updateJobs();
|
|
||||||
|
|
||||||
jobsEmitter.on("reload", () => {
|
|
||||||
updateJobs();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#each activeJobs as job (job.id)}
|
|
||||||
<div class="job" transition:fade>
|
|
||||||
<div class="job-label">{job.title}</div>
|
|
||||||
<ProgessBar value={job.progress} />
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.job {
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
.job-label {
|
|
||||||
white-space: nowrap;
|
|
||||||
margin-right: 2em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,87 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type {
|
|
||||||
UpNotification,
|
|
||||||
UpNotificationLevel,
|
|
||||||
} from "../../notifications";
|
|
||||||
import { notify } from "../../notifications";
|
|
||||||
import { fade } from "svelte/transition";
|
|
||||||
import Icon from "../utils/Icon.svelte";
|
|
||||||
import { DEBUG, lipsum } from "../../lib/debug";
|
|
||||||
|
|
||||||
let notifications: UpNotification[] = [];
|
|
||||||
if (DEBUG.mockNotifications) {
|
|
||||||
notifications = [
|
|
||||||
{
|
|
||||||
id: "1",
|
|
||||||
level: "error",
|
|
||||||
content: `This is an error notification, ${lipsum(5)}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2",
|
|
||||||
level: "warning",
|
|
||||||
content: `This is a warning notification, ${lipsum(5)}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "3",
|
|
||||||
level: "info",
|
|
||||||
content: `This is an info notification, ${lipsum(5)}`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
notifications = notifications.slice(0, DEBUG.mockNotifications);
|
|
||||||
if (notifications.length < DEBUG.mockNotifications) {
|
|
||||||
notifications = [
|
|
||||||
...notifications,
|
|
||||||
...Array(DEBUG.mockNotifications - notifications.length)
|
|
||||||
.fill(0)
|
|
||||||
.map(() => ({
|
|
||||||
id: Math.random().toString(),
|
|
||||||
level: ["error", "warning", "info"][
|
|
||||||
Math.floor(Math.random() * 3)
|
|
||||||
] as UpNotificationLevel,
|
|
||||||
content: lipsum(12),
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
notifications = notifications;
|
|
||||||
}
|
|
||||||
|
|
||||||
notify.on("notification", (notification) => {
|
|
||||||
notifications.push(notification);
|
|
||||||
notifications = notifications;
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
notifications.splice(
|
|
||||||
notifications.findIndex((n) => (n.id = notification.id)),
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
notifications = notifications;
|
|
||||||
}, 5000);
|
|
||||||
});
|
|
||||||
|
|
||||||
const icons = {
|
|
||||||
error: "error-alt",
|
|
||||||
warning: "error",
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#each notifications as notification (notification.id)}
|
|
||||||
<div
|
|
||||||
class="notification notification-{notification.level || 'info'}"
|
|
||||||
transition:fade
|
|
||||||
>
|
|
||||||
<Icon name={icons[notification.level] || "bell"} />
|
|
||||||
{notification.content}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
@use "../../styles/colors";
|
|
||||||
|
|
||||||
.notification-error {
|
|
||||||
color: colors.$red;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-warning {
|
|
||||||
color: colors.$orange;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,17 +0,0 @@
|
||||||
section.labelborder {
|
|
||||||
margin-top: 0.66rem;
|
|
||||||
|
|
||||||
header {
|
|
||||||
display: flex;
|
|
||||||
align-items: end;
|
|
||||||
justify-content: space-between;
|
|
||||||
|
|
||||||
border-bottom: 1px solid var(--foreground);
|
|
||||||
padding-bottom: 0.33rem;
|
|
||||||
margin-bottom: 0.33rem;
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,96 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import Selector, {
|
|
||||||
type SELECTOR_TYPE,
|
|
||||||
type SelectorValue,
|
|
||||||
} from "./Selector.svelte";
|
|
||||||
import { createEventDispatcher } from "svelte";
|
|
||||||
import type { IValue } from "@upnd/upend/types";
|
|
||||||
import IconButton from "./IconButton.svelte";
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
|
|
||||||
export let value: IValue | undefined = undefined;
|
|
||||||
export let types: SELECTOR_TYPE[] | undefined = undefined;
|
|
||||||
let newValue: SelectorValue = value;
|
|
||||||
|
|
||||||
let editing = false;
|
|
||||||
|
|
||||||
let selector: Selector;
|
|
||||||
let hover = false;
|
|
||||||
let focus = false;
|
|
||||||
|
|
||||||
$: if (editing && selector) selector.focus();
|
|
||||||
$: if (!focus && !hover) editing = false;
|
|
||||||
|
|
||||||
function onInput(ev: CustomEvent<SelectorValue>) {
|
|
||||||
newValue = ev.detail;
|
|
||||||
selector.focus();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="editable"
|
|
||||||
class:editing
|
|
||||||
on:mouseenter={() => (hover = true)}
|
|
||||||
on:mouseleave={() => (hover = false)}
|
|
||||||
>
|
|
||||||
<div class="inner">
|
|
||||||
{#if editing}
|
|
||||||
<div
|
|
||||||
class="selector"
|
|
||||||
on:keydown={(ev) => {
|
|
||||||
if (ev.key === "Escape") {
|
|
||||||
editing = false;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Selector
|
|
||||||
{types}
|
|
||||||
bind:this={selector}
|
|
||||||
on:focus={(ev) => (focus = ev.detail)}
|
|
||||||
on:input={onInput}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<IconButton
|
|
||||||
name="save"
|
|
||||||
on:click={() => {
|
|
||||||
dispatch("edit", newValue);
|
|
||||||
editing = false;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div class="content">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
<div class="edit-icon">
|
|
||||||
<IconButton name="edit" on:click={() => (editing = true)} />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.edit-icon {
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editable:hover .edit-icon {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inner {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.25em;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selector {
|
|
||||||
flex-grow: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,15 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
export let value: string;
|
|
||||||
export let title: string | undefined = undefined;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="ellipsis" title={title || value}><slot>{value}</slot></div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.ellipsis {
|
|
||||||
max-width: 100%;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,29 +0,0 @@
|
||||||
<script lang="ts" context="module">
|
|
||||||
let loaded = false;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
export let plain = false;
|
|
||||||
export let name: string;
|
|
||||||
export let border = false;
|
|
||||||
|
|
||||||
if (!loaded) {
|
|
||||||
document.head.innerHTML += `<link
|
|
||||||
rel="stylesheet"
|
|
||||||
href="vendor/boxicons/css/boxicons.min.css"
|
|
||||||
/>`;
|
|
||||||
loaded = true;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<i class="bx bx-{name}" class:plain class:bx-border={border} />
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.bx:not(.plain) {
|
|
||||||
font-size: 115%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bx-border {
|
|
||||||
border-color: white;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,88 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import Icon from "./Icon.svelte";
|
|
||||||
|
|
||||||
export let name: string;
|
|
||||||
export let active = false;
|
|
||||||
export let disabled = false;
|
|
||||||
export let title: string | undefined = undefined;
|
|
||||||
export let outline = false;
|
|
||||||
export let subdued = false;
|
|
||||||
export let small = false;
|
|
||||||
export let plain = false;
|
|
||||||
export let color: string | undefined = "var(--active-color, var(--primary))";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<button
|
|
||||||
on:click
|
|
||||||
class:active
|
|
||||||
class:outline
|
|
||||||
class:subdued
|
|
||||||
class:small
|
|
||||||
class:plain
|
|
||||||
{disabled}
|
|
||||||
{title}
|
|
||||||
style="--color: {color}"
|
|
||||||
>
|
|
||||||
<Icon {name} />
|
|
||||||
<div class="text">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
button {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
color: inherit;
|
|
||||||
opacity: 0.66;
|
|
||||||
|
|
||||||
&.plain {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
transition:
|
|
||||||
opacity 0.2s,
|
|
||||||
color 0.2s,
|
|
||||||
border-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.subdued {
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outline {
|
|
||||||
border: 1px solid var(--foreground);
|
|
||||||
border-radius: 4px;
|
|
||||||
|
|
||||||
padding: 0.25em 1em;
|
|
||||||
&.small {
|
|
||||||
padding: 0.1em 0.8em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.active,
|
|
||||||
button:hover {
|
|
||||||
opacity: 1;
|
|
||||||
color: var(--color);
|
|
||||||
border-color: var(--color);
|
|
||||||
}
|
|
||||||
|
|
||||||
button:disabled {
|
|
||||||
color: gray;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text {
|
|
||||||
font-size: 0.5em;
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 0.2em;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,68 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { createEventDispatcher } from "svelte";
|
|
||||||
let input: HTMLInputElement;
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
|
|
||||||
export let placeholder = "";
|
|
||||||
export let value = "";
|
|
||||||
export let disabled = false;
|
|
||||||
export let size: number | undefined = 7;
|
|
||||||
|
|
||||||
let focused = false;
|
|
||||||
$: dispatch("focusChange", focused);
|
|
||||||
|
|
||||||
function onInput() {
|
|
||||||
dispatch("input", value);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function focus() {
|
|
||||||
input.focus();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="input" class:focused>
|
|
||||||
<slot name="prefix" />
|
|
||||||
<input
|
|
||||||
bind:this={input}
|
|
||||||
{placeholder}
|
|
||||||
bind:value
|
|
||||||
on:input={onInput}
|
|
||||||
on:focus={() => (focused = true)}
|
|
||||||
on:blur={() => (focused = false)}
|
|
||||||
size={Math.max(value.length, size)}
|
|
||||||
on:keydown
|
|
||||||
{disabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.input {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25em;
|
|
||||||
padding: 0.25em;
|
|
||||||
|
|
||||||
border: 1px solid var(--foreground-lighter);
|
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--background);
|
|
||||||
|
|
||||||
transition: box-shadow 0.25s;
|
|
||||||
&.focused {
|
|
||||||
box-shadow: 0 0 2px 3px var(--primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
|
||||||
flex-grow: 1;
|
|
||||||
min-width: 0;
|
|
||||||
|
|
||||||
color: var(--foreground);
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,63 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
export let hide = false;
|
|
||||||
let hidden = true;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<section class="labelborder" class:hide class:hidden>
|
|
||||||
<header
|
|
||||||
on:click={() => {
|
|
||||||
if (hide) {
|
|
||||||
hidden = !hidden;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
on:keydown={(ev) => {
|
|
||||||
if (["Space", "Enter"].includes(ev.key) && hide) hidden = !hidden;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<slot name="header-full">
|
|
||||||
<h3><slot name="header" /></h3>
|
|
||||||
</slot>
|
|
||||||
</header>
|
|
||||||
<div class="content">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
section.labelborder {
|
|
||||||
margin-top: 0.66rem;
|
|
||||||
|
|
||||||
header {
|
|
||||||
display: flex;
|
|
||||||
align-items: end;
|
|
||||||
justify-content: space-between;
|
|
||||||
|
|
||||||
border-bottom: 1px solid var(--foreground);
|
|
||||||
padding-bottom: 0.33rem;
|
|
||||||
margin-bottom: 0.33rem;
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.hide {
|
|
||||||
header {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
transition: opacity 0.2s ease-in-out;
|
|
||||||
&.hidden {
|
|
||||||
opacity: 0.66;
|
|
||||||
|
|
||||||
header {
|
|
||||||
border-bottom-width: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,60 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { debounce } from "lodash";
|
|
||||||
import { createEventDispatcher } from "svelte";
|
|
||||||
import { useEntity } from "../../lib/entity";
|
|
||||||
import type { AttributeCreate, AttributeUpdate } from "../../types/base";
|
|
||||||
import type { UpEntry } from "@upnd/upend";
|
|
||||||
import LabelBorder from "./LabelBorder.svelte";
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
|
|
||||||
export let address: string;
|
|
||||||
|
|
||||||
$: ({ entity } = useEntity(address));
|
|
||||||
|
|
||||||
let noteEntry: UpEntry | undefined;
|
|
||||||
let notes: string | undefined = undefined;
|
|
||||||
$: {
|
|
||||||
if ($entity?.attr["NOTE"]?.length && $entity?.attr["NOTE"][0]?.value?.c) {
|
|
||||||
noteEntry = $entity?.attr["NOTE"][0];
|
|
||||||
notes = String(noteEntry.value.c);
|
|
||||||
} else {
|
|
||||||
noteEntry = undefined;
|
|
||||||
notes = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let contentEl: HTMLDivElement;
|
|
||||||
|
|
||||||
const update = debounce(() => {
|
|
||||||
if (noteEntry) {
|
|
||||||
dispatch("change", {
|
|
||||||
type: "update",
|
|
||||||
address: noteEntry.address,
|
|
||||||
attribute: "NOTE",
|
|
||||||
value: { t: "String", c: contentEl.innerText },
|
|
||||||
} as AttributeUpdate);
|
|
||||||
} else {
|
|
||||||
dispatch("change", {
|
|
||||||
type: "create",
|
|
||||||
address: address,
|
|
||||||
attribute: "NOTE",
|
|
||||||
value: { t: "String", c: contentEl.innerText },
|
|
||||||
} as AttributeCreate);
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<LabelBorder hide={!notes?.length}>
|
|
||||||
<span slot="header">Notes</span>
|
|
||||||
<div class="notes" contenteditable on:input={update} bind:this={contentEl}>
|
|
||||||
{notes ? notes : ""}
|
|
||||||
</div>
|
|
||||||
</LabelBorder>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.notes {
|
|
||||||
background: var(--background);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.5em !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,71 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
export let value: number | undefined = undefined;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="progress-bar"
|
|
||||||
class:indeterminate={value == undefined}
|
|
||||||
style="--value: {value}%"
|
|
||||||
>
|
|
||||||
<div class="value" />
|
|
||||||
<div class="label">
|
|
||||||
<slot>
|
|
||||||
{value ? Math.round(value) : "?"}%
|
|
||||||
</slot>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.progress-bar {
|
|
||||||
width: 100%;
|
|
||||||
height: 1em;
|
|
||||||
background: white;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.value {
|
|
||||||
background: var(--primary);
|
|
||||||
height: 100%;
|
|
||||||
width: var(--value, 100%);
|
|
||||||
transition: width 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar,
|
|
||||||
.value {
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
font-size: 0.8em;
|
|
||||||
color: white;
|
|
||||||
z-index: 9;
|
|
||||||
position: absolute;
|
|
||||||
left: 50%;
|
|
||||||
top: 0;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
text-align: center;
|
|
||||||
font-weight: bold;
|
|
||||||
mix-blend-mode: difference;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar.indeterminate {
|
|
||||||
.value {
|
|
||||||
animation-name: indeterminate;
|
|
||||||
animation-duration: 2s;
|
|
||||||
animation-iteration-count: infinite;
|
|
||||||
animation-timing-function: ease-in-out;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes indeterminate {
|
|
||||||
0% {
|
|
||||||
background-color: var(--primary);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
background-color: var(--primary-lighter);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-color: var(--primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,585 +0,0 @@
|
||||||
<script lang="ts" context="module">
|
|
||||||
import type { IValue } from "@upnd/upend/types";
|
|
||||||
import type { UpEntry } from "@upnd/upend";
|
|
||||||
import UpEntryComponent from "../display/UpEntry.svelte";
|
|
||||||
|
|
||||||
export type SELECTOR_TYPE =
|
|
||||||
| "Address"
|
|
||||||
| "LabelledAddress"
|
|
||||||
| "NewAddress"
|
|
||||||
| "Attribute"
|
|
||||||
| "NewAttribute"
|
|
||||||
| "String"
|
|
||||||
| "Number"
|
|
||||||
| "Null";
|
|
||||||
|
|
||||||
export type SelectorValue = {
|
|
||||||
t: SELECTOR_TYPE;
|
|
||||||
} & (
|
|
||||||
| {
|
|
||||||
t: "Address";
|
|
||||||
c: Address;
|
|
||||||
entry?: UpEntry;
|
|
||||||
labels?: string[];
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
t: "Attribute";
|
|
||||||
name: string;
|
|
||||||
labels?: string[];
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
t: "String";
|
|
||||||
c: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
t: "Number";
|
|
||||||
c: number;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
t: "Null";
|
|
||||||
c: null;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export type SelectorOption =
|
|
||||||
| SelectorValue
|
|
||||||
| { t: "NewAddress"; c: string }
|
|
||||||
| { t: "NewAttribute"; name: string; label: string };
|
|
||||||
|
|
||||||
export async function selectorValueAsValue(
|
|
||||||
value: SelectorValue,
|
|
||||||
): Promise<IValue> {
|
|
||||||
switch (value.t) {
|
|
||||||
case "Address":
|
|
||||||
return {
|
|
||||||
t: "Address",
|
|
||||||
c: value.c,
|
|
||||||
};
|
|
||||||
case "Attribute":
|
|
||||||
return {
|
|
||||||
t: "Address",
|
|
||||||
c: await api.componentsToAddress({ t: "Attribute", c: value.name }),
|
|
||||||
};
|
|
||||||
case "String":
|
|
||||||
return {
|
|
||||||
t: "String",
|
|
||||||
c: value.c,
|
|
||||||
};
|
|
||||||
case "Number":
|
|
||||||
return {
|
|
||||||
t: "Number",
|
|
||||||
c: value.c,
|
|
||||||
};
|
|
||||||
case "Null":
|
|
||||||
return {
|
|
||||||
t: "Null",
|
|
||||||
c: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { debounce } from "lodash";
|
|
||||||
import { createEventDispatcher } from "svelte";
|
|
||||||
import type { UpListing } from "@upnd/upend";
|
|
||||||
import type { Address } from "@upnd/upend/types";
|
|
||||||
import { baseSearchOnce, createLabelled } from "../../util/search";
|
|
||||||
import UpObject from "../display/UpObject.svelte";
|
|
||||||
import IconButton from "./IconButton.svelte";
|
|
||||||
import Input from "./Input.svelte";
|
|
||||||
import { matchSorter } from "match-sorter";
|
|
||||||
import api from "../../lib/api";
|
|
||||||
import { ATTR_LABEL } from "@upnd/upend/constants";
|
|
||||||
import { i18n } from "../../i18n";
|
|
||||||
import debug from "debug";
|
|
||||||
import Spinner from "./Spinner.svelte";
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
const dbg = debug("kestrel:Selector");
|
|
||||||
let selectorEl: HTMLElement;
|
|
||||||
|
|
||||||
export let MAX_OPTIONS = 25;
|
|
||||||
|
|
||||||
export let types: SELECTOR_TYPE[] = [
|
|
||||||
"Address",
|
|
||||||
"NewAddress",
|
|
||||||
"Attribute",
|
|
||||||
"String",
|
|
||||||
"Number",
|
|
||||||
];
|
|
||||||
export let attributeOptions: string[] | undefined = undefined;
|
|
||||||
export let emptyOptions: SelectorOption[] | undefined = undefined;
|
|
||||||
export let placeholder = "";
|
|
||||||
export let disabled = false;
|
|
||||||
export let keepFocusOnSet = false;
|
|
||||||
|
|
||||||
export let initial: SelectorValue | undefined = undefined;
|
|
||||||
let inputValue = "";
|
|
||||||
let updating = false;
|
|
||||||
|
|
||||||
$: setInitial(initial);
|
|
||||||
function setInitial(initial: SelectorValue | undefined) {
|
|
||||||
if (initial) {
|
|
||||||
switch (initial.t) {
|
|
||||||
case "Address":
|
|
||||||
case "String":
|
|
||||||
inputValue = initial.c;
|
|
||||||
break;
|
|
||||||
case "Attribute":
|
|
||||||
inputValue = initial.name;
|
|
||||||
break;
|
|
||||||
case "Number":
|
|
||||||
inputValue = String(initial.c);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let current:
|
|
||||||
| (SelectorOption & { t: "Address" | "Attribute" | "String" | "Number" })
|
|
||||||
| undefined = undefined;
|
|
||||||
|
|
||||||
export function reset() {
|
|
||||||
inputValue = "";
|
|
||||||
current = undefined;
|
|
||||||
dispatch("input", current);
|
|
||||||
}
|
|
||||||
|
|
||||||
let options: SelectorOption[] = [];
|
|
||||||
let searchResult: UpListing | undefined = undefined;
|
|
||||||
const updateOptions = debounce(async (query: string, doSearch: boolean) => {
|
|
||||||
updating = true;
|
|
||||||
let result: SelectorOption[] = [];
|
|
||||||
|
|
||||||
if (query.length === 0 && emptyOptions !== undefined) {
|
|
||||||
options = emptyOptions;
|
|
||||||
updating = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (types.includes("Number")) {
|
|
||||||
const number = parseFloat(query);
|
|
||||||
if (!Number.isNaN(number)) {
|
|
||||||
result.push({
|
|
||||||
t: "Number",
|
|
||||||
c: number,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (types.includes("String") && query.length) {
|
|
||||||
result.push({
|
|
||||||
t: "String",
|
|
||||||
c: query,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
options = result;
|
|
||||||
|
|
||||||
if (types.includes("Address") || types.includes("LabelledAddress")) {
|
|
||||||
if (doSearch) {
|
|
||||||
if (emptyOptions === undefined || query.length > 0) {
|
|
||||||
searchResult = await baseSearchOnce(query);
|
|
||||||
} else {
|
|
||||||
searchResult = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let exactHits = Object.entries(addressToLabels)
|
|
||||||
.filter(([_, labels]) =>
|
|
||||||
labels.map((l) => l.toLowerCase()).includes(query.toLowerCase()),
|
|
||||||
)
|
|
||||||
.map(([addr, _]) => addr);
|
|
||||||
|
|
||||||
if (exactHits.length) {
|
|
||||||
exactHits.forEach((addr) =>
|
|
||||||
result.push({
|
|
||||||
t: "Address",
|
|
||||||
c: addr,
|
|
||||||
labels: addressToLabels[addr],
|
|
||||||
entry: null,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} else if (query.length && types.includes("NewAddress")) {
|
|
||||||
result.push({
|
|
||||||
t: "NewAddress",
|
|
||||||
c: query,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let validOptions = (searchResult?.entries || []).filter(
|
|
||||||
(e) => !exactHits.includes(e.entity),
|
|
||||||
);
|
|
||||||
// only includes LabelledAddress
|
|
||||||
if (!types.includes("Address")) {
|
|
||||||
validOptions = validOptions.filter((e) => e.attribute == ATTR_LABEL);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortedOptions = matchSorter(validOptions, inputValue, {
|
|
||||||
keys: ["value.c", (i) => addressToLabels[i.entity]?.join(" ")],
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const entry of sortedOptions) {
|
|
||||||
const common = {
|
|
||||||
t: "Address" as const,
|
|
||||||
c: entry.entity,
|
|
||||||
};
|
|
||||||
if (entry.attribute == ATTR_LABEL) {
|
|
||||||
result.push({
|
|
||||||
...common,
|
|
||||||
labels: [entry.value.c.toString()],
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
result.push({ ...common, entry });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (types.includes("Attribute")) {
|
|
||||||
const allAttributes = await api.fetchAllAttributes();
|
|
||||||
const attributes = attributeOptions
|
|
||||||
? allAttributes.filter((attr) => attributeOptions.includes(attr.name))
|
|
||||||
: allAttributes;
|
|
||||||
if (emptyOptions === undefined || query.length > 0) {
|
|
||||||
result.push(
|
|
||||||
...attributes
|
|
||||||
.filter(
|
|
||||||
(attr) =>
|
|
||||||
attr.name.toLowerCase().includes(query.toLowerCase()) ||
|
|
||||||
attr.labels.some((label) =>
|
|
||||||
label.toLowerCase().includes(query.toLowerCase()),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.map(
|
|
||||||
(attribute) =>
|
|
||||||
({
|
|
||||||
t: "Attribute",
|
|
||||||
...attribute,
|
|
||||||
}) as SelectorOption,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const attributeToCreate = query
|
|
||||||
.toUpperCase()
|
|
||||||
.replaceAll(/[^A-Z0-9]/g, "_");
|
|
||||||
if (
|
|
||||||
!attributeOptions &&
|
|
||||||
query &&
|
|
||||||
!allAttributes.map((attr) => attr.name).includes(attributeToCreate) &&
|
|
||||||
types.includes("NewAttribute")
|
|
||||||
) {
|
|
||||||
result.push({
|
|
||||||
t: "NewAttribute",
|
|
||||||
name: attributeToCreate,
|
|
||||||
label: query,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
options = result;
|
|
||||||
updating = false;
|
|
||||||
}, 200);
|
|
||||||
|
|
||||||
$: dbg("%o Options: %O", selectorEl, options);
|
|
||||||
|
|
||||||
$: {
|
|
||||||
if (inputFocused) {
|
|
||||||
updateOptions.cancel();
|
|
||||||
updateOptions(inputValue, true);
|
|
||||||
addressToLabels = {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let addressToLabels: { [key: string]: string[] } = {};
|
|
||||||
function onAddressResolved(address: string, ev: CustomEvent<string[]>) {
|
|
||||||
addressToLabels[address] = ev.detail;
|
|
||||||
updateOptions.cancel();
|
|
||||||
updateOptions(inputValue, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function set(option: SelectorOption) {
|
|
||||||
dbg("%o Setting option %O", selectorEl, option);
|
|
||||||
|
|
||||||
switch (option.t) {
|
|
||||||
case "Address":
|
|
||||||
inputValue = option.c;
|
|
||||||
current = option;
|
|
||||||
break;
|
|
||||||
case "NewAddress":
|
|
||||||
{
|
|
||||||
const addr = await createLabelled(option.c);
|
|
||||||
inputValue = addr;
|
|
||||||
current = {
|
|
||||||
t: "Address",
|
|
||||||
c: addr,
|
|
||||||
labels: [option.c],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
case "Attribute":
|
|
||||||
inputValue = option.name;
|
|
||||||
current = option;
|
|
||||||
break;
|
|
||||||
case "NewAttribute":
|
|
||||||
inputValue = option.name;
|
|
||||||
|
|
||||||
{
|
|
||||||
const address = await api.componentsToAddress({
|
|
||||||
t: "Attribute",
|
|
||||||
c: option.name,
|
|
||||||
});
|
|
||||||
await api.putEntityAttribute(address, ATTR_LABEL, {
|
|
||||||
t: "String",
|
|
||||||
c: option.label,
|
|
||||||
});
|
|
||||||
|
|
||||||
current = {
|
|
||||||
t: "Attribute",
|
|
||||||
name: option.name,
|
|
||||||
labels: [option.label],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "String":
|
|
||||||
inputValue = option.c;
|
|
||||||
current = option;
|
|
||||||
break;
|
|
||||||
case "Number":
|
|
||||||
inputValue = String(option.c);
|
|
||||||
current = option;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
dbg("%o Result set value: %O", selectorEl, current);
|
|
||||||
dispatch("input", current);
|
|
||||||
|
|
||||||
options = [];
|
|
||||||
optionFocusIndex = -1;
|
|
||||||
hover = false;
|
|
||||||
if (keepFocusOnSet) {
|
|
||||||
focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let listEl: HTMLUListElement;
|
|
||||||
let optionFocusIndex = -1;
|
|
||||||
function handleArrowKeys(ev: KeyboardEvent) {
|
|
||||||
if (!options.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const optionEls = Array.from(listEl.children) as HTMLLIElement[];
|
|
||||||
let targetIndex = optionEls.findIndex(
|
|
||||||
(el) => document.activeElement === el,
|
|
||||||
);
|
|
||||||
switch (ev.key) {
|
|
||||||
case "ArrowDown":
|
|
||||||
targetIndex += 1;
|
|
||||||
|
|
||||||
// pressed down on last
|
|
||||||
if (targetIndex >= optionEls.length) {
|
|
||||||
targetIndex = 0;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "ArrowUp":
|
|
||||||
targetIndex -= 1;
|
|
||||||
|
|
||||||
// pressed up on input
|
|
||||||
if (targetIndex == -2) {
|
|
||||||
targetIndex = optionEls.length - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// pressed up on first
|
|
||||||
if (targetIndex == -1) {
|
|
||||||
focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
return; // early return, stop processing
|
|
||||||
}
|
|
||||||
|
|
||||||
if (optionEls[targetIndex]) {
|
|
||||||
optionEls[targetIndex].focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let input: Input;
|
|
||||||
export function focus() {
|
|
||||||
// dbg("%o Focusing input", selectorEl);
|
|
||||||
input.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
let inputFocused = false;
|
|
||||||
let hover = false; // otherwise clicking makes options disappear faster than it can emit a set
|
|
||||||
$: visible =
|
|
||||||
(inputFocused || hover || optionFocusIndex > -1) &&
|
|
||||||
Boolean(options.length || updating);
|
|
||||||
$: dispatch("focus", inputFocused || hover || optionFocusIndex > -1);
|
|
||||||
|
|
||||||
$: dbg(
|
|
||||||
"%o focus = %s, hover = %s, visible = %s",
|
|
||||||
selectorEl,
|
|
||||||
inputFocused,
|
|
||||||
hover,
|
|
||||||
visible,
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="selector" bind:this={selectorEl}>
|
|
||||||
{#if current?.t === "Address" && inputValue.length > 0}
|
|
||||||
<div class="input">
|
|
||||||
<div class="label">
|
|
||||||
<UpObject link address={String(current.c)} />
|
|
||||||
</div>
|
|
||||||
<IconButton name="x" on:click={() => (inputValue = "")} />
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<Input
|
|
||||||
bind:this={input}
|
|
||||||
bind:value={inputValue}
|
|
||||||
on:focusChange={(ev) => (inputFocused = ev.detail)}
|
|
||||||
on:keydown={handleArrowKeys}
|
|
||||||
{disabled}
|
|
||||||
{placeholder}
|
|
||||||
>
|
|
||||||
<slot name="prefix" slot="prefix" />
|
|
||||||
</Input>
|
|
||||||
{/if}
|
|
||||||
<ul
|
|
||||||
class="options"
|
|
||||||
class:visible
|
|
||||||
on:mouseenter={() => (hover = true)}
|
|
||||||
on:mouseleave={() => (hover = false)}
|
|
||||||
bind:this={listEl}
|
|
||||||
>
|
|
||||||
{#if updating}
|
|
||||||
<li><Spinner centered /></li>
|
|
||||||
{/if}
|
|
||||||
{#each options.slice(0, MAX_OPTIONS) as option, idx}
|
|
||||||
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
|
||||||
<li
|
|
||||||
tabindex="0"
|
|
||||||
on:click={() => set(option)}
|
|
||||||
on:mousemove={() => focus()}
|
|
||||||
on:focus={() => (optionFocusIndex = idx)}
|
|
||||||
on:blur={() => (optionFocusIndex = -1)}
|
|
||||||
on:keydown={(ev) => {
|
|
||||||
if (ev.key === "Enter") {
|
|
||||||
set(option);
|
|
||||||
} else {
|
|
||||||
handleArrowKeys(ev);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{#if option.t === "Address"}
|
|
||||||
{@const address = option.c}
|
|
||||||
{#if option.entry}
|
|
||||||
<UpEntryComponent entry={option.entry} />
|
|
||||||
{:else}
|
|
||||||
<UpObject
|
|
||||||
{address}
|
|
||||||
labels={option.labels}
|
|
||||||
on:resolved={(ev) => onAddressResolved(address, ev)}
|
|
||||||
/>{/if}
|
|
||||||
{:else if option.t === "NewAddress"}
|
|
||||||
<div class="content new">{option.c}</div>
|
|
||||||
<div class="type">{$i18n.t("Create object")}</div>
|
|
||||||
{:else if option.t === "Attribute"}
|
|
||||||
{#if option.labels.length}
|
|
||||||
<div class="content">
|
|
||||||
{#each option.labels as label}
|
|
||||||
<div class="label">{label}</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<div class="type">{option.name}</div>
|
|
||||||
{:else}
|
|
||||||
<div class="content">
|
|
||||||
{option.name}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{:else if option.t === "NewAttribute"}
|
|
||||||
<div class="content">{option.label}</div>
|
|
||||||
<div class="type">{$i18n.t("Create attribute")} ({option.name})</div>
|
|
||||||
{:else}
|
|
||||||
<div class="type">{option.t}</div>
|
|
||||||
<div class="content">{option.c}</div>
|
|
||||||
{/if}
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.selector {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
.label {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.options {
|
|
||||||
position: absolute;
|
|
||||||
list-style: none;
|
|
||||||
margin: 2px 0 0;
|
|
||||||
padding: 0;
|
|
||||||
border: 1px solid var(--foreground-lighter);
|
|
||||||
width: 100%;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--background);
|
|
||||||
|
|
||||||
visibility: hidden;
|
|
||||||
opacity: 0;
|
|
||||||
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
|
|
||||||
z-index: 99;
|
|
||||||
|
|
||||||
&.visible {
|
|
||||||
visibility: visible;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0.25em;
|
|
||||||
|
|
||||||
transition: background-color 0.1s;
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--background-lighter);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
background-color: var(--background-lighter);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type,
|
|
||||||
.content {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type {
|
|
||||||
opacity: 0.8;
|
|
||||||
font-size: smaller;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.content.new {
|
|
||||||
padding: 0.25em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,30 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
export let centered: boolean | string = false;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="spinner lds-ripple"
|
|
||||||
class:centered={Boolean(centered)}
|
|
||||||
class:absolute-centered={centered == "absolute"}
|
|
||||||
>
|
|
||||||
<i class="bx bx-loader bx-spin" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.spinner {
|
|
||||||
height: 1em;
|
|
||||||
}
|
|
||||||
.spinner.centered {
|
|
||||||
position: relative;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner.absolute-centered {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,338 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { readable, type Readable } from "svelte/store";
|
|
||||||
import type { UpListing } from "@upnd/upend";
|
|
||||||
import type { Address } from "@upnd/upend/types";
|
|
||||||
import { query } from "../../lib/entity";
|
|
||||||
import UpObject from "../display/UpObject.svelte";
|
|
||||||
import UpObjectCard from "../display/UpObjectCard.svelte";
|
|
||||||
import { ATTR_LABEL } from "@upnd/upend/constants";
|
|
||||||
import { i18n } from "../../i18n";
|
|
||||||
import IconButton from "../utils/IconButton.svelte";
|
|
||||||
import Selector, { type SelectorValue } from "../utils/Selector.svelte";
|
|
||||||
import { createEventDispatcher } from "svelte";
|
|
||||||
import type { WidgetChange } from "src/types/base";
|
|
||||||
import debug from "debug";
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
const dbg = debug(`kestrel:EntityList`);
|
|
||||||
|
|
||||||
export let entities: Address[];
|
|
||||||
export let thumbnails = true;
|
|
||||||
export let select: "add" | "remove" = "add";
|
|
||||||
export let sort = true;
|
|
||||||
export let address: Address | undefined = undefined;
|
|
||||||
|
|
||||||
$: deduplicatedEntities = Array.from(new Set(entities));
|
|
||||||
|
|
||||||
let style: "list" | "grid" | "flex" = "grid";
|
|
||||||
|
|
||||||
let clientWidth: number;
|
|
||||||
$: style = !thumbnails || clientWidth < 600 ? "list" : "grid";
|
|
||||||
|
|
||||||
// Sorting
|
|
||||||
let sortedEntities: Address[] = [];
|
|
||||||
|
|
||||||
let sortKeys: { [key: string]: string[] } = {};
|
|
||||||
function addSortKeys(key: string, vals: string[], resort: boolean) {
|
|
||||||
if (!sortKeys[key]) {
|
|
||||||
sortKeys[key] = [];
|
|
||||||
}
|
|
||||||
let changed = false;
|
|
||||||
vals.forEach((val) => {
|
|
||||||
if (!sortKeys[key].includes(val)) {
|
|
||||||
changed = true;
|
|
||||||
sortKeys[key].push(val);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (resort && changed) sortEntities();
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortEntities() {
|
|
||||||
if (!sort) return;
|
|
||||||
|
|
||||||
sortedEntities = deduplicatedEntities.concat();
|
|
||||||
|
|
||||||
sortedEntities.sort((a, b) => {
|
|
||||||
if (!sortKeys[a]?.length || !sortKeys[b]?.length) {
|
|
||||||
if (Boolean(sortKeys[a]?.length) && !sortKeys[b]?.length) {
|
|
||||||
return -1;
|
|
||||||
} else if (!sortKeys[a]?.length && Boolean(sortKeys[b]?.length)) {
|
|
||||||
return 1;
|
|
||||||
} else {
|
|
||||||
return a.localeCompare(b);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return sortKeys[a][0].localeCompare(sortKeys[b][0], undefined, {
|
|
||||||
numeric: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Labelling
|
|
||||||
let labelListing: Readable<UpListing> = readable(undefined);
|
|
||||||
$: {
|
|
||||||
const addressesString = deduplicatedEntities
|
|
||||||
.map((addr) => `@${addr}`)
|
|
||||||
.join(" ");
|
|
||||||
|
|
||||||
labelListing = query(
|
|
||||||
`(matches (in ${addressesString}) "${ATTR_LABEL}" ? )`,
|
|
||||||
).result;
|
|
||||||
}
|
|
||||||
|
|
||||||
$: {
|
|
||||||
if ($labelListing) {
|
|
||||||
deduplicatedEntities.forEach((address) => {
|
|
||||||
addSortKeys(
|
|
||||||
address,
|
|
||||||
$labelListing.getObject(address).identify(),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
sortEntities();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sort) {
|
|
||||||
sortedEntities = entities;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Visibility
|
|
||||||
let visible: Set<string> = new Set();
|
|
||||||
let observer = new IntersectionObserver((intersections) => {
|
|
||||||
intersections.forEach((intersection) => {
|
|
||||||
const address = (intersection.target as HTMLElement).dataset["address"];
|
|
||||||
if (!address) {
|
|
||||||
console.warn("Intersected wrong element?");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (intersection.isIntersecting) {
|
|
||||||
visible.add(address);
|
|
||||||
}
|
|
||||||
visible = visible;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function observe(node: HTMLElement) {
|
|
||||||
observer.observe(node);
|
|
||||||
|
|
||||||
return {
|
|
||||||
destroy() {
|
|
||||||
observer.unobserve(node);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adding
|
|
||||||
let addSelector: Selector | undefined;
|
|
||||||
let adding = false;
|
|
||||||
|
|
||||||
$: if (adding && addSelector) addSelector.focus();
|
|
||||||
|
|
||||||
function addEntity(ev: CustomEvent<SelectorValue>) {
|
|
||||||
dbg("Adding entity", ev.detail);
|
|
||||||
const addAddress = ev.detail?.t == "Address" ? ev.detail.c : undefined;
|
|
||||||
if (!addAddress) return;
|
|
||||||
|
|
||||||
dispatch("change", {
|
|
||||||
type: "entry-add",
|
|
||||||
address: addAddress,
|
|
||||||
} as WidgetChange);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeEntity(address: string) {
|
|
||||||
if (
|
|
||||||
confirm(
|
|
||||||
$i18n.t("Are you sure you want to remove this entry from members?"),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
dbg("Removing entity", address);
|
|
||||||
dispatch("change", {
|
|
||||||
type: "entry-delete",
|
|
||||||
address,
|
|
||||||
} as WidgetChange);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="entitylist style-{style}"
|
|
||||||
class:has-thumbnails={thumbnails}
|
|
||||||
bind:clientWidth
|
|
||||||
>
|
|
||||||
{#if !sortedEntities.length}
|
|
||||||
<div class="message">
|
|
||||||
{$i18n.t("No entries.")}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="items">
|
|
||||||
{#each sortedEntities as entity (entity)}
|
|
||||||
<div
|
|
||||||
data-address={entity}
|
|
||||||
data-select-mode={select}
|
|
||||||
use:observe
|
|
||||||
class="item"
|
|
||||||
>
|
|
||||||
{#if visible.has(entity)}
|
|
||||||
{#if thumbnails}
|
|
||||||
<UpObjectCard
|
|
||||||
address={entity}
|
|
||||||
labels={sortKeys[entity]}
|
|
||||||
banner={false}
|
|
||||||
select={select === "add"}
|
|
||||||
on:resolved={(event) => {
|
|
||||||
addSortKeys(entity, event.detail, true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div class="icon">
|
|
||||||
<IconButton
|
|
||||||
name="trash"
|
|
||||||
color="#dc322f"
|
|
||||||
on:click={() => removeEntity(entity)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="object">
|
|
||||||
<UpObject
|
|
||||||
link
|
|
||||||
address={entity}
|
|
||||||
labels={sortKeys[entity]}
|
|
||||||
select={select === "add"}
|
|
||||||
on:resolved={(event) => {
|
|
||||||
addSortKeys(entity, event.detail, true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="icon">
|
|
||||||
<IconButton
|
|
||||||
name="trash"
|
|
||||||
color="#dc322f"
|
|
||||||
on:click={() => removeEntity(entity)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<div class="skeleton" style="text-align: center">...</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{#if address}
|
|
||||||
<div class="add">
|
|
||||||
{#if adding}
|
|
||||||
<Selector
|
|
||||||
bind:this={addSelector}
|
|
||||||
placeholder={$i18n.t("Search database or paste an URL")}
|
|
||||||
types={["Address", "NewAddress"]}
|
|
||||||
on:input={addEntity}
|
|
||||||
on:focus={(ev) => {
|
|
||||||
if (!ev.detail) {
|
|
||||||
adding = false;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<IconButton
|
|
||||||
name="plus-circle"
|
|
||||||
outline
|
|
||||||
subdued
|
|
||||||
on:click={() => {
|
|
||||||
adding = true;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
@use "../../styles/colors";
|
|
||||||
|
|
||||||
.items {
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entitylist.has-thumbnails .items {
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.entitylist.style-grid .items) {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
align-items: end;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.entitylist.style-flex .items) {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.entitylist.style-list .items) {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message {
|
|
||||||
text-align: center;
|
|
||||||
margin: 0.5em;
|
|
||||||
opacity: 0.66;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entitylist:not(.has-thumbnails) {
|
|
||||||
.item {
|
|
||||||
display: flex;
|
|
||||||
.object {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
width: 0;
|
|
||||||
transition: width 0.3s ease;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
.icon {
|
|
||||||
width: 1.5em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.entitylist.has-thumbnails {
|
|
||||||
.item {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
position: absolute;
|
|
||||||
top: 0.5em;
|
|
||||||
right: 0.5em;
|
|
||||||
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover .icon {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.add {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entitylist.style-grid .add {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,476 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import filesize from "filesize";
|
|
||||||
import { formatRelative, fromUnixTime, parseISO } from "date-fns";
|
|
||||||
import Ellipsis from "../utils/Ellipsis.svelte";
|
|
||||||
import UpObject from "../display/UpObject.svelte";
|
|
||||||
import { createEventDispatcher } from "svelte";
|
|
||||||
import type { AttributeUpdate, WidgetChange } from "../../types/base";
|
|
||||||
import type { UpEntry, UpListing } from "@upnd/upend";
|
|
||||||
import IconButton from "../utils/IconButton.svelte";
|
|
||||||
import Selector, {
|
|
||||||
type SelectorValue,
|
|
||||||
selectorValueAsValue,
|
|
||||||
} from "../utils/Selector.svelte";
|
|
||||||
import Editable from "../utils/Editable.svelte";
|
|
||||||
import { query } from "../../lib/entity";
|
|
||||||
import { type Readable, readable } from "svelte/store";
|
|
||||||
import { defaultEntitySort, entityValueSort } from "../../util/sort";
|
|
||||||
import { attributeLabels } from "../../util/labels";
|
|
||||||
import { formatDuration } from "../../util/fragments/time";
|
|
||||||
import { i18n } from "../../i18n";
|
|
||||||
import UpLink from "../display/UpLink.svelte";
|
|
||||||
import { ATTR_ADDED, ATTR_LABEL } from "@upnd/upend/constants";
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
|
|
||||||
export let columns: string | undefined = undefined;
|
|
||||||
export let header = true;
|
|
||||||
|
|
||||||
export let orderByValue = false;
|
|
||||||
export let columnWidths: string[] | undefined = undefined;
|
|
||||||
|
|
||||||
export let entries: UpEntry[];
|
|
||||||
export let attributes: string[] | undefined = undefined;
|
|
||||||
|
|
||||||
// Display
|
|
||||||
$: displayColumns = (columns || "entity, attribute, value")
|
|
||||||
.split(",")
|
|
||||||
.map((c) => c.trim());
|
|
||||||
|
|
||||||
const TIMESTAMP_COL = "timestamp";
|
|
||||||
const PROVENANCE_COL = "provenance";
|
|
||||||
const ENTITY_COL = "entity";
|
|
||||||
const ATTR_COL = "attribute";
|
|
||||||
const VALUE_COL = "value";
|
|
||||||
|
|
||||||
$: templateColumns = (
|
|
||||||
(displayColumns || []).map((column, idx) => {
|
|
||||||
if (columnWidths?.[idx]) return columnWidths[idx];
|
|
||||||
return "minmax(6em, auto)";
|
|
||||||
}) as string[]
|
|
||||||
)
|
|
||||||
.concat(["auto"])
|
|
||||||
.join(" ");
|
|
||||||
|
|
||||||
// Editing
|
|
||||||
let adding = false;
|
|
||||||
let addHover = false;
|
|
||||||
let addFocus = false;
|
|
||||||
let newAttrSelector: Selector;
|
|
||||||
let newEntryAttribute = "";
|
|
||||||
let newEntryValue: SelectorValue | undefined;
|
|
||||||
|
|
||||||
$: if (adding && newAttrSelector) newAttrSelector.focus();
|
|
||||||
$: if (!addFocus && !addHover) adding = false;
|
|
||||||
|
|
||||||
async function addEntry(attribute: string, value: SelectorValue) {
|
|
||||||
dispatch("change", {
|
|
||||||
type: "create",
|
|
||||||
attribute,
|
|
||||||
value: await selectorValueAsValue(value),
|
|
||||||
} as WidgetChange);
|
|
||||||
newEntryAttribute = "";
|
|
||||||
newEntryValue = undefined;
|
|
||||||
}
|
|
||||||
async function removeEntry(address: string) {
|
|
||||||
if (confirm($i18n.t("Are you sure you want to remove the property?"))) {
|
|
||||||
dispatch("change", { type: "delete", address } as WidgetChange);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function updateEntry(
|
|
||||||
address: string,
|
|
||||||
attribute: string,
|
|
||||||
value: SelectorValue,
|
|
||||||
) {
|
|
||||||
dispatch("change", {
|
|
||||||
type: "update",
|
|
||||||
address,
|
|
||||||
attribute,
|
|
||||||
value: await selectorValueAsValue(value),
|
|
||||||
} as AttributeUpdate);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Labelling
|
|
||||||
let labelListing: Readable<UpListing> = readable(undefined);
|
|
||||||
$: {
|
|
||||||
const addresses = [];
|
|
||||||
entries
|
|
||||||
.flatMap((e) =>
|
|
||||||
e.value.t === "Address" ? [e.entity, e.value.c] : [e.entity],
|
|
||||||
)
|
|
||||||
.forEach((addr) => {
|
|
||||||
if (!addresses.includes(addr)) {
|
|
||||||
addresses.push(addr);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const addressesString = addresses.map((addr) => `@${addr}`).join(" ");
|
|
||||||
|
|
||||||
labelListing = query(
|
|
||||||
`(matches (in ${addressesString}) "${ATTR_LABEL}" ? )`,
|
|
||||||
).result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sorting
|
|
||||||
let sortedEntries = entries;
|
|
||||||
|
|
||||||
let sortKeys: { [key: string]: string[] } = {};
|
|
||||||
function addSortKeys(key: string, vals: string[], resort: boolean) {
|
|
||||||
if (!sortKeys[key]) {
|
|
||||||
sortKeys[key] = [];
|
|
||||||
}
|
|
||||||
let changed = false;
|
|
||||||
vals.forEach((val) => {
|
|
||||||
if (!sortKeys[key].includes(val)) {
|
|
||||||
changed = true;
|
|
||||||
sortKeys[key].push(val);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (resort && changed) sortEntries();
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortEntries() {
|
|
||||||
sortedEntries = orderByValue
|
|
||||||
? entityValueSort(entries, Object.assign(sortKeys, $attributeLabels))
|
|
||||||
: defaultEntitySort(entries, Object.assign(sortKeys, $attributeLabels));
|
|
||||||
}
|
|
||||||
|
|
||||||
$: {
|
|
||||||
if ($labelListing) {
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
addSortKeys(
|
|
||||||
entry.entity,
|
|
||||||
$labelListing.getObject(entry.entity).identify(),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (entry.value.t === "Address") {
|
|
||||||
addSortKeys(
|
|
||||||
entry.value.c,
|
|
||||||
$labelListing.getObject(String(entry.value.c)).identify(),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
sortEntries();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
addSortKeys(
|
|
||||||
entry.entity,
|
|
||||||
entry.listing.getObject(entry.entity).identify(),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
if (entry.value.t === "Address") {
|
|
||||||
addSortKeys(
|
|
||||||
entry.value.c,
|
|
||||||
entry.listing.getObject(String(entry.value.c)).identify(),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
sortEntries();
|
|
||||||
|
|
||||||
// Visibility
|
|
||||||
let visible: Set<string> = new Set();
|
|
||||||
let observer = new IntersectionObserver((intersections) => {
|
|
||||||
intersections.forEach((intersection) => {
|
|
||||||
const address = (intersection.target as HTMLElement).dataset["address"];
|
|
||||||
if (!address) {
|
|
||||||
console.warn("Intersected wrong element?");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (intersection.isIntersecting) {
|
|
||||||
visible.add(address);
|
|
||||||
}
|
|
||||||
visible = visible;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function observe(node: HTMLElement) {
|
|
||||||
observer.observe(node);
|
|
||||||
|
|
||||||
return {
|
|
||||||
destroy() {
|
|
||||||
observer.unobserve(node);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Formatting & Display
|
|
||||||
const COLUMN_LABELS: { [key: string]: string } = {
|
|
||||||
timestamp: $i18n.t("Added at"),
|
|
||||||
provenance: $i18n.t("Provenance"),
|
|
||||||
entity: $i18n.t("Entity"),
|
|
||||||
attribute: $i18n.t("Attribute"),
|
|
||||||
value: $i18n.t("Value"),
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatValue(value: string | number, attribute: string): string {
|
|
||||||
try {
|
|
||||||
switch (attribute) {
|
|
||||||
case "FILE_SIZE":
|
|
||||||
return filesize(parseInt(String(value), 10), { base: 2 });
|
|
||||||
case ATTR_ADDED:
|
|
||||||
case "LAST_VISITED":
|
|
||||||
return formatRelative(
|
|
||||||
fromUnixTime(parseInt(String(value), 10)),
|
|
||||||
new Date(),
|
|
||||||
);
|
|
||||||
case "NUM_VISITED":
|
|
||||||
return `${value} times`;
|
|
||||||
case "MEDIA_DURATION":
|
|
||||||
return formatDuration(parseInt(String(value), 10));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// noop.
|
|
||||||
}
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unused attributes
|
|
||||||
let unusedAttributes = [];
|
|
||||||
|
|
||||||
$: (async () => {
|
|
||||||
unusedAttributes = await Promise.all(
|
|
||||||
(attributes || []).filter(
|
|
||||||
(attr) => !entries.some((entry) => entry.attribute === attr),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="entry-list" style:--template-columns={templateColumns}>
|
|
||||||
{#if header}
|
|
||||||
<header>
|
|
||||||
{#each displayColumns as column}
|
|
||||||
<div class="label">
|
|
||||||
{COLUMN_LABELS[column] || $attributeLabels[column] || column}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
<div class="attr-action"></div>
|
|
||||||
</header>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#each sortedEntries as entry (entry.address)}
|
|
||||||
{#if visible.has(entry.address)}
|
|
||||||
{#each displayColumns as column}
|
|
||||||
{#if column == TIMESTAMP_COL}
|
|
||||||
<div class="cell" title={entry.timestamp}>
|
|
||||||
{formatRelative(parseISO(entry.timestamp), new Date())}
|
|
||||||
</div>
|
|
||||||
{:else if column == PROVENANCE_COL}
|
|
||||||
<div class="cell">{entry.provenance}</div>
|
|
||||||
{:else if column == ENTITY_COL}
|
|
||||||
<div class="cell entity mark-entity">
|
|
||||||
<UpObject
|
|
||||||
link
|
|
||||||
labels={$labelListing
|
|
||||||
?.getObject(String(entry.entity))
|
|
||||||
?.identify() || []}
|
|
||||||
address={entry.entity}
|
|
||||||
on:resolved={(event) => {
|
|
||||||
addSortKeys(entry.entity, event.detail, true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{:else if column == ATTR_COL}
|
|
||||||
<div
|
|
||||||
class="cell mark-attribute"
|
|
||||||
class:formatted={Boolean(
|
|
||||||
Object.keys($attributeLabels).includes(entry.attribute),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<UpLink to={{ attribute: entry.attribute }}>
|
|
||||||
<Ellipsis
|
|
||||||
value={$attributeLabels[entry.attribute] || entry.attribute}
|
|
||||||
title={$attributeLabels[entry.attribute]
|
|
||||||
? `${$attributeLabels[entry.attribute]} (${entry.attribute})`
|
|
||||||
: entry.attribute}
|
|
||||||
/>
|
|
||||||
</UpLink>
|
|
||||||
</div>
|
|
||||||
{:else if column == VALUE_COL}
|
|
||||||
<div class="cell value mark-value">
|
|
||||||
<Editable
|
|
||||||
value={entry.value}
|
|
||||||
on:edit={(ev) =>
|
|
||||||
updateEntry(entry.address, entry.attribute, ev.detail)}
|
|
||||||
>
|
|
||||||
{#if entry.value.t === "Address"}
|
|
||||||
<UpObject
|
|
||||||
link
|
|
||||||
address={String(entry.value.c)}
|
|
||||||
labels={$labelListing
|
|
||||||
?.getObject(String(entry.value.c))
|
|
||||||
?.identify() || []}
|
|
||||||
on:resolved={(event) => {
|
|
||||||
addSortKeys(String(entry.value.c), event.detail, true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div
|
|
||||||
class:formatted={Boolean(
|
|
||||||
formatValue(entry.value.c, entry.attribute),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Ellipsis
|
|
||||||
value={formatValue(entry.value.c, entry.attribute) ||
|
|
||||||
String(entry.value.c)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</Editable>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div>?</div>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
<div class="attr-action">
|
|
||||||
<IconButton
|
|
||||||
plain
|
|
||||||
subdued
|
|
||||||
name="x-circle"
|
|
||||||
color="#dc322f"
|
|
||||||
on:click={() => removeEntry(entry.address)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="skeleton" data-address={entry.address} use:observe>...</div>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
{#each unusedAttributes as attribute}
|
|
||||||
{#each displayColumns as column}
|
|
||||||
{#if column == ATTR_COL}
|
|
||||||
<div
|
|
||||||
class="cell mark-attribute"
|
|
||||||
class:formatted={Boolean(
|
|
||||||
Object.keys($attributeLabels).includes(attribute),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<UpLink to={{ attribute }}>
|
|
||||||
<Ellipsis
|
|
||||||
value={$attributeLabels[attribute] || attribute}
|
|
||||||
title={$attributeLabels[attribute]
|
|
||||||
? `${$attributeLabels[attribute]} (${attribute})`
|
|
||||||
: attribute}
|
|
||||||
/>
|
|
||||||
</UpLink>
|
|
||||||
</div>
|
|
||||||
{:else if column == VALUE_COL}
|
|
||||||
<div class="cell">
|
|
||||||
<Editable on:edit={(ev) => addEntry(attribute, ev.detail)}>
|
|
||||||
<span class="unset">{$i18n.t("(unset)")}</span>
|
|
||||||
</Editable>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="cell"></div>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
<div class="attr-action"></div>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
{#if !attributes?.length}
|
|
||||||
{#if adding}
|
|
||||||
<div
|
|
||||||
class="add-row"
|
|
||||||
on:mouseenter={() => (addHover = true)}
|
|
||||||
on:mouseleave={() => (addHover = false)}
|
|
||||||
>
|
|
||||||
{#each displayColumns as column}
|
|
||||||
{#if column == ATTR_COL}
|
|
||||||
<div class="cell mark-attribute">
|
|
||||||
<Selector
|
|
||||||
types={["Attribute", "NewAttribute"]}
|
|
||||||
on:input={(ev) => (newEntryAttribute = ev.detail.name)}
|
|
||||||
on:focus={(ev) => (addFocus = ev.detail)}
|
|
||||||
keepFocusOnSet
|
|
||||||
bind:this={newAttrSelector}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{:else if column === VALUE_COL}
|
|
||||||
<div class="cell mark-value">
|
|
||||||
<Selector
|
|
||||||
on:input={(ev) => (newEntryValue = ev.detail)}
|
|
||||||
on:focus={(ev) => (addFocus = ev.detail)}
|
|
||||||
keepFocusOnSet
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="cell"></div>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
<div class="attr-action">
|
|
||||||
<IconButton
|
|
||||||
name="save"
|
|
||||||
on:click={() => addEntry(newEntryAttribute, newEntryValue)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="add-button">
|
|
||||||
<IconButton
|
|
||||||
outline
|
|
||||||
subdued
|
|
||||||
name="plus-circle"
|
|
||||||
on:click={() => (adding = true)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.entry-list {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: var(--template-columns);
|
|
||||||
gap: 0.05rem 0.5rem;
|
|
||||||
|
|
||||||
header {
|
|
||||||
display: contents;
|
|
||||||
|
|
||||||
.label {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell {
|
|
||||||
font-family: var(--monospace-font);
|
|
||||||
line-break: anywhere;
|
|
||||||
min-width: 0;
|
|
||||||
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 2px;
|
|
||||||
|
|
||||||
&.formatted,
|
|
||||||
.formatted {
|
|
||||||
font-family: var(--default-font);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.attr-action {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-row {
|
|
||||||
display: contents;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-button {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.unset {
|
|
||||||
opacity: 0.66;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1 +0,0 @@
|
||||||
/// <reference types="svelte" />
|
|
|
@ -1,13 +0,0 @@
|
||||||
{
|
|
||||||
"attributes": {
|
|
||||||
"FILE_MIME": "MIME type",
|
|
||||||
"FILE_SIZE": "File size",
|
|
||||||
"ADDED": "Added at",
|
|
||||||
"LAST_VISITED": "Last visited at",
|
|
||||||
"NUM_VISITED": "Times visited",
|
|
||||||
"ATTR_LABEL": "Label",
|
|
||||||
"IS": "Type",
|
|
||||||
"TYPE": "Type ID",
|
|
||||||
"MEDIA_DURATION": "Duration"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,12 +0,0 @@
|
||||||
import i18next from "i18next";
|
|
||||||
import { createI18nStore } from "svelte-i18next";
|
|
||||||
import en from "./en.json";
|
|
||||||
|
|
||||||
i18next.init({
|
|
||||||
lng: "en",
|
|
||||||
resources: {
|
|
||||||
en,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const i18n = createI18nStore(i18next);
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { UpEndApi } from "@upnd/upend";
|
import { UpEndApi } from '@upnd/upend';
|
||||||
import { UpEndWasmExtensionsWeb } from "@upnd/upend/wasm/web";
|
import { UpEndWasmExtensionsWeb } from '@upnd/upend/wasm/web';
|
||||||
import wasmURL from "@upnd/wasm-web/upend_wasm_bg.wasm?url";
|
import wasmURL from '@upnd/wasm-web/upend_wasm_bg.wasm?url';
|
||||||
|
|
||||||
const wasm = new UpEndWasmExtensionsWeb(wasmURL);
|
const wasm = new UpEndWasmExtensionsWeb(wasmURL);
|
||||||
export default new UpEndApi({ instanceUrl: "/", wasmExtensions: wasm });
|
export default new UpEndApi({ instanceUrl: '/', wasmExtensions: wasm });
|
||||||
|
|
|
@ -0,0 +1,160 @@
|
||||||
|
<script context="module" lang="ts">
|
||||||
|
import mitt from 'mitt';
|
||||||
|
|
||||||
|
export type AddEvents = {
|
||||||
|
files: File[];
|
||||||
|
urls: string[];
|
||||||
|
};
|
||||||
|
export const addEmitter = mitt<AddEvents>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import Icon from './utils/Icon.svelte';
|
||||||
|
import IconButton from './utils/IconButton.svelte';
|
||||||
|
import api from '$lib/api';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
let files: File[] = [];
|
||||||
|
let URLs: string[] = [];
|
||||||
|
let uploading = false;
|
||||||
|
|
||||||
|
$: visible = files.length + URLs.length > 0;
|
||||||
|
|
||||||
|
addEmitter.on('files', (ev) => {
|
||||||
|
ev.forEach((file) => {
|
||||||
|
if (!files.map((f) => `${f.name}${f.size}`).includes(`${file.name}${file.size}`)) {
|
||||||
|
files.push(file);
|
||||||
|
}
|
||||||
|
files = files;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function upload() {
|
||||||
|
uploading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const addresses = await Promise.all(files.map(async (file) => api.putBlob(file)));
|
||||||
|
|
||||||
|
goto(`/browse/${addresses.join(',')}`);
|
||||||
|
} catch (error) {
|
||||||
|
alert(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
uploading = false;
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
if (!uploading) {
|
||||||
|
files = [];
|
||||||
|
URLs = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:body on:keydown={(ev) => ev.key === 'Escape' && reset()} />
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
|
||||||
|
<div class="addmodal-container" class:visible class:uploading on:click={reset}>
|
||||||
|
<div class="addmodal" on:click|stopPropagation>
|
||||||
|
<div class="files">
|
||||||
|
{#each files as file}
|
||||||
|
<div class="file">
|
||||||
|
{#if file.type.startsWith('image')}
|
||||||
|
<img src={URL.createObjectURL(file)} alt="To be uploaded." />
|
||||||
|
{:else}
|
||||||
|
<div class="icon">
|
||||||
|
<Icon name="file" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="label">{file.name}</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<IconButton name="upload" on:click={upload} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.addmodal-container {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
color: var(--foreground);
|
||||||
|
|
||||||
|
display: none;
|
||||||
|
&.visible {
|
||||||
|
display: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.uploading {
|
||||||
|
cursor: progress;
|
||||||
|
|
||||||
|
.addmodal {
|
||||||
|
filter: brightness(0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.addmodal {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
|
||||||
|
background: var(--background);
|
||||||
|
|
||||||
|
color: var(--foreground);
|
||||||
|
border: solid 2px var(--foreground);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1em;
|
||||||
|
|
||||||
|
padding: 0.5em;
|
||||||
|
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 66vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
border: 1px solid var(--foreground);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--background-lighter);
|
||||||
|
padding: 0.5em;
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-height: 12em;
|
||||||
|
max-width: 12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
flex-grow: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 48px;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,90 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
import Icon from './utils/Icon.svelte';
|
||||||
|
import Selector from './utils/Selector.svelte';
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
let selector: Selector;
|
||||||
|
|
||||||
|
let editable = false;
|
||||||
|
$: {
|
||||||
|
if (editable) {
|
||||||
|
dispatch('editable');
|
||||||
|
setTimeout(() => dispatch('editable'), 500); // once animation has finished
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$: editable && selector && selector.focus();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="view"
|
||||||
|
class:editable
|
||||||
|
on:click={() => (editable = true)}
|
||||||
|
on:keydown={(ev) => {
|
||||||
|
if (['Space', 'Enter'].includes(ev.key)) editable = true;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="icon">
|
||||||
|
<Icon name="plus-circle" />
|
||||||
|
</div>
|
||||||
|
{#if editable}
|
||||||
|
<div class="controls">
|
||||||
|
<Selector
|
||||||
|
bind:this={selector}
|
||||||
|
types={['Address', 'NewAddress', 'Attribute']}
|
||||||
|
on:input={(ev) => {
|
||||||
|
dispatch('input', ev.detail);
|
||||||
|
editable = false;
|
||||||
|
}}
|
||||||
|
on:focus={(ev) => {
|
||||||
|
if (!ev.detail) editable = false;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.view {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
background: var(--background-lighter);
|
||||||
|
color: var(--foreground-lighter);
|
||||||
|
border: 1px solid var(--foreground-lightest);
|
||||||
|
border-radius: 0.5em;
|
||||||
|
padding: 1rem;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
transition:
|
||||||
|
opacity 0.3s,
|
||||||
|
width 0.5s,
|
||||||
|
min-width 0.5s;
|
||||||
|
|
||||||
|
opacity: 0.4;
|
||||||
|
width: 48px;
|
||||||
|
min-width: 48px;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&.editable {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.editable {
|
||||||
|
width: 18em;
|
||||||
|
min-width: 18em;
|
||||||
|
.icon {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
font-size: 36px;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,177 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher, onMount, setContext, tick } from 'svelte';
|
||||||
|
import IconButton from './utils/IconButton.svelte';
|
||||||
|
import { selected } from './EntitySelect.svelte';
|
||||||
|
import type { BrowseContext } from '../util/browse';
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import { i18n } from '../i18n';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
export let address: string | undefined = undefined;
|
||||||
|
export let index: number;
|
||||||
|
export let only: boolean;
|
||||||
|
export let background = 'var(--background-lighter)';
|
||||||
|
export let forceDetail = false;
|
||||||
|
let shifted = false;
|
||||||
|
let key = Math.random();
|
||||||
|
|
||||||
|
let detail = only || forceDetail;
|
||||||
|
let detailChanged = false;
|
||||||
|
$: if (!detailChanged) detail = only || forceDetail;
|
||||||
|
$: if (detailChanged) tick().then(() => dispatch('detail', detail));
|
||||||
|
|
||||||
|
let indexStore = writable(index);
|
||||||
|
$: $indexStore = index;
|
||||||
|
|
||||||
|
let addressesStore = writable<string[]>([]);
|
||||||
|
$: $addressesStore = $page.params.addresses?.split(',') || [];
|
||||||
|
|
||||||
|
setContext('browse', {
|
||||||
|
index: indexStore,
|
||||||
|
addresses: addressesStore
|
||||||
|
} as BrowseContext);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// Required to make detail mode detection work in Browse
|
||||||
|
dispatch('detail', detail);
|
||||||
|
});
|
||||||
|
$: if ($selected.length) {
|
||||||
|
detail = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function visit() {
|
||||||
|
window.open(`/browse/${address}`, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
let width = 460;
|
||||||
|
if (window.innerWidth < 600) {
|
||||||
|
width = window.innerWidth - 6;
|
||||||
|
}
|
||||||
|
function drag(ev: MouseEvent) {
|
||||||
|
const startWidth = width;
|
||||||
|
const startX = ev.screenX;
|
||||||
|
|
||||||
|
function onMouseMove(ev: MouseEvent) {
|
||||||
|
width = startWidth + (ev.screenX - startX);
|
||||||
|
width = width < 300 ? 300 : width;
|
||||||
|
}
|
||||||
|
function onMouseUp(_: MouseEvent) {
|
||||||
|
window.removeEventListener('mousemove', onMouseMove);
|
||||||
|
window.removeEventListener('mouseup', onMouseUp);
|
||||||
|
}
|
||||||
|
window.addEventListener('mousemove', onMouseMove);
|
||||||
|
window.addEventListener('mouseup', onMouseUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
key = Math.random();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
|
<div
|
||||||
|
class="browse-column"
|
||||||
|
class:detail
|
||||||
|
style="--background-color: {background}"
|
||||||
|
on:mousemove={(ev) => (shifted = ev.shiftKey)}
|
||||||
|
>
|
||||||
|
<div class="view" style="--width: {width}px">
|
||||||
|
<header>
|
||||||
|
{#if address}
|
||||||
|
<IconButton name="link" on:click={() => visit()} disabled={only}>
|
||||||
|
{$i18n.t('Detach')}
|
||||||
|
</IconButton>
|
||||||
|
{/if}
|
||||||
|
{#if !forceDetail}
|
||||||
|
<IconButton
|
||||||
|
name={detail ? 'zoom-out' : 'zoom-in'}
|
||||||
|
on:click={() => {
|
||||||
|
detail = !detail;
|
||||||
|
detailChanged = true;
|
||||||
|
}}
|
||||||
|
active={detail}
|
||||||
|
>
|
||||||
|
{$i18n.t('Detail')}
|
||||||
|
</IconButton>
|
||||||
|
{:else}
|
||||||
|
<div class="noop"></div>
|
||||||
|
{/if}
|
||||||
|
{#if address}
|
||||||
|
<IconButton name="intersect" on:click={() => dispatch('combine', address)}>
|
||||||
|
{$i18n.t('Combine')}
|
||||||
|
</IconButton>
|
||||||
|
{/if}
|
||||||
|
{#if !shifted}
|
||||||
|
<IconButton name="x-circle" on:click={() => dispatch('close')} disabled={only}>
|
||||||
|
{$i18n.t('Close')}
|
||||||
|
</IconButton>
|
||||||
|
{:else}
|
||||||
|
<IconButton name="refresh" on:click={() => reload()}>
|
||||||
|
{$i18n.t('Reload')}
|
||||||
|
</IconButton>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
{#key key}
|
||||||
|
<slot {detail} />
|
||||||
|
{/key}
|
||||||
|
</div>
|
||||||
|
<div class="resizeHandle" on:mousedown|preventDefault={drag} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.browse-column {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browse-column.detail {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.view {
|
||||||
|
@media screen and (min-width: 600px) {
|
||||||
|
min-width: 85vw;
|
||||||
|
max-width: min(85vw, 1920px);
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.view {
|
||||||
|
min-width: var(--width);
|
||||||
|
max-width: var(--width);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
background: var(--background-color);
|
||||||
|
color: var(--foreground-lighter);
|
||||||
|
border: 1px solid var(--foreground-lightest);
|
||||||
|
border-radius: 0.5em;
|
||||||
|
padding: 1rem;
|
||||||
|
|
||||||
|
// transition: min-width 0.2s, max-width 0.2s;
|
||||||
|
// TODO - container has nowhere to scroll, breaking `detail` scroll
|
||||||
|
|
||||||
|
header {
|
||||||
|
font-size: 20px;
|
||||||
|
position: relative;
|
||||||
|
min-height: 1em;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizeHandle {
|
||||||
|
cursor: ew-resize;
|
||||||
|
height: 100%;
|
||||||
|
width: 0.5rem;
|
||||||
|
|
||||||
|
@media screen and (max-width: 600px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,164 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { i18n } from '../i18n';
|
||||||
|
import EntitySetEditor from './EntitySetEditor.svelte';
|
||||||
|
import EntryView from './EntryView.svelte';
|
||||||
|
import Icon from './utils/Icon.svelte';
|
||||||
|
import EntityList from './widgets/EntityList.svelte';
|
||||||
|
import api from '$lib/api';
|
||||||
|
import { Query } from '@upnd/upend';
|
||||||
|
import { ATTR_IN } from '@upnd/upend/constants';
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { Any } from '@upnd/upend/query';
|
||||||
|
import type { Widget } from '$lib/components/EntryView.svelte';
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
export let spec: string;
|
||||||
|
|
||||||
|
const individualSpecs = spec.split(/(?=[+=-])/);
|
||||||
|
let includedGroups = individualSpecs.filter((s) => s.startsWith('+')).map((s) => s.slice(1));
|
||||||
|
let requiredGroups = individualSpecs.filter((s) => s.startsWith('=')).map((s) => s.slice(1));
|
||||||
|
let excludedGroups = individualSpecs.filter((s) => s.startsWith('-')).map((s) => s.slice(1));
|
||||||
|
|
||||||
|
$: if (
|
||||||
|
includedGroups.length === 0 &&
|
||||||
|
requiredGroups.length === 0 &&
|
||||||
|
excludedGroups.length === 0
|
||||||
|
) {
|
||||||
|
dispatch('close');
|
||||||
|
}
|
||||||
|
|
||||||
|
const combinedWidgets: Widget[] = [
|
||||||
|
{
|
||||||
|
name: 'List',
|
||||||
|
icon: 'list-check',
|
||||||
|
components: ({ entities }) => [
|
||||||
|
{
|
||||||
|
component: EntityList,
|
||||||
|
props: {
|
||||||
|
entities,
|
||||||
|
thumbnails: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'EntityList',
|
||||||
|
icon: 'image',
|
||||||
|
components: ({ entities }) => [
|
||||||
|
{
|
||||||
|
component: EntityList,
|
||||||
|
props: {
|
||||||
|
entities,
|
||||||
|
thumbnails: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
let resultEntities: string[] = [];
|
||||||
|
async function updateResultEntities(
|
||||||
|
includedGroups: string[],
|
||||||
|
requiredGroups: string[],
|
||||||
|
excludedGroups: string[]
|
||||||
|
) {
|
||||||
|
const included = includedGroups.length
|
||||||
|
? (
|
||||||
|
await api.query(
|
||||||
|
Query.matches(
|
||||||
|
Any,
|
||||||
|
ATTR_IN,
|
||||||
|
includedGroups.map((g) => `@${g}`)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).objects
|
||||||
|
: [];
|
||||||
|
const required = requiredGroups.length
|
||||||
|
? (
|
||||||
|
await api.query(
|
||||||
|
Query.matches(
|
||||||
|
Any,
|
||||||
|
ATTR_IN,
|
||||||
|
requiredGroups.map((g) => `@${g}`)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).objects
|
||||||
|
: [];
|
||||||
|
const excluded = excludedGroups.length
|
||||||
|
? (
|
||||||
|
await api.query(
|
||||||
|
Query.matches(
|
||||||
|
Any,
|
||||||
|
ATTR_IN,
|
||||||
|
excludedGroups.map((g) => `@${g}`)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).objects
|
||||||
|
: [];
|
||||||
|
resultEntities = (Object.keys(included).length ? Object.keys(included) : Object.keys(required))
|
||||||
|
.filter((e) => !requiredGroups.length || Object.keys(required).includes(e))
|
||||||
|
.filter((e) => !Object.keys(excluded).includes(e));
|
||||||
|
}
|
||||||
|
$: updateResultEntities(includedGroups, requiredGroups, excludedGroups);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="view" data-address-multi={resultEntities}>
|
||||||
|
<h2>
|
||||||
|
<Icon plain name="intersect" />
|
||||||
|
{$i18n.t('Combine')}
|
||||||
|
</h2>
|
||||||
|
<div class="controls">
|
||||||
|
<EntitySetEditor
|
||||||
|
entities={includedGroups}
|
||||||
|
header={$i18n.t('Include') || ''}
|
||||||
|
confirmRemoveMessage={null}
|
||||||
|
on:add={(ev) => (includedGroups = [...includedGroups, ev.detail])}
|
||||||
|
on:remove={(ev) => (includedGroups = includedGroups.filter((e) => e !== ev.detail))}
|
||||||
|
/>
|
||||||
|
<EntitySetEditor
|
||||||
|
entities={requiredGroups}
|
||||||
|
header={$i18n.t('Require') || ''}
|
||||||
|
confirmRemoveMessage={null}
|
||||||
|
on:add={(ev) => (requiredGroups = [...requiredGroups, ev.detail])}
|
||||||
|
on:remove={(ev) => (requiredGroups = requiredGroups.filter((e) => e !== ev.detail))}
|
||||||
|
/>
|
||||||
|
<EntitySetEditor
|
||||||
|
entities={excludedGroups}
|
||||||
|
header={$i18n.t('Exclude') || ''}
|
||||||
|
confirmRemoveMessage={null}
|
||||||
|
on:add={(ev) => (excludedGroups = [...excludedGroups, ev.detail])}
|
||||||
|
on:remove={(ev) => (excludedGroups = excludedGroups.filter((e) => e !== ev.detail))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="entities">
|
||||||
|
<EntryView
|
||||||
|
title={$i18n.t('Matching entities') || ''}
|
||||||
|
entities={resultEntities}
|
||||||
|
widgets={combinedWidgets}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
margin-top: -0.66em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entities {
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,88 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { addEmitter } from './AddModal.svelte';
|
||||||
|
import Icon from './utils/Icon.svelte';
|
||||||
|
|
||||||
|
let dragging = false;
|
||||||
|
|
||||||
|
function onDrop(ev: DragEvent) {
|
||||||
|
if (ev.dataTransfer?.files.length) {
|
||||||
|
addEmitter.emit('files', Array.from(ev.dataTransfer?.files || []));
|
||||||
|
} // TODO: else check for URLs
|
||||||
|
dragging = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnter() {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragOver(ev: DragEvent) {
|
||||||
|
if (Array.from(ev.dataTransfer?.items || []).some((it) => it.kind === 'file')) {
|
||||||
|
dragging = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragLeave() {
|
||||||
|
dragging = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPaste(ev: ClipboardEvent) {
|
||||||
|
if (ev.clipboardData?.files.length) {
|
||||||
|
addEmitter.emit('files', Array.from(ev.clipboardData?.files || []));
|
||||||
|
} // TODO: else check for URLs
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:body
|
||||||
|
on:dragenter|preventDefault={onDragEnter}
|
||||||
|
on:dragover|preventDefault={onDragOver}
|
||||||
|
on:dragleave|preventDefault={onDragLeave}
|
||||||
|
on:drop|preventDefault={onDrop}
|
||||||
|
on:paste={onPaste}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="dropindicator" class:dragging>
|
||||||
|
<div class="content">
|
||||||
|
<div class="icon">
|
||||||
|
<Icon name="current-location" />
|
||||||
|
</div>
|
||||||
|
<p>Drop an URL, an image or a file here!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.dropindicator {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dragging {
|
||||||
|
display: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
|
||||||
|
background: rgba(0, 0, 0, 0.9);
|
||||||
|
|
||||||
|
color: var(--foreground);
|
||||||
|
border: solid 0.25em var(--foreground);
|
||||||
|
border-radius: 0.5em;
|
||||||
|
padding: 1.5em;
|
||||||
|
|
||||||
|
text-align: center;
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: 128px;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,161 @@
|
||||||
|
<script lang="ts" context="module">
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
export const selected = writable<string[]>([]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { i18n } from '../i18n';
|
||||||
|
|
||||||
|
let canvas: HTMLCanvasElement;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
function resizeCanvas() {
|
||||||
|
canvas.width = window.innerWidth;
|
||||||
|
canvas.height = window.innerHeight;
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', resizeCanvas);
|
||||||
|
resizeCanvas();
|
||||||
|
|
||||||
|
let selecting = false;
|
||||||
|
let selectAllArea: DOMRect | undefined = undefined;
|
||||||
|
let selectAllAddresses: string[] = [];
|
||||||
|
let addressesToRemove = new Set();
|
||||||
|
document.addEventListener('mousedown', (ev) => {
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
if (ev.ctrlKey || ev.metaKey) {
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
selecting = true;
|
||||||
|
addressesToRemove = new Set();
|
||||||
|
|
||||||
|
const el = document.elementFromPoint(ev.clientX, ev.clientY) as HTMLElement;
|
||||||
|
|
||||||
|
const multiElement = el.closest('[data-address-multi]') as HTMLElement | undefined;
|
||||||
|
|
||||||
|
if (multiElement) {
|
||||||
|
const banner = multiElement.querySelector('h2');
|
||||||
|
|
||||||
|
if (banner) {
|
||||||
|
const rect = banner.getBoundingClientRect();
|
||||||
|
selectAllArea = rect;
|
||||||
|
selectAllAddresses = multiElement.dataset.addressMulti?.split(',') || [];
|
||||||
|
|
||||||
|
ctx.rect(rect.left, rect.top, rect.width, rect.height);
|
||||||
|
ctx.fillStyle = '#dc322f33';
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = '#dc322f77';
|
||||||
|
ctx.font = `bold ${rect.height / 2}px Inter`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
const fix = ctx.measureText('M').actualBoundingBoxDescent / 2;
|
||||||
|
ctx.fillText(
|
||||||
|
$i18n.t('Select All'),
|
||||||
|
rect.left + rect.width / 2,
|
||||||
|
rect.top + rect.height / 2 + fix
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#dc322f77';
|
||||||
|
ctx.lineWidth = 7;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(ev.clientX, ev.clientY);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', (ev) => {
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
if (selecting) {
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
if (selectAllArea) {
|
||||||
|
if (
|
||||||
|
ev.clientX > selectAllArea.left &&
|
||||||
|
ev.clientX < selectAllArea.right &&
|
||||||
|
ev.clientY > selectAllArea.top &&
|
||||||
|
ev.clientY < selectAllArea.bottom
|
||||||
|
) {
|
||||||
|
selected.update((selected) => {
|
||||||
|
return [
|
||||||
|
...selected,
|
||||||
|
...selectAllAddresses.filter((a) => {
|
||||||
|
return !selected.includes(a);
|
||||||
|
})
|
||||||
|
];
|
||||||
|
});
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const el = document.elementFromPoint(ev.clientX, ev.clientY) as HTMLElement;
|
||||||
|
|
||||||
|
const addressElement = el.closest('[data-address]') as HTMLElement | undefined;
|
||||||
|
if (addressElement) {
|
||||||
|
const address = addressElement.dataset.address;
|
||||||
|
const selectMode = addressElement.dataset.selectMode;
|
||||||
|
if (selectMode === 'add' || selectMode === undefined) {
|
||||||
|
selected.update((selected) => {
|
||||||
|
if (address && !selected.includes(address)) {
|
||||||
|
return [...selected, address];
|
||||||
|
} else {
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (selectMode === 'remove') {
|
||||||
|
addressesToRemove.add(address);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.lineTo(ev.clientX, ev.clientY);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(ev.clientX, ev.clientY);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mouseup', () => {
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
selectAllArea = undefined;
|
||||||
|
selectAllAddresses = [];
|
||||||
|
selecting = false;
|
||||||
|
ctx?.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
for (const address of addressesToRemove) {
|
||||||
|
selected.update((selected) => {
|
||||||
|
return selected.filter((a) => a !== address);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="selectIndicator">
|
||||||
|
<canvas bind:this={canvas}></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.selectIndicator {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,111 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import UpObjectDisplay from './display/UpObject.svelte';
|
||||||
|
import Selector, { type SelectorValue } from './utils/Selector.svelte';
|
||||||
|
import IconButton from './utils/IconButton.svelte';
|
||||||
|
import { i18n } from '../i18n';
|
||||||
|
import LabelBorder from './utils/LabelBorder.svelte';
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
export let entities: string[];
|
||||||
|
export let hide = false;
|
||||||
|
|
||||||
|
export let header = '';
|
||||||
|
export let confirmRemoveMessage: string | null = $i18n.t('Are you sure you want to remove this?');
|
||||||
|
export let emptyMessage = $i18n.t('Nothing to show.');
|
||||||
|
|
||||||
|
let adding = false;
|
||||||
|
let selector: Selector;
|
||||||
|
|
||||||
|
$: if (adding && selector) selector.focus();
|
||||||
|
|
||||||
|
async function add(ev: CustomEvent<SelectorValue>) {
|
||||||
|
if (ev.detail.t !== 'Address') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dispatch('add', ev.detail.c);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(address: string) {
|
||||||
|
if (!confirmRemoveMessage || confirm(confirmRemoveMessage)) {
|
||||||
|
dispatch('remove', address);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<LabelBorder {hide}>
|
||||||
|
<span slot="header">{header}</span>
|
||||||
|
|
||||||
|
{#if adding}
|
||||||
|
<div class="selector">
|
||||||
|
<Selector
|
||||||
|
bind:this={selector}
|
||||||
|
types={['Address', 'NewAddress']}
|
||||||
|
on:input={add}
|
||||||
|
on:focus={(ev) => {
|
||||||
|
if (!ev.detail) adding = false;
|
||||||
|
}}
|
||||||
|
placeholder={$i18n.t('Choose an entity...') || ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="body">
|
||||||
|
<div class="group-list">
|
||||||
|
{#each entities as entity}
|
||||||
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
|
<div
|
||||||
|
class="group"
|
||||||
|
on:mouseenter={() => dispatch('highlighted', entity)}
|
||||||
|
on:mouseleave={() => dispatch('highlighted', undefined)}
|
||||||
|
>
|
||||||
|
<UpObjectDisplay address={entity} link />
|
||||||
|
<IconButton subdued name="x-circle" on:click={() => remove(entity)} />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="no-groups">
|
||||||
|
{emptyMessage}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if !adding}
|
||||||
|
<div class="add-button">
|
||||||
|
<IconButton outline small name="folder-plus" on:click={() => (adding = true)} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</LabelBorder>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.group-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem 0.2rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
display: flex;
|
||||||
|
align-items: start;
|
||||||
|
|
||||||
|
.group-list {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
padding-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-groups {
|
||||||
|
opacity: 0.66;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,150 @@
|
||||||
|
<script lang="ts" context="module">
|
||||||
|
import type { ComponentType } from 'svelte';
|
||||||
|
export interface WidgetComponent {
|
||||||
|
component: ComponentType;
|
||||||
|
props: { [key: string]: unknown };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Widget {
|
||||||
|
name: string;
|
||||||
|
icon?: string;
|
||||||
|
components: (input: {
|
||||||
|
entries: UpEntry[];
|
||||||
|
entities: string[];
|
||||||
|
group?: string;
|
||||||
|
address?: string;
|
||||||
|
}) => Array<WidgetComponent>;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import EntryList from './widgets/EntryList.svelte';
|
||||||
|
import type { UpEntry } from '@upnd/upend';
|
||||||
|
import Icon from './utils/Icon.svelte';
|
||||||
|
import IconButton from './utils/IconButton.svelte';
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import UpObject from './display/UpObject.svelte';
|
||||||
|
import LabelBorder from './utils/LabelBorder.svelte';
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
export let entries: UpEntry[] = [];
|
||||||
|
export let entities: string[] = [];
|
||||||
|
export let widgets: Widget[] | undefined = undefined;
|
||||||
|
export let initialWidget: string | undefined = undefined;
|
||||||
|
export let title: string | undefined = undefined;
|
||||||
|
export let group: string | undefined = undefined;
|
||||||
|
export let address: string | undefined = undefined;
|
||||||
|
export let icon: string | undefined = undefined;
|
||||||
|
export let highlighted = false;
|
||||||
|
|
||||||
|
let currentWidget: string | undefined;
|
||||||
|
|
||||||
|
function switchWidget(widget: string) {
|
||||||
|
currentWidget = widget;
|
||||||
|
dispatch('widgetSwitched', currentWidget);
|
||||||
|
}
|
||||||
|
|
||||||
|
let availableWidgets: Widget[] = [];
|
||||||
|
$: {
|
||||||
|
availableWidgets = [];
|
||||||
|
|
||||||
|
if (entries.length) {
|
||||||
|
availableWidgets = [
|
||||||
|
...availableWidgets,
|
||||||
|
{
|
||||||
|
name: 'Entry List',
|
||||||
|
icon: 'table',
|
||||||
|
components: ({ entries }) => [
|
||||||
|
{
|
||||||
|
component: EntryList,
|
||||||
|
props: { entries, columns: 'entity, attribute, value' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widgets?.length) {
|
||||||
|
availableWidgets = [...widgets, ...availableWidgets];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initialWidget && availableWidgets.map((w) => w.name).includes(initialWidget)) {
|
||||||
|
currentWidget = initialWidget;
|
||||||
|
} else {
|
||||||
|
currentWidget = availableWidgets[0].name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let components: WidgetComponent[] = [];
|
||||||
|
$: {
|
||||||
|
components =
|
||||||
|
availableWidgets
|
||||||
|
.find((w) => w.name === currentWidget)
|
||||||
|
?.components({ entries, entities, group, address }) || [];
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<LabelBorder hide={entries.length === 0 && entities.length === 0}>
|
||||||
|
<svelte:fragment slot="header-full">
|
||||||
|
<h3 class:highlighted>
|
||||||
|
{#if group}
|
||||||
|
{#if icon}
|
||||||
|
<div class="icon">
|
||||||
|
<Icon name={icon} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<UpObject link address={group} labels={title ? [title] : undefined} />
|
||||||
|
{:else}
|
||||||
|
{#if icon}
|
||||||
|
<div class="icon">
|
||||||
|
<Icon name={icon} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{title || ''}
|
||||||
|
{/if}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{#if currentWidget && availableWidgets.length > 1}
|
||||||
|
<div class="views">
|
||||||
|
{#each availableWidgets as widget (widget.name)}
|
||||||
|
<IconButton
|
||||||
|
name={widget.icon || 'cube'}
|
||||||
|
title={widget.name}
|
||||||
|
active={widget.name === currentWidget}
|
||||||
|
--active-color="var(--foreground)"
|
||||||
|
on:click={() => switchWidget(widget.name)}
|
||||||
|
>
|
||||||
|
{widget.name}
|
||||||
|
</IconButton>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</svelte:fragment>
|
||||||
|
{#each components as component}
|
||||||
|
<svelte:component this={component.component} {...component.props || {}} on:change />
|
||||||
|
{/each}
|
||||||
|
</LabelBorder>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.icon {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 1.25em;
|
||||||
|
margin-top: -0.3em;
|
||||||
|
position: relative;
|
||||||
|
bottom: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
transition: text-shadow 0.2s;
|
||||||
|
|
||||||
|
&.highlighted {
|
||||||
|
text-shadow: #cb4b16 0 0 0.5em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.views {
|
||||||
|
display: flex;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,174 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { ATTR_IN, ATTR_LABEL } from '@upnd/upend/constants';
|
||||||
|
import api from '$lib/api';
|
||||||
|
import { i18n } from '../i18n';
|
||||||
|
import Spinner from './utils/Spinner.svelte';
|
||||||
|
import UpObject from './display/UpObject.svelte';
|
||||||
|
|
||||||
|
const groups = (async () => {
|
||||||
|
const data = await api.query(`(matches ? "${ATTR_IN}" ?)`);
|
||||||
|
|
||||||
|
const addresses = data.entries
|
||||||
|
.filter((e) => e.value.t === 'Address')
|
||||||
|
.map((e) => e.value.c) as string[];
|
||||||
|
|
||||||
|
const sortedAddresses = [...new Set(addresses)]
|
||||||
|
.map((address) => ({
|
||||||
|
address,
|
||||||
|
count: addresses.filter((a) => a === address).length
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.count - a.count);
|
||||||
|
|
||||||
|
const addressesString = sortedAddresses.map(({ address }) => `@${address}`).join(' ');
|
||||||
|
const labels = (
|
||||||
|
await api.query(`(matches (in ${addressesString}) "${ATTR_LABEL}" ? )`)
|
||||||
|
).entries.filter((e) => e.value.t === 'String');
|
||||||
|
|
||||||
|
const display = sortedAddresses.map(({ address, count }) => ({
|
||||||
|
address,
|
||||||
|
labels: labels
|
||||||
|
.filter((e) => e.entity === address)
|
||||||
|
.map((e) => e.value.c)
|
||||||
|
.sort() as string[],
|
||||||
|
count
|
||||||
|
}));
|
||||||
|
|
||||||
|
display
|
||||||
|
.sort((a, b) => (a.labels[0] || '').localeCompare(b.labels[0] || ''))
|
||||||
|
.sort((a, b) => b.count - a.count);
|
||||||
|
|
||||||
|
const labelsToGroups = new Map<string, string[]>();
|
||||||
|
labels.forEach((e) => {
|
||||||
|
const groups = labelsToGroups.get(e.value.c as string) || [];
|
||||||
|
if (!groups.includes(e.entity)) {
|
||||||
|
groups.push(e.entity);
|
||||||
|
}
|
||||||
|
labelsToGroups.set(e.value.c as string, groups);
|
||||||
|
});
|
||||||
|
const duplicates = [...labelsToGroups.entries()]
|
||||||
|
.filter(([_, groups]) => groups.length > 1)
|
||||||
|
.map(([label, groups]) => ({ label, groups }));
|
||||||
|
|
||||||
|
return {
|
||||||
|
groups: display,
|
||||||
|
total: sortedAddresses.length,
|
||||||
|
duplicateGroups: duplicates
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
let clientWidth: number;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="groups" bind:clientWidth class:small={clientWidth < 600}>
|
||||||
|
<h2>{$i18n.t('Groups')}</h2>
|
||||||
|
<div class="main">
|
||||||
|
{#await groups}
|
||||||
|
<Spinner centered />
|
||||||
|
{:then data}
|
||||||
|
<ul>
|
||||||
|
{#each data.groups as group}
|
||||||
|
<li class="group" data-address={group.address}>
|
||||||
|
<UpObject link address={group.address} labels={group.labels} />
|
||||||
|
<div class="count">{group.count}</div>
|
||||||
|
</li>
|
||||||
|
{:else}
|
||||||
|
<li>No groups?</li>
|
||||||
|
{/each}
|
||||||
|
{#if data.groups && data.total > data.groups.length}
|
||||||
|
<li>+ {data.total - data.groups.length}...</li>
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
{#if data.duplicateGroups.length > 0}
|
||||||
|
<h3>{$i18n.t('Duplicate groups')}</h3>
|
||||||
|
<ul class="duplicate">
|
||||||
|
{#each data.duplicateGroups as { label, groups }}
|
||||||
|
<li class="duplicate-group">
|
||||||
|
<div class="label">{label}</div>
|
||||||
|
<ul>
|
||||||
|
{#each groups as group}
|
||||||
|
<li>
|
||||||
|
<UpObject link address={group} backpath={2} />
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{/await}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '../styles/colors';
|
||||||
|
|
||||||
|
.groups {
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
flex-grow: 1;
|
||||||
|
height: 0;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
overflow: hidden auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-top: -0.66em;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5em;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.66em;
|
||||||
|
margin-left: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duplicate {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duplicate-group {
|
||||||
|
flex-basis: 49%;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--foreground);
|
||||||
|
padding: 0.5rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
|
ul {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.groups.small {
|
||||||
|
ul {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,585 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import EntryView, { type Widget } from './EntryView.svelte';
|
||||||
|
import { useEntity } from '$lib/entity';
|
||||||
|
import UpObject from './display/UpObject.svelte';
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { derived, type Readable } from 'svelte/store';
|
||||||
|
import { Query, type UpEntry } from '@upnd/upend';
|
||||||
|
import Spinner from './utils/Spinner.svelte';
|
||||||
|
import NotesEditor from './utils/NotesEditor.svelte';
|
||||||
|
import type { WidgetChange } from '../types/base';
|
||||||
|
import type { Address, EntityInfo } from '@upnd/upend/types';
|
||||||
|
import IconButton from './utils/IconButton.svelte';
|
||||||
|
import BlobViewer from './display/BlobViewer.svelte';
|
||||||
|
import { i18n } from '../i18n';
|
||||||
|
import EntryList from './widgets/EntryList.svelte';
|
||||||
|
import api from '$lib/api';
|
||||||
|
import EntityList from './widgets/EntityList.svelte';
|
||||||
|
import { ATTR_IN, ATTR_KEY, ATTR_LABEL, ATTR_OF } from '@upnd/upend/constants';
|
||||||
|
import InspectGroups from './InspectGroups.svelte';
|
||||||
|
import InspectTypeEditor from './InspectTypeEditor.svelte';
|
||||||
|
import LabelBorder from './utils/LabelBorder.svelte';
|
||||||
|
import { debug } from 'debug';
|
||||||
|
import { Any } from '@upnd/upend/query';
|
||||||
|
import { isDefined } from '$lib/util/werk';
|
||||||
|
|
||||||
|
const dbg = debug('kestrel:Inspect');
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
export let address: string;
|
||||||
|
export let detail: boolean;
|
||||||
|
let showAsEntries = false;
|
||||||
|
let highlightedType: string | undefined;
|
||||||
|
|
||||||
|
let blobHandled = false;
|
||||||
|
|
||||||
|
$: ({ entity, entityInfo, error, revalidate } = useEntity(address));
|
||||||
|
|
||||||
|
$: allTypes = derived(
|
||||||
|
entityInfo,
|
||||||
|
($entityInfo, set) => {
|
||||||
|
getAllTypes($entityInfo!).then((allTypes) => {
|
||||||
|
set(allTypes);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
) as Readable<{
|
||||||
|
[key: string]: {
|
||||||
|
labels: string[];
|
||||||
|
attributes: string[];
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
$: sortedTypes = Object.entries($allTypes)
|
||||||
|
.sort(([a, _], [b, __]) => a.localeCompare(b))
|
||||||
|
.sort(([_, a], [__, b]) => a.attributes.length - b.attributes.length);
|
||||||
|
|
||||||
|
async function getAllTypes(entityInfo: EntityInfo) {
|
||||||
|
const allTypes: Record<Address, { labels: string[]; attributes: string[] }> = {};
|
||||||
|
|
||||||
|
if (!entityInfo) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeAddresses: string[] = [
|
||||||
|
await api.getAddress(entityInfo.t),
|
||||||
|
...($entity?.attr[ATTR_IN] || []).map((e) => e.value.c as string)
|
||||||
|
];
|
||||||
|
const typeAddressesIn = typeAddresses.map((addr) => `@${addr}`).join(' ');
|
||||||
|
|
||||||
|
const labelsQuery = await api.query(`(matches (in ${typeAddressesIn}) "${ATTR_LABEL}" ?)`);
|
||||||
|
|
||||||
|
typeAddresses.forEach((address) => {
|
||||||
|
let labels = labelsQuery.getObject(address).identify();
|
||||||
|
|
||||||
|
let typeLabel: string | undefined;
|
||||||
|
if (typeLabel) {
|
||||||
|
labels.unshift(typeLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
allTypes[address] = {
|
||||||
|
labels,
|
||||||
|
attributes: []
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const attributes = await api.query(`(matches ? "${ATTR_OF}" (in ${typeAddressesIn}))`);
|
||||||
|
await Promise.all(
|
||||||
|
typeAddresses.map(async (address) => {
|
||||||
|
allTypes[address].attributes = (
|
||||||
|
await Promise.all(
|
||||||
|
(attributes.getObject(address).attr[`~${ATTR_OF}`] || []).map(async (e) => {
|
||||||
|
try {
|
||||||
|
const { t, c } = await api.addressToComponents(e.entity);
|
||||||
|
if (t == 'Attribute') {
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).filter(isDefined);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const result: Record<Address, { labels: string[]; attributes: string[] }> = {};
|
||||||
|
Object.keys(allTypes).forEach((addr) => {
|
||||||
|
if (allTypes[addr].attributes.length > 0) {
|
||||||
|
result[addr] = allTypes[addr];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
let untypedProperties = [] as UpEntry[];
|
||||||
|
let untypedLinks = [] as UpEntry[];
|
||||||
|
|
||||||
|
$: {
|
||||||
|
untypedProperties = [];
|
||||||
|
untypedLinks = [];
|
||||||
|
|
||||||
|
($entity?.attributes || []).forEach((entry) => {
|
||||||
|
const entryTypes = Object.entries($allTypes || {}).filter(([_, t]) =>
|
||||||
|
t.attributes.includes(entry.attribute)
|
||||||
|
);
|
||||||
|
if (entryTypes.length === 0) {
|
||||||
|
if (entry.value.t === 'Address') {
|
||||||
|
untypedLinks.push(entry);
|
||||||
|
} else {
|
||||||
|
untypedProperties.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
untypedProperties = untypedProperties;
|
||||||
|
untypedLinks = untypedLinks;
|
||||||
|
}
|
||||||
|
|
||||||
|
$: filteredUntypedProperties = untypedProperties.filter(
|
||||||
|
(entry) =>
|
||||||
|
![
|
||||||
|
ATTR_LABEL,
|
||||||
|
ATTR_IN,
|
||||||
|
ATTR_KEY,
|
||||||
|
'NOTE',
|
||||||
|
'LAST_VISITED',
|
||||||
|
'NUM_VISITED',
|
||||||
|
'LAST_ATTRIBUTE_WIDGET'
|
||||||
|
].includes(entry.attribute)
|
||||||
|
);
|
||||||
|
|
||||||
|
$: currentUntypedProperties = filteredUntypedProperties;
|
||||||
|
|
||||||
|
$: filteredUntypedLinks = untypedLinks.filter(
|
||||||
|
(entry) => ![ATTR_IN, ATTR_OF].includes(entry.attribute)
|
||||||
|
);
|
||||||
|
|
||||||
|
$: currentUntypedLinks = filteredUntypedLinks;
|
||||||
|
|
||||||
|
$: currentBacklinks =
|
||||||
|
$entity?.backlinks.filter((entry) => ![ATTR_IN, ATTR_OF].includes(entry.attribute)) || [];
|
||||||
|
|
||||||
|
$: tagged = $entity?.attr[`~${ATTR_IN}`] || [];
|
||||||
|
|
||||||
|
let attributesUsed: UpEntry[] = [];
|
||||||
|
$: {
|
||||||
|
if ($entityInfo?.t === 'Attribute') {
|
||||||
|
api
|
||||||
|
.query(`(matches ? "${$entityInfo.c}" ?)`)
|
||||||
|
.then((result) => (attributesUsed = result.entries));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let correctlyTagged: Address[] | undefined;
|
||||||
|
let incorrectlyTagged: Address[] | undefined;
|
||||||
|
$: {
|
||||||
|
if ($entity?.attr[`~${ATTR_OF}`]) {
|
||||||
|
fetchCorrectlyTagged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCorrectlyTagged() {
|
||||||
|
const attributes = (
|
||||||
|
await Promise.all(
|
||||||
|
($entity?.attr[`~${ATTR_OF}`] ?? []).map((e) => api.addressToComponents(e.entity))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.filter((ac) => ac.t == 'Attribute')
|
||||||
|
.map((ac) => ac.c)
|
||||||
|
.filter(isDefined);
|
||||||
|
|
||||||
|
const attributeQuery = await api.query(
|
||||||
|
Query.matches(
|
||||||
|
tagged.map((t) => `@${t.entity}`),
|
||||||
|
attributes,
|
||||||
|
Any
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
correctlyTagged = [];
|
||||||
|
incorrectlyTagged = [];
|
||||||
|
|
||||||
|
for (const element of tagged) {
|
||||||
|
const entity = attributeQuery.getObject(element.entity);
|
||||||
|
if (attributes.every((attr) => entity.attr[attr])) {
|
||||||
|
correctlyTagged = [...correctlyTagged, element.entity];
|
||||||
|
} else {
|
||||||
|
incorrectlyTagged = [...incorrectlyTagged, element.entity];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onChange(ev: CustomEvent<WidgetChange>) {
|
||||||
|
dbg('onChange', ev.detail);
|
||||||
|
const change = ev.detail;
|
||||||
|
switch (change.type) {
|
||||||
|
case 'create':
|
||||||
|
await api.putEntry({
|
||||||
|
entity: address,
|
||||||
|
attribute: change.attribute,
|
||||||
|
value: change.value
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
await api.deleteEntry(change.address);
|
||||||
|
break;
|
||||||
|
case 'update':
|
||||||
|
await api.putEntityAttribute(address, change.attribute, change.value);
|
||||||
|
break;
|
||||||
|
case 'entry-add':
|
||||||
|
await api.putEntry({
|
||||||
|
entity: change.address,
|
||||||
|
attribute: ATTR_IN,
|
||||||
|
value: { t: 'Address', c: address }
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'entry-delete': {
|
||||||
|
const inEntry = $entity?.attr[`~${ATTR_IN}`].find((e) => e.entity === change.address);
|
||||||
|
if (inEntry) {
|
||||||
|
await api.deleteEntry(inEntry.address);
|
||||||
|
} else {
|
||||||
|
console.warn("Couldn't find IN entry for entity %s?!", change.address);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
console.error('Unimplemented AttributeChange', change);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
revalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
let identities = [address];
|
||||||
|
|
||||||
|
function onResolved(ev: CustomEvent<string[]>) {
|
||||||
|
identities = ev.detail;
|
||||||
|
dispatch('resolved', ev.detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteObject() {
|
||||||
|
if (confirm(`${$i18n.t('Really delete')} "${identities.join(' | ')}"?`)) {
|
||||||
|
await api.deleteEntry(address);
|
||||||
|
dispatch('close');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const attributeWidgets: Widget[] = [
|
||||||
|
{
|
||||||
|
name: 'List',
|
||||||
|
icon: 'list-check',
|
||||||
|
components: ({ entries }) => [
|
||||||
|
{
|
||||||
|
component: EntryList,
|
||||||
|
props: {
|
||||||
|
entries,
|
||||||
|
columns: 'attribute, value'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const linkWidgets: Widget[] = [
|
||||||
|
{
|
||||||
|
name: 'List',
|
||||||
|
icon: 'list-check',
|
||||||
|
components: ({ entries, group }) => [
|
||||||
|
{
|
||||||
|
component: EntryList,
|
||||||
|
props: {
|
||||||
|
entries,
|
||||||
|
columns: 'attribute, value',
|
||||||
|
attributes: group ? $allTypes[group]?.attributes : [] || []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Entity List',
|
||||||
|
icon: 'image',
|
||||||
|
components: ({ entries, address }) => [
|
||||||
|
{
|
||||||
|
component: EntityList,
|
||||||
|
props: {
|
||||||
|
address,
|
||||||
|
entities: entries.filter((e) => e.value.t == 'Address').map((e) => e.value.c),
|
||||||
|
thumbnails: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const taggedWidgets: Widget[] = [
|
||||||
|
{
|
||||||
|
name: 'List',
|
||||||
|
icon: 'list-check',
|
||||||
|
components: ({ entries, address }) => [
|
||||||
|
{
|
||||||
|
component: EntityList,
|
||||||
|
props: {
|
||||||
|
address,
|
||||||
|
entities: entries.map((e) => e.entity),
|
||||||
|
thumbnails: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'EntityList',
|
||||||
|
icon: 'image',
|
||||||
|
components: ({ entries, address }) => [
|
||||||
|
{
|
||||||
|
component: EntityList,
|
||||||
|
props: {
|
||||||
|
address,
|
||||||
|
entities: entries.map((e) => e.entity),
|
||||||
|
thumbnails: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
$: entity.subscribe(async (object) => {
|
||||||
|
if (object && object.listing?.entries.length) {
|
||||||
|
dbg('Updating visit stats for %o', object);
|
||||||
|
await api.putEntityAttribute(
|
||||||
|
object.address,
|
||||||
|
'LAST_VISITED',
|
||||||
|
{
|
||||||
|
t: 'Number',
|
||||||
|
c: new Date().getTime() / 1000
|
||||||
|
},
|
||||||
|
'IMPLICIT'
|
||||||
|
);
|
||||||
|
|
||||||
|
await api.putEntityAttribute(
|
||||||
|
object.address,
|
||||||
|
'NUM_VISITED',
|
||||||
|
{
|
||||||
|
t: 'Number',
|
||||||
|
c: (parseInt(String(object.get('NUM_VISITED'))) || 0) + 1
|
||||||
|
},
|
||||||
|
'IMPLICIT'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="inspect"
|
||||||
|
class:detail
|
||||||
|
class:blob={blobHandled}
|
||||||
|
data-address-multi={($entity?.attr['~IN']?.map((e) => e.entity) || []).join(',')}
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<h2>
|
||||||
|
{#if $entity}
|
||||||
|
<UpObject banner {address} on:resolved={onResolved} />
|
||||||
|
{:else}
|
||||||
|
<Spinner centered />
|
||||||
|
{/if}
|
||||||
|
</h2>
|
||||||
|
</header>
|
||||||
|
{#if !showAsEntries}
|
||||||
|
<div class="main-content">
|
||||||
|
<div class="detail-col">
|
||||||
|
<div class="blob-viewer">
|
||||||
|
<BlobViewer {address} {detail} on:handled={(ev) => (blobHandled = ev.detail)} />
|
||||||
|
</div>
|
||||||
|
{#if !$error && $entity}
|
||||||
|
<InspectGroups
|
||||||
|
{entity}
|
||||||
|
on:highlighted={(ev) => (highlightedType = ev.detail)}
|
||||||
|
on:change={() => revalidate()}
|
||||||
|
/>
|
||||||
|
<div class="properties">
|
||||||
|
<NotesEditor {address} on:change={onChange} />
|
||||||
|
<InspectTypeEditor {entity} on:change={() => revalidate()} />
|
||||||
|
{#each sortedTypes as [typeAddr, { labels, attributes }]}
|
||||||
|
<EntryView
|
||||||
|
entries={($entity?.attributes || []).filter((e) =>
|
||||||
|
attributes.includes(e.attribute)
|
||||||
|
)}
|
||||||
|
widgets={linkWidgets}
|
||||||
|
on:change={onChange}
|
||||||
|
highlighted={highlightedType == typeAddr}
|
||||||
|
title={labels.join(' | ')}
|
||||||
|
group={typeAddr}
|
||||||
|
{address}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if currentUntypedProperties.length > 0}
|
||||||
|
<EntryView
|
||||||
|
title={$i18n.t('Other Properties') || ''}
|
||||||
|
widgets={attributeWidgets}
|
||||||
|
entries={currentUntypedProperties}
|
||||||
|
on:change={onChange}
|
||||||
|
{address}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if currentUntypedLinks.length > 0}
|
||||||
|
<EntryView
|
||||||
|
title={$i18n.t('Links') || ''}
|
||||||
|
widgets={linkWidgets}
|
||||||
|
entries={currentUntypedLinks}
|
||||||
|
on:change={onChange}
|
||||||
|
{address}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !correctlyTagged || !incorrectlyTagged}
|
||||||
|
<EntryView
|
||||||
|
title={`${$i18n.t('Members')}`}
|
||||||
|
widgets={taggedWidgets}
|
||||||
|
entries={tagged}
|
||||||
|
on:change={onChange}
|
||||||
|
{address}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<EntryView
|
||||||
|
title={`${$i18n.t('Typed Members')} (${correctlyTagged.length})`}
|
||||||
|
widgets={taggedWidgets}
|
||||||
|
entries={tagged.filter((e) => correctlyTagged?.includes(e.entity))}
|
||||||
|
on:change={onChange}
|
||||||
|
{address}
|
||||||
|
/>
|
||||||
|
<EntryView
|
||||||
|
title={`${$i18n.t('Untyped members')} (${incorrectlyTagged.length})`}
|
||||||
|
widgets={taggedWidgets}
|
||||||
|
entries={tagged.filter((e) => incorrectlyTagged?.includes(e.entity))}
|
||||||
|
on:change={onChange}
|
||||||
|
{address}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if currentBacklinks.length > 0}
|
||||||
|
<EntryView
|
||||||
|
title={`${$i18n.t('Referred to')} (${currentBacklinks.length})`}
|
||||||
|
entries={currentBacklinks}
|
||||||
|
on:change={onChange}
|
||||||
|
{address}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if $entityInfo?.t === 'Attribute'}
|
||||||
|
<LabelBorder>
|
||||||
|
<span slot="header">{$i18n.t('Used')} ({attributesUsed.length})</span>
|
||||||
|
<EntryList columns="entity,value" entries={attributesUsed} orderByValue />
|
||||||
|
</LabelBorder>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="error">
|
||||||
|
{$error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="entries">
|
||||||
|
<h2>{$i18n.t('Attributes')}</h2>
|
||||||
|
<EntryList
|
||||||
|
entries={$entity?.attributes || []}
|
||||||
|
columns={detail ? 'timestamp, provenance, attribute, value' : 'attribute, value'}
|
||||||
|
on:change={onChange}
|
||||||
|
/>
|
||||||
|
<h2>{$i18n.t('Backlinks')}</h2>
|
||||||
|
<EntryList
|
||||||
|
entries={$entity?.backlinks || []}
|
||||||
|
columns={detail ? 'timestamp, provenance, entity, attribute' : 'entity, attribute'}
|
||||||
|
on:change={onChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="footer">
|
||||||
|
<IconButton
|
||||||
|
name="detail"
|
||||||
|
title={$i18n.t('Show as entries') || ''}
|
||||||
|
active={showAsEntries}
|
||||||
|
on:click={() => (showAsEntries = !showAsEntries)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<IconButton
|
||||||
|
name="trash"
|
||||||
|
outline
|
||||||
|
subdued
|
||||||
|
color="#dc322f"
|
||||||
|
on:click={deleteObject}
|
||||||
|
title={$i18n.t('Delete object') || ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
header h2 {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inspect,
|
||||||
|
.main-content {
|
||||||
|
flex: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties {
|
||||||
|
flex: auto;
|
||||||
|
height: 0; // https://stackoverflow.com/a/14964944
|
||||||
|
min-height: 12em;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 1600px) {
|
||||||
|
.inspect.detail {
|
||||||
|
.main-content {
|
||||||
|
position: relative;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.blob {
|
||||||
|
.detail-col {
|
||||||
|
width: 33%;
|
||||||
|
flex-grow: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blob-viewer {
|
||||||
|
width: 65%;
|
||||||
|
height: 100%;
|
||||||
|
position: absolute;
|
||||||
|
left: 1%;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content .detail-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entries {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: 2rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,46 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import api from '$lib/api';
|
||||||
|
import { ATTR_IN } from '@upnd/upend/constants';
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import type { UpObject } from '@upnd/upend';
|
||||||
|
import type { Readable } from 'svelte/store';
|
||||||
|
import EntitySetEditor from './EntitySetEditor.svelte';
|
||||||
|
import { i18n } from '../i18n';
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
export let entity: Readable<UpObject | undefined>;
|
||||||
|
|
||||||
|
$: groups = Object.fromEntries(
|
||||||
|
($entity?.attr[ATTR_IN] || []).map((e) => [e.value.c as string, e.address])
|
||||||
|
);
|
||||||
|
|
||||||
|
async function addGroup(address: string) {
|
||||||
|
if (!$entity) return;
|
||||||
|
|
||||||
|
await api.putEntry([
|
||||||
|
{
|
||||||
|
entity: $entity.address,
|
||||||
|
attribute: ATTR_IN,
|
||||||
|
value: {
|
||||||
|
t: 'Address',
|
||||||
|
c: address
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
dispatch('change');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeGroup(address: string) {
|
||||||
|
await api.deleteEntry(groups[address]);
|
||||||
|
dispatch('change');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<EntitySetEditor
|
||||||
|
entities={Object.keys(groups)}
|
||||||
|
header={$i18n.t('Groups') || ''}
|
||||||
|
hide={Object.keys(groups).length === 0}
|
||||||
|
on:add={(e) => addGroup(e.detail)}
|
||||||
|
on:remove={(e) => removeGroup(e.detail)}
|
||||||
|
on:highlighted
|
||||||
|
/>
|
|
@ -0,0 +1,131 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import UpObjectDisplay from './display/UpObject.svelte';
|
||||||
|
import Selector, { type SelectorValue } from './utils/Selector.svelte';
|
||||||
|
import IconButton from './utils/IconButton.svelte';
|
||||||
|
import api from '$lib/api';
|
||||||
|
import { i18n } from '../i18n';
|
||||||
|
import type { UpObject, UpEntry } from '@upnd/upend';
|
||||||
|
import type { Readable } from 'svelte/store';
|
||||||
|
import { ATTR_OF } from '@upnd/upend/constants';
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import LabelBorder from './utils/LabelBorder.svelte';
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
export let entity: Readable<UpObject | undefined>;
|
||||||
|
|
||||||
|
let adding = false;
|
||||||
|
let typeSelector: Selector;
|
||||||
|
|
||||||
|
$: if (adding && typeSelector) typeSelector.focus();
|
||||||
|
|
||||||
|
$: typeEntries = $entity?.attr[`~${ATTR_OF}`] || [];
|
||||||
|
|
||||||
|
async function add(ev: CustomEvent<SelectorValue>) {
|
||||||
|
if (!$entity || ev.detail.t !== 'Attribute') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.putEntry({
|
||||||
|
entity: {
|
||||||
|
t: 'Attribute',
|
||||||
|
c: ev.detail.name
|
||||||
|
},
|
||||||
|
attribute: ATTR_OF,
|
||||||
|
value: { t: 'Address', c: $entity.address }
|
||||||
|
});
|
||||||
|
dispatch('change');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(entry: UpEntry) {
|
||||||
|
if (!$entity) return;
|
||||||
|
|
||||||
|
let really = confirm(
|
||||||
|
$i18n.t('Really remove "{{attributeName}}" from "{{typeName}}"?', {
|
||||||
|
attributeName: (await api.addressToComponents(entry.entity)).c,
|
||||||
|
typeName: $entity.identify().join('/')
|
||||||
|
}) || ''
|
||||||
|
);
|
||||||
|
|
||||||
|
if (really) {
|
||||||
|
await api.deleteEntry(entry.address);
|
||||||
|
dispatch('change');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if typeEntries.length || $entity?.attr['~IN']?.length}
|
||||||
|
<LabelBorder hide={typeEntries.length === 0}>
|
||||||
|
<span slot="header">{$i18n.t('Type Attributes')}</span>
|
||||||
|
{#if adding}
|
||||||
|
<div class="selector">
|
||||||
|
<Selector
|
||||||
|
bind:this={typeSelector}
|
||||||
|
types={['Attribute', 'NewAttribute']}
|
||||||
|
on:input={add}
|
||||||
|
placeholder={$i18n.t('Assign an attribute to this type...') || ''}
|
||||||
|
on:focus={(ev) => {
|
||||||
|
if (!ev.detail) adding = false;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="body">
|
||||||
|
<ul class="attributes">
|
||||||
|
{#each typeEntries as typeEntry}
|
||||||
|
<li class="attribute">
|
||||||
|
<div class="label">
|
||||||
|
<UpObjectDisplay address={typeEntry.entity} link />
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<IconButton name="x-circle" on:click={() => remove(typeEntry)} />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{:else}
|
||||||
|
<li class="no-attributes">
|
||||||
|
{$i18n.t('No attributes assigned to this type.')}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
<div class="add-button">
|
||||||
|
<IconButton outline small name="plus-circle" on:click={() => (adding = true)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</LabelBorder>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.attributes {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attribute {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
display: flex;
|
||||||
|
align-items: start;
|
||||||
|
|
||||||
|
.attributes {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-attributes {
|
||||||
|
opacity: 0.66;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,75 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import api from '$lib/api';
|
||||||
|
import { ATTR_IN } from '@upnd/upend/constants';
|
||||||
|
import { i18n } from '../i18n';
|
||||||
|
import { Query, UpListing } from '@upnd/upend';
|
||||||
|
import EntitySetEditor from './EntitySetEditor.svelte';
|
||||||
|
import { Any } from '@upnd/upend/query';
|
||||||
|
|
||||||
|
export let entities: string[];
|
||||||
|
|
||||||
|
let groups: string[] = [];
|
||||||
|
let groupListing: UpListing | undefined = undefined;
|
||||||
|
async function updateGroups() {
|
||||||
|
const currentEntities = entities.concat();
|
||||||
|
const allGroups = await api.query(
|
||||||
|
Query.matches(
|
||||||
|
currentEntities.map((e) => `@${e}`),
|
||||||
|
ATTR_IN,
|
||||||
|
Any
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const commonGroups = new Set(
|
||||||
|
allGroups.values
|
||||||
|
.filter((v) => v.t == 'Address')
|
||||||
|
.map((v) => v.c as string)
|
||||||
|
.filter((groupAddr) => {
|
||||||
|
return Object.values(allGroups.objects).every((obj) => {
|
||||||
|
return obj.attr[ATTR_IN].some((v) => v.value.c === groupAddr);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (entities.toString() == currentEntities.toString()) {
|
||||||
|
groups = Array.from(commonGroups);
|
||||||
|
groupListing = allGroups;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$: entities && updateGroups();
|
||||||
|
|
||||||
|
async function addGroup(address: string) {
|
||||||
|
await api.putEntry(
|
||||||
|
entities.map((entity) => ({
|
||||||
|
entity,
|
||||||
|
attribute: ATTR_IN,
|
||||||
|
value: {
|
||||||
|
t: 'Address',
|
||||||
|
c: address
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
await updateGroups();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeGroup(address: string) {
|
||||||
|
await Promise.all(
|
||||||
|
entities.map((entity) => {
|
||||||
|
const group = groupListing?.objects[entity].attr[ATTR_IN].find(
|
||||||
|
(v) => v.value.c === address
|
||||||
|
);
|
||||||
|
if (group) {
|
||||||
|
return api.deleteEntry(group.address);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await updateGroups();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<EntitySetEditor
|
||||||
|
entities={groups}
|
||||||
|
header={$i18n.t('Common groups') || ''}
|
||||||
|
on:add={(ev) => addGroup(ev.detail)}
|
||||||
|
on:remove={(ev) => removeGroup(ev.detail)}
|
||||||
|
/>
|
|
@ -0,0 +1,77 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { i18n } from '../i18n';
|
||||||
|
import { selected } from './EntitySelect.svelte';
|
||||||
|
import EntryView from './EntryView.svelte';
|
||||||
|
import MultiGroupEditor from './MultiGroupEditor.svelte';
|
||||||
|
import Icon from './utils/Icon.svelte';
|
||||||
|
import EntityList from './widgets/EntityList.svelte';
|
||||||
|
import type { Widget } from '$lib/components/EntryView.svelte';
|
||||||
|
|
||||||
|
const selectedWidgets: Widget[] = [
|
||||||
|
{
|
||||||
|
name: 'List',
|
||||||
|
icon: 'list-check',
|
||||||
|
components: ({ entities }) => [
|
||||||
|
{
|
||||||
|
component: EntityList,
|
||||||
|
props: {
|
||||||
|
entities,
|
||||||
|
thumbnails: false,
|
||||||
|
select: 'remove'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'EntityList',
|
||||||
|
icon: 'image',
|
||||||
|
components: ({ entities }) => [
|
||||||
|
{
|
||||||
|
component: EntityList,
|
||||||
|
props: {
|
||||||
|
entities,
|
||||||
|
thumbnails: true,
|
||||||
|
select: 'remove'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="view">
|
||||||
|
<h2>
|
||||||
|
<Icon plain name="select-multiple" />
|
||||||
|
{$i18n.t('Selected')}: {$selected.length}
|
||||||
|
</h2>
|
||||||
|
<div class="actions">
|
||||||
|
<MultiGroupEditor entities={$selected} />
|
||||||
|
</div>
|
||||||
|
<div class="entities">
|
||||||
|
<EntryView
|
||||||
|
title={$i18n.t('Selected entities') || ''}
|
||||||
|
entities={$selected}
|
||||||
|
widgets={selectedWidgets}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
margin-top: -0.66em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entities {
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,450 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import UpObject from './display/UpObject.svelte';
|
||||||
|
import api from '$lib/api';
|
||||||
|
import Selector, { type SelectorValue } from './utils/Selector.svelte';
|
||||||
|
import { createEventDispatcher, onMount, tick } from 'svelte';
|
||||||
|
import type { ZoomBehavior, ZoomTransform, Selection } from 'd3';
|
||||||
|
import Spinner from './utils/Spinner.svelte';
|
||||||
|
import UpObjectCard from './display/UpObjectCard.svelte';
|
||||||
|
import BlobPreview from './display/BlobPreview.svelte';
|
||||||
|
import SurfacePoint from './display/SurfacePoint.svelte';
|
||||||
|
import { i18n } from '../i18n';
|
||||||
|
import debug from 'debug';
|
||||||
|
import { Query } from '@upnd/upend';
|
||||||
|
import { Any } from '@upnd/upend/query';
|
||||||
|
import { isDefined } from '$lib/util/werk';
|
||||||
|
const dbg = debug('kestrel:surface');
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
export let x: string | undefined = undefined;
|
||||||
|
export let y: string | undefined = undefined;
|
||||||
|
$: dispatch('updateAddress', { x, y });
|
||||||
|
|
||||||
|
let loaded = false;
|
||||||
|
|
||||||
|
let viewMode = 'point';
|
||||||
|
|
||||||
|
let currentX = NaN;
|
||||||
|
let currentY = NaN;
|
||||||
|
|
||||||
|
let zoom: ZoomBehavior<Element, unknown> | undefined;
|
||||||
|
let autofit: () => void | undefined;
|
||||||
|
|
||||||
|
let view: Selection<HTMLElement, unknown, null, undefined>;
|
||||||
|
let viewEl: HTMLElement | undefined;
|
||||||
|
let viewHeight = 0;
|
||||||
|
let viewWidth = 0;
|
||||||
|
|
||||||
|
let selector: Selector | undefined;
|
||||||
|
|
||||||
|
$: if (selector) selector.focus();
|
||||||
|
|
||||||
|
$: {
|
||||||
|
if ((x && !y) || (!x && y)) findPerpendicular();
|
||||||
|
}
|
||||||
|
async function findPerpendicular() {
|
||||||
|
const presentAxis = x || y;
|
||||||
|
const presentAxisAddress = await api.componentsToAddress({
|
||||||
|
t: 'Attribute',
|
||||||
|
c: presentAxis
|
||||||
|
});
|
||||||
|
const result = await api.query(
|
||||||
|
Query.or(
|
||||||
|
Query.matches(`@${presentAxisAddress}`, 'PERPENDICULAR', Any),
|
||||||
|
Query.matches(Any, 'PERPENDICULAR', `@${presentAxisAddress}`)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const perpendicular = [
|
||||||
|
...result.entries.map((e) => e.entity),
|
||||||
|
...result.values.filter((v) => v.t === 'Address').map((v) => v.c as string)
|
||||||
|
].find((address) => address !== presentAxisAddress);
|
||||||
|
|
||||||
|
if (perpendicular) {
|
||||||
|
const perpendicularComponents = await api.addressToComponents(perpendicular);
|
||||||
|
if (perpendicularComponents.t !== 'Attribute') return;
|
||||||
|
const perpendicularName = perpendicularComponents.c;
|
||||||
|
|
||||||
|
if (x) {
|
||||||
|
y = perpendicularName;
|
||||||
|
} else {
|
||||||
|
x = perpendicularName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IPoint {
|
||||||
|
address: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
let points: IPoint[] = [];
|
||||||
|
async function loadPoints() {
|
||||||
|
if (!x || !y) return;
|
||||||
|
|
||||||
|
points = [];
|
||||||
|
const result = await api.query(`(matches ? (in "${x}" "${y}") ?)`);
|
||||||
|
|
||||||
|
points = Object.entries(result.objects)
|
||||||
|
.map(([address, obj]) => {
|
||||||
|
let objX = parseInt(String(obj.get(x!)));
|
||||||
|
let objY = parseInt(String(obj.get(y!)));
|
||||||
|
|
||||||
|
if (objX && objY) {
|
||||||
|
return {
|
||||||
|
address,
|
||||||
|
x: objX,
|
||||||
|
y: objY
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(isDefined);
|
||||||
|
|
||||||
|
tick().then(() => {
|
||||||
|
autofit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$: {
|
||||||
|
if (x && y) {
|
||||||
|
loadPoints();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectorCoords: [number, number] | null = null;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const d3 = await import('d3');
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
if (!viewEl) {
|
||||||
|
dbg("Couldn't find view element");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
viewWidth = viewEl.clientWidth;
|
||||||
|
viewHeight = viewEl.clientHeight;
|
||||||
|
|
||||||
|
dbg('Initializing Surface view: %dx%d', viewWidth, viewHeight);
|
||||||
|
view = d3.select(viewEl);
|
||||||
|
const svg = view.select('svg');
|
||||||
|
if (svg.empty()) {
|
||||||
|
throw new Error("Failed initializing Surface - couldn't locate SVG element");
|
||||||
|
}
|
||||||
|
svg.selectAll('*').remove();
|
||||||
|
|
||||||
|
const xScale = d3.scaleLinear().domain([0, viewWidth]).range([0, viewWidth]);
|
||||||
|
|
||||||
|
const yScale = d3.scaleLinear().domain([0, viewHeight]).range([viewHeight, 0]);
|
||||||
|
|
||||||
|
let xTicks = 10;
|
||||||
|
let yTicks = viewHeight / (viewWidth / xTicks);
|
||||||
|
|
||||||
|
const xAxis = d3
|
||||||
|
.axisBottom(xScale)
|
||||||
|
.ticks(xTicks)
|
||||||
|
.tickSize(viewHeight)
|
||||||
|
.tickPadding(5 - viewHeight);
|
||||||
|
|
||||||
|
const yAxis = d3
|
||||||
|
.axisRight(yScale)
|
||||||
|
.ticks(yTicks)
|
||||||
|
.tickSize(viewWidth)
|
||||||
|
.tickPadding(5 - viewWidth);
|
||||||
|
|
||||||
|
const gX = svg.append('g').call(xAxis);
|
||||||
|
const gY = svg.append('g').call(yAxis);
|
||||||
|
|
||||||
|
zoom = d3.zoom().on('zoom', zoomed);
|
||||||
|
|
||||||
|
function zoomed({ transform }: { transform: ZoomTransform }) {
|
||||||
|
const points = view.select('.content');
|
||||||
|
points.style(
|
||||||
|
'transform',
|
||||||
|
`translate(${transform.x}px, ${transform.y}px) scale(${transform.k})`
|
||||||
|
);
|
||||||
|
const allPoints = view.selectAll('.point');
|
||||||
|
allPoints.style('transform', `scale(${1 / transform.k})`);
|
||||||
|
|
||||||
|
gX.call(xAxis.scale(transform.rescaleX(xScale)));
|
||||||
|
gY.call(yAxis.scale(transform.rescaleY(yScale)));
|
||||||
|
|
||||||
|
updateStyles();
|
||||||
|
}
|
||||||
|
|
||||||
|
autofit = () => {
|
||||||
|
if (!zoom) return;
|
||||||
|
|
||||||
|
zoom.translateTo(view as any, 0, viewHeight);
|
||||||
|
|
||||||
|
if (points.length) {
|
||||||
|
zoom.scaleTo(
|
||||||
|
view as any,
|
||||||
|
Math.min(
|
||||||
|
viewWidth / 2 / Math.max(...points.map((p) => Math.abs(p.x))) - 0.3,
|
||||||
|
viewHeight / 2 / Math.max(...points.map((p) => Math.abs(p.y))) - 0.3
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function updateStyles() {
|
||||||
|
svg
|
||||||
|
.selectAll('.tick line')
|
||||||
|
.attr('stroke-width', (d) => {
|
||||||
|
return d === 0 ? 2 : 1;
|
||||||
|
})
|
||||||
|
.attr('stroke', (d) => {
|
||||||
|
return d === 0 ? 'var(--foreground-lightest)' : 'var(--foreground-lighter)';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// function reset() {
|
||||||
|
// svg.transition().duration(750).call(zoom.transform, d3.zoomIdentity);
|
||||||
|
// }
|
||||||
|
|
||||||
|
view.on('mousemove', (ev: MouseEvent) => {
|
||||||
|
// not using offsetXY because `translate` transforms on .inner mess it up
|
||||||
|
const viewBBox = (view.node() as HTMLElement).getBoundingClientRect();
|
||||||
|
const [x, y] = d3
|
||||||
|
.zoomTransform(view.select('.content').node() as HTMLElement)
|
||||||
|
.invert([ev.clientX - viewBBox.left, ev.clientY - viewBBox.top]);
|
||||||
|
|
||||||
|
currentX = xScale.invert(x);
|
||||||
|
currentY = yScale.invert(y);
|
||||||
|
});
|
||||||
|
|
||||||
|
d3.select(viewEl)
|
||||||
|
.call(zoom as any)
|
||||||
|
.on('dblclick.zoom', (_ev: MouseEvent) => {
|
||||||
|
selectorCoords = [currentX, currentY];
|
||||||
|
});
|
||||||
|
|
||||||
|
autofit();
|
||||||
|
|
||||||
|
loaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
tick().then(() => init());
|
||||||
|
});
|
||||||
|
resizeObserver.observe(viewEl as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSelectorInput(ev: CustomEvent<SelectorValue>) {
|
||||||
|
const value = ev.detail;
|
||||||
|
if (value.t !== 'Address') return;
|
||||||
|
const address = value.c;
|
||||||
|
|
||||||
|
const [xValue, yValue] = selectorCoords as any;
|
||||||
|
selectorCoords = null;
|
||||||
|
await Promise.all(
|
||||||
|
(
|
||||||
|
[
|
||||||
|
[x, xValue],
|
||||||
|
[y, yValue]
|
||||||
|
] as any[]
|
||||||
|
).map(([axis, value]: [string, number]) =>
|
||||||
|
api.putEntityAttribute(address, axis, {
|
||||||
|
t: 'Number',
|
||||||
|
c: value
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await loadPoints();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="surface">
|
||||||
|
<div class="header ui">
|
||||||
|
<div class="axis-selector">
|
||||||
|
<div class="label">X</div>
|
||||||
|
<Selector
|
||||||
|
types={['Attribute', 'NewAttribute']}
|
||||||
|
initial={x ? { t: 'Attribute', name: x } : undefined}
|
||||||
|
on:input={(ev) => {
|
||||||
|
if (ev.detail.t === 'Attribute') x = ev.detail.name;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div class="value">
|
||||||
|
{(Math.round(currentX * 100) / 100).toLocaleString('en', {
|
||||||
|
useGrouping: false,
|
||||||
|
minimumFractionDigits: 2
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="axis-selector">
|
||||||
|
<div class="label">Y</div>
|
||||||
|
<Selector
|
||||||
|
types={['Attribute', 'NewAttribute']}
|
||||||
|
initial={y ? { t: 'Attribute', name: y } : undefined}
|
||||||
|
on:input={(ev) => {
|
||||||
|
if (ev.detail.t === 'Attribute') y = ev.detail.name;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div class="value">
|
||||||
|
{(Math.round(currentY * 100) / 100).toLocaleString('en', {
|
||||||
|
useGrouping: false,
|
||||||
|
minimumFractionDigits: 2
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="view" class:loaded bind:this={viewEl}>
|
||||||
|
<div class="ui view-mode-selector">
|
||||||
|
<div class="label">
|
||||||
|
{$i18n.t('View as')}
|
||||||
|
</div>
|
||||||
|
<select bind:value={viewMode}>
|
||||||
|
<option value="point">{$i18n.t('Point')}</option>
|
||||||
|
<option value="link">{$i18n.t('Link')}</option>
|
||||||
|
<option value="card">{$i18n.t('Card')}</option>
|
||||||
|
<!-- <option value="preview">{$i18n.t("Preview")}</option> -->
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{#if !loaded}
|
||||||
|
<div class="loading">
|
||||||
|
<Spinner centered="absolute" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="content">
|
||||||
|
{#if selectorCoords !== null}
|
||||||
|
<div
|
||||||
|
class="point selector"
|
||||||
|
style="
|
||||||
|
left: {selectorCoords[0]}px;
|
||||||
|
top: {viewHeight - selectorCoords[1]}px"
|
||||||
|
>
|
||||||
|
<Selector
|
||||||
|
types={['Address', 'NewAddress']}
|
||||||
|
on:input={onSelectorInput}
|
||||||
|
on:focus={(ev) => {
|
||||||
|
if (!ev.detail) selectorCoords = null;
|
||||||
|
}}
|
||||||
|
bind:this={selector}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#each points as point}
|
||||||
|
<div class="point" style="left: {point.x}px; top: {viewHeight - point.y}px">
|
||||||
|
<div class="inner">
|
||||||
|
{#if viewMode == 'link'}
|
||||||
|
<UpObject link address={point.address} />
|
||||||
|
{:else if viewMode == 'card'}
|
||||||
|
<UpObjectCard address={point.address} />
|
||||||
|
{:else if viewMode == 'preview'}
|
||||||
|
<BlobPreview address={point.address} />
|
||||||
|
{:else if viewMode == 'point'}
|
||||||
|
<SurfacePoint address={point.address} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<svg />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.surface {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1em;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
|
||||||
|
.axis-selector {
|
||||||
|
display: flex;
|
||||||
|
gap: 1em;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 1rem;
|
||||||
|
&::after {
|
||||||
|
content: ':';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.view {
|
||||||
|
flex-grow: 1;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
:global(svg) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.tick text) {
|
||||||
|
color: var(--foreground-lightest);
|
||||||
|
font-size: 1rem;
|
||||||
|
text-shadow: 0 0 0.25em var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.point {
|
||||||
|
position: absolute;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
|
||||||
|
.inner {
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
z-index: 99;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-mode-selector {
|
||||||
|
position: absolute;
|
||||||
|
top: 2rem;
|
||||||
|
right: 1.5em;
|
||||||
|
padding: 0.66em;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--foreground-lighter);
|
||||||
|
background: var(--background);
|
||||||
|
opacity: 0.66;
|
||||||
|
transition: opacity 0.25s;
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.loaded) {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-mode-selector {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5em;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
z-index: 99;
|
||||||
|
transform: scale(3);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,192 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { useEntity } from '$lib/entity';
|
||||||
|
import Spinner from '../utils/Spinner.svelte';
|
||||||
|
import FragmentViewer from './blobs/FragmentViewer.svelte';
|
||||||
|
import ModelViewer from './blobs/ModelViewer.svelte';
|
||||||
|
import VideoViewer from './blobs/VideoViewer.svelte';
|
||||||
|
import HashBadge from './HashBadge.svelte';
|
||||||
|
import api from '$lib/api';
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { getTypes } from '$lib/util/mediatypes';
|
||||||
|
import { concurrentImage } from '../imageQueue';
|
||||||
|
import { ATTR_IN } from '@upnd/upend/constants';
|
||||||
|
import AudioPreview from './blobs/AudioPreview.svelte';
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
export let address: string;
|
||||||
|
export let recurse = 3;
|
||||||
|
|
||||||
|
$: ({ entity, entityInfo } = useEntity(address));
|
||||||
|
$: types = $entity && $entityInfo && getTypes($entity, $entityInfo);
|
||||||
|
|
||||||
|
$: handled =
|
||||||
|
!$entity ||
|
||||||
|
!$entityInfo ||
|
||||||
|
types?.audio ||
|
||||||
|
types?.video ||
|
||||||
|
types?.image ||
|
||||||
|
types?.model ||
|
||||||
|
types?.web ||
|
||||||
|
types?.fragment ||
|
||||||
|
(types?.group && recurse > 0);
|
||||||
|
|
||||||
|
$: dispatch('handled', handled);
|
||||||
|
|
||||||
|
let loaded: string | boolean = false;
|
||||||
|
$: dispatch('loaded', Boolean(loaded));
|
||||||
|
|
||||||
|
let failedChildren: string[] = [];
|
||||||
|
let loadedChildren: string[] = [];
|
||||||
|
$: groupChildren =
|
||||||
|
$entity?.backlinks.filter((e) => e.attribute === ATTR_IN).map((e) => String(e.entity)) || [];
|
||||||
|
$: showGroupChildren =
|
||||||
|
groupChildren
|
||||||
|
?.filter(
|
||||||
|
(addr) => !failedChildren.slice(0, Math.max(groupChildren.length - 4, 0)).includes(addr)
|
||||||
|
)
|
||||||
|
.slice(0, 4) || [];
|
||||||
|
|
||||||
|
$: if (types?.group)
|
||||||
|
loaded =
|
||||||
|
loadedChildren.length >= Math.min(4, groupChildren.length) ||
|
||||||
|
loadedChildren.length + failedChildren.length === groupChildren.length;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="preview" title={$entity?.identify().join('/')}>
|
||||||
|
{#if handled}
|
||||||
|
<div class="inner">
|
||||||
|
{#if !loaded}
|
||||||
|
<Spinner centered="absolute" />
|
||||||
|
{/if}
|
||||||
|
{#if types?.group}
|
||||||
|
<ul class="group">
|
||||||
|
{#each showGroupChildren as address (address)}
|
||||||
|
<li>
|
||||||
|
<svelte:self
|
||||||
|
{address}
|
||||||
|
recurse={recurse - 1}
|
||||||
|
on:handled={(ev) => {
|
||||||
|
if (!ev.detail && !failedChildren.includes(address))
|
||||||
|
failedChildren = [...failedChildren, address];
|
||||||
|
}}
|
||||||
|
on:loaded={(ev) => {
|
||||||
|
if (ev.detail && !loadedChildren.includes(address))
|
||||||
|
loadedChildren = [...loadedChildren, address];
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else if types?.model}
|
||||||
|
<ModelViewer
|
||||||
|
lookonly
|
||||||
|
src="{api.apiUrl}/raw/{address}"
|
||||||
|
on:loaded={() => (loaded = address)}
|
||||||
|
/>
|
||||||
|
{:else if types?.web}
|
||||||
|
<img
|
||||||
|
alt="OpenGraph image for {$entityInfo?.t == 'Url' && $entityInfo?.c}"
|
||||||
|
use:concurrentImage={String($entity?.get('OG_IMAGE'))}
|
||||||
|
on:load={() => (loaded = address)}
|
||||||
|
on:error={() => (handled = false)}
|
||||||
|
/>
|
||||||
|
{:else if types?.fragment}
|
||||||
|
<FragmentViewer {address} detail={false} on:loaded={() => (loaded = address)} />
|
||||||
|
{:else if types?.audio}
|
||||||
|
<AudioPreview
|
||||||
|
{address}
|
||||||
|
on:loaded={() => (loaded = address)}
|
||||||
|
on:error={() => (handled = false)}
|
||||||
|
/>
|
||||||
|
{:else if types?.video}
|
||||||
|
<VideoViewer {address} detail={false} on:loaded={() => (loaded = address)} />
|
||||||
|
{:else}
|
||||||
|
<div class="image" class:loaded={loaded == address || !handled}>
|
||||||
|
<img
|
||||||
|
class:loaded={loaded == address}
|
||||||
|
alt="Thumbnail for {address}..."
|
||||||
|
use:concurrentImage={`${api.apiUrl}/${
|
||||||
|
types?.mimeType?.includes('svg+xml') ? 'raw' : 'thumb'
|
||||||
|
}/${address}?size=512&quality=75`}
|
||||||
|
on:load={() => (loaded = address)}
|
||||||
|
on:error={() => (handled = false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="hashbadge">
|
||||||
|
<HashBadge {address} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.preview {
|
||||||
|
flex-grow: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.inner {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex-grow: 1;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hashbadge {
|
||||||
|
font-size: 48px;
|
||||||
|
opacity: 0.25;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
|
||||||
|
&:not(.loaded) {
|
||||||
|
flex-grow: 1;
|
||||||
|
height: 6rem;
|
||||||
|
max-height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.group {
|
||||||
|
flex-grow: 1;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
grid-template-rows: repeat(2, 1fr);
|
||||||
|
|
||||||
|
padding: 0.25rem;
|
||||||
|
gap: 0.25rem;
|
||||||
|
border: 1px solid var(--foreground);
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: end;
|
||||||
|
list-style: none;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,119 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { useEntity } from '$lib/entity';
|
||||||
|
import Spinner from '../utils/Spinner.svelte';
|
||||||
|
import AudioViewer from './blobs/AudioViewer.svelte';
|
||||||
|
import FragmentViewer from './blobs/FragmentViewer.svelte';
|
||||||
|
import ImageViewer from './blobs/ImageViewer.svelte';
|
||||||
|
import ModelViewer from './blobs/ModelViewer.svelte';
|
||||||
|
import TextViewer from './blobs/TextViewer.svelte';
|
||||||
|
import VideoViewer from './blobs/VideoViewer.svelte';
|
||||||
|
import UpLink from './UpLink.svelte';
|
||||||
|
import api from '$lib/api';
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { getTypes } from '$lib/util/mediatypes';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
export let address: string;
|
||||||
|
export let detail: boolean;
|
||||||
|
|
||||||
|
let handled = false;
|
||||||
|
|
||||||
|
$: ({ entity, entityInfo } = useEntity(address));
|
||||||
|
$: types = $entity && $entityInfo && getTypes($entity, $entityInfo);
|
||||||
|
|
||||||
|
$: handled =
|
||||||
|
(types &&
|
||||||
|
(types.audio ||
|
||||||
|
types.video ||
|
||||||
|
types.image ||
|
||||||
|
types.text ||
|
||||||
|
types.pdf ||
|
||||||
|
types.model ||
|
||||||
|
types.web ||
|
||||||
|
types.fragment)) ??
|
||||||
|
false;
|
||||||
|
|
||||||
|
$: dispatch('handled', handled);
|
||||||
|
|
||||||
|
let imageLoaded: string | null = null;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if handled}
|
||||||
|
<div class="preview" class:detail>
|
||||||
|
{#if types?.text}
|
||||||
|
<div class="text-viewer">
|
||||||
|
<TextViewer {address} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if types?.audio}
|
||||||
|
<AudioViewer {address} {detail} />
|
||||||
|
{/if}
|
||||||
|
{#if types?.video}
|
||||||
|
<VideoViewer detail {address} />
|
||||||
|
{/if}
|
||||||
|
{#if types?.image}
|
||||||
|
<ImageViewer {address} {detail} />
|
||||||
|
{/if}
|
||||||
|
{#if types?.pdf}
|
||||||
|
<iframe src="{api.apiUrl}/raw/{address}?inline" title="PDF document of {address}" />
|
||||||
|
{/if}
|
||||||
|
{#if types?.model}
|
||||||
|
<ModelViewer src="{api.apiUrl}/raw/{address}" />
|
||||||
|
{/if}
|
||||||
|
{#if types?.web}
|
||||||
|
{#if imageLoaded != address}
|
||||||
|
<Spinner />
|
||||||
|
{/if}
|
||||||
|
<img
|
||||||
|
src={String($entity?.get('OG_IMAGE'))}
|
||||||
|
alt="OpenGraph image for {$entityInfo?.t == 'Url' && $entityInfo?.c}"
|
||||||
|
on:load={() => (imageLoaded = address)}
|
||||||
|
on:error={() => (handled = false)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if types?.fragment}
|
||||||
|
<UpLink passthrough to={{ entity: String($entity?.get('ANNOTATES')) }}>
|
||||||
|
<FragmentViewer {address} {detail} />
|
||||||
|
</UpLink>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
// min-height: 33vh;
|
||||||
|
max-height: 50vh;
|
||||||
|
|
||||||
|
&.detail {
|
||||||
|
height: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
flex-grow: 1;
|
||||||
|
// min-height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
img,
|
||||||
|
.text-viewer {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
iframe {
|
||||||
|
width: 99%;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-viewer {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,64 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
const BADGE_HEIGHT = 3;
|
||||||
|
export let address: string;
|
||||||
|
|
||||||
|
let canvas: HTMLCanvasElement | undefined;
|
||||||
|
let width = 0;
|
||||||
|
|
||||||
|
const bytes = [...address].map((c) => c.charCodeAt(0));
|
||||||
|
while (bytes.length % (3 * BADGE_HEIGHT) !== 0) {
|
||||||
|
bytes.push(bytes[bytes.length - 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
width = Math.ceil(bytes.length / 3 / BADGE_HEIGHT);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const ctx = canvas?.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
console.warn("Couldn't initialize canvas!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hueRange = 120;
|
||||||
|
const hueCenter = 90 + bytes.length * 2.5;
|
||||||
|
|
||||||
|
let idx = 0;
|
||||||
|
function draw() {
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const tmp = [];
|
||||||
|
while (bytes.length > 0 && tmp.length < 3) {
|
||||||
|
tmp.push(bytes.shift());
|
||||||
|
}
|
||||||
|
while (tmp.length < 3) {
|
||||||
|
tmp.push(tmp[tmp.length - 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const h = (tmp[0]! / 128) * hueRange + hueCenter - hueRange / 2;
|
||||||
|
const s = (tmp[1]! / 128) * 100;
|
||||||
|
const l = (tmp[2]! / 128) * 100;
|
||||||
|
ctx.fillStyle = `hsl(${h},${s}%,${l}%)`;
|
||||||
|
ctx.fillRect(Math.floor(idx / BADGE_HEIGHT), idx % BADGE_HEIGHT, 1, 1);
|
||||||
|
idx++;
|
||||||
|
if (bytes.length > 0) {
|
||||||
|
requestAnimationFrame(draw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(draw);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<canvas bind:this={canvas} {width} height="3" title={address} />
|
||||||
|
|
||||||
|
<!--suppress CssOverwrittenProperties -->
|
||||||
|
<style>
|
||||||
|
canvas {
|
||||||
|
display: inline-block;
|
||||||
|
height: 1em;
|
||||||
|
image-rendering: optimizeSpeed;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,50 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Address } from '@upnd/upend/types';
|
||||||
|
import UpObject from './UpObject.svelte';
|
||||||
|
import UpLink from './UpLink.svelte';
|
||||||
|
|
||||||
|
export let address: Address;
|
||||||
|
let popup = false;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y-mouse-events-have-key-events -->
|
||||||
|
<UpLink passthrough to={{ entity: address }}>
|
||||||
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
|
<div
|
||||||
|
class="surface-point"
|
||||||
|
on:mouseover={() => (popup = true)}
|
||||||
|
on:mouseleave={() => (popup = false)}
|
||||||
|
>
|
||||||
|
{#if popup}
|
||||||
|
<div class="popup-inner">
|
||||||
|
<UpObject {address} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</UpLink>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '../../styles/colors.scss';
|
||||||
|
|
||||||
|
.surface-point {
|
||||||
|
display: relative;
|
||||||
|
|
||||||
|
width: 0.75rem;
|
||||||
|
height: 0.75rem;
|
||||||
|
border-radius: 25%;
|
||||||
|
background: colors.$red;
|
||||||
|
box-shadow: 0 0 0 1px darken(colors.$red, 20%);
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
&:hover {
|
||||||
|
background: lighten(colors.$red, 20%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-inner {
|
||||||
|
position: relative;
|
||||||
|
top: 1rem;
|
||||||
|
display: inline-block;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,58 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { UpEntry } from '@upnd/upend';
|
||||||
|
import { attributeLabels } from '../../util/labels';
|
||||||
|
import UpObject from './UpObject.svelte';
|
||||||
|
export let resolve = true;
|
||||||
|
|
||||||
|
export let entry: UpEntry;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="entry">
|
||||||
|
<div class="entity">
|
||||||
|
<UpObject plain link address={entry.entity} labels={resolve ? undefined : []} />
|
||||||
|
</div>
|
||||||
|
<div class="attribute" title={entry.attribute}>
|
||||||
|
{$attributeLabels[entry.attribute] || entry.attribute}
|
||||||
|
</div>
|
||||||
|
<div class="value value-{entry.value.t.toLowerCase()}">
|
||||||
|
{#if entry.value.t === 'Address'}
|
||||||
|
<UpObject link address={entry.value.c} labels={resolve ? undefined : []} />
|
||||||
|
{:else}
|
||||||
|
{entry.value.c}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.entry {
|
||||||
|
border: 1px solid var(--foreground);
|
||||||
|
background: var(--background-lighter);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.1em 0.5em 0.1em 0.25em;
|
||||||
|
display: flex;
|
||||||
|
align-content: center;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1em;
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.attribute {
|
||||||
|
flex-grow: 1;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 300;
|
||||||
|
&::before {
|
||||||
|
content: '→\00a0';
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
content: '\00a0→';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.value-value) {
|
||||||
|
font-family: var(--monospace-font);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,84 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from 'svelte';
|
||||||
|
import { readable } from 'svelte/store';
|
||||||
|
import type { Address, VALUE_TYPE } from '@upnd/upend/types';
|
||||||
|
import type { BrowseContext } from '$lib/util/browse';
|
||||||
|
import api from '$lib/api';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
export let passthrough = false;
|
||||||
|
export let title: string | undefined = undefined;
|
||||||
|
export let text = false;
|
||||||
|
export let to: {
|
||||||
|
entity?: Address;
|
||||||
|
attribute?: string;
|
||||||
|
surfaceAttribute?: string;
|
||||||
|
value?: { t: VALUE_TYPE; c: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
const NOOP = '#';
|
||||||
|
let targetHref = NOOP;
|
||||||
|
$: {
|
||||||
|
if (to.entity) {
|
||||||
|
targetHref = to.entity;
|
||||||
|
} else if (to.attribute) {
|
||||||
|
api.componentsToAddress({ t: 'Attribute', c: to.attribute }).then((address) => {
|
||||||
|
targetHref = address;
|
||||||
|
});
|
||||||
|
} else if (to.surfaceAttribute) {
|
||||||
|
targetHref = `surface:${to.surfaceAttribute}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = getContext('browse') as BrowseContext | undefined;
|
||||||
|
const index = context ? context.index : readable(0);
|
||||||
|
const addresses = context ? context.addresses : readable([]);
|
||||||
|
|
||||||
|
function onClick(ev: MouseEvent) {
|
||||||
|
if (window.location.pathname.startsWith('/browse')) {
|
||||||
|
let newAddresses = $addresses.concat();
|
||||||
|
|
||||||
|
// Shift to append to the end instead of replacing
|
||||||
|
if (ev.shiftKey) {
|
||||||
|
newAddresses = newAddresses.concat([targetHref]);
|
||||||
|
} else {
|
||||||
|
if ($addresses[$index] !== targetHref) {
|
||||||
|
newAddresses = newAddresses.slice(0, $index + 1).concat([targetHref]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
goto('/browse/' + newAddresses.join(','));
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
goto(`/browse/${targetHref}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a
|
||||||
|
class="uplink"
|
||||||
|
class:text
|
||||||
|
class:passthrough
|
||||||
|
class:unresolved={targetHref === NOOP}
|
||||||
|
href="/browse/{targetHref}"
|
||||||
|
on:click|preventDefault={onClick}
|
||||||
|
{title}
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
:global(.uplink) {
|
||||||
|
text-decoration: none;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
:global(.uplink.text) {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
:global(.uplink.passthrough) {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
:global(.uplink.unresolved) {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,366 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher, getContext } from 'svelte';
|
||||||
|
|
||||||
|
import HashBadge from './HashBadge.svelte';
|
||||||
|
import UpObjectLabel from './UpObjectLabel.svelte';
|
||||||
|
import UpLink from './UpLink.svelte';
|
||||||
|
import Icon from '../utils/Icon.svelte';
|
||||||
|
import { readable, type Readable, writable } from 'svelte/store';
|
||||||
|
import { notify, UpNotification } from '$lib/notifications';
|
||||||
|
import IconButton from '../utils/IconButton.svelte';
|
||||||
|
import { vaultInfo } from '$lib/util/info';
|
||||||
|
import type { BrowseContext } from '$lib/util/browse';
|
||||||
|
import { Query, type UpObject } from '@upnd/upend';
|
||||||
|
import type { ADDRESS_TYPE, EntityInfo } from '@upnd/upend/types';
|
||||||
|
import { useEntity } from '$lib/entity';
|
||||||
|
import { i18n } from '$lib/i18n';
|
||||||
|
import api from '$lib/api';
|
||||||
|
import { ATTR_IN, ATTR_LABEL, HIER_ROOT_ADDR } from '@upnd/upend/constants';
|
||||||
|
import { selected } from '../EntitySelect.svelte';
|
||||||
|
import { Any } from '@upnd/upend/query';
|
||||||
|
import type { AddressComponents } from '@upnd/upend/wasm';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
export let address: string;
|
||||||
|
export let labels: string[] | undefined = undefined;
|
||||||
|
export let link = false;
|
||||||
|
export let banner = false;
|
||||||
|
export let resolve = !(labels || []).length || banner;
|
||||||
|
export let backpath = 0;
|
||||||
|
export let select = true;
|
||||||
|
export let plain = false;
|
||||||
|
|
||||||
|
let entity: Readable<UpObject | undefined> = readable(undefined);
|
||||||
|
let entityInfo: Readable<EntityInfo | AddressComponents | undefined> = writable(undefined);
|
||||||
|
$: if (resolve) ({ entity, entityInfo } = useEntity(address));
|
||||||
|
$: if (!resolve)
|
||||||
|
entityInfo = readable(undefined as undefined | AddressComponents, (set) => {
|
||||||
|
api.addressToComponents(address).then((info) => {
|
||||||
|
set(info);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let hasFile = false;
|
||||||
|
$: {
|
||||||
|
if ($entityInfo?.t == 'Hash' && banner) {
|
||||||
|
fetch(api.getRaw(address), {
|
||||||
|
method: 'HEAD'
|
||||||
|
}).then((response) => {
|
||||||
|
hasFile = response.ok;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identification
|
||||||
|
let inferredIds: string[] = [];
|
||||||
|
$: inferredIds = $entity?.identify() || [];
|
||||||
|
let addressIds: string[] = [];
|
||||||
|
$: resolving = inferredIds.concat(labels || []).length == 0 && !$entity;
|
||||||
|
|
||||||
|
$: fetchAddressLabels(address);
|
||||||
|
|
||||||
|
async function fetchAddressLabels(address: string) {
|
||||||
|
addressIds = [];
|
||||||
|
await Promise.all(
|
||||||
|
(['Hash', 'Uuid', 'Attribute', 'Url'] as ADDRESS_TYPE[]).map(async (t) => {
|
||||||
|
if ((await api.getAddress(t)) == address) {
|
||||||
|
addressIds.push(`∈ ${t}`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
addressIds = addressIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
let displayLabel = address;
|
||||||
|
$: {
|
||||||
|
const allLabels = inferredIds.concat(addressIds).concat(labels || []);
|
||||||
|
displayLabel = Array.from(new Set(allLabels)).join(' | ');
|
||||||
|
|
||||||
|
if (!displayLabel && $entityInfo?.t === 'Attribute') {
|
||||||
|
displayLabel = `${$entityInfo.c}`;
|
||||||
|
}
|
||||||
|
displayLabel = displayLabel || address;
|
||||||
|
}
|
||||||
|
|
||||||
|
$: dispatch('resolved', inferredIds);
|
||||||
|
|
||||||
|
// Resolved backpath
|
||||||
|
let resolvedBackpath: string[] = [];
|
||||||
|
$: if (backpath) resolveBackpath();
|
||||||
|
|
||||||
|
async function resolveBackpath() {
|
||||||
|
resolvedBackpath = [];
|
||||||
|
let levels = 0;
|
||||||
|
let current = address;
|
||||||
|
while (levels < backpath && current !== HIER_ROOT_ADDR) {
|
||||||
|
const parent = await api.query(Query.matches(`@${current}`, ATTR_IN, Any));
|
||||||
|
if (parent.entries.length) {
|
||||||
|
current = parent.entries[0].value.c as string;
|
||||||
|
const label = await api.query(Query.matches(`@${current}`, ATTR_LABEL, Any));
|
||||||
|
if (label.entries.length) {
|
||||||
|
resolvedBackpath = [label.entries[0].value.c as string, ...resolvedBackpath];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
levels++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation highlights
|
||||||
|
const context = getContext('browse') as BrowseContext | undefined;
|
||||||
|
const index = context?.index || readable(0);
|
||||||
|
const addresses = context?.addresses || readable([]);
|
||||||
|
|
||||||
|
// Native open
|
||||||
|
function nativeOpen() {
|
||||||
|
notify.emit(
|
||||||
|
'notification',
|
||||||
|
new UpNotification(
|
||||||
|
$i18n.t('Opening {{identity}} in a default native application...', {
|
||||||
|
identity: inferredIds[0] || address
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
api
|
||||||
|
.nativeOpen(address)
|
||||||
|
.then(async (response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`${response.statusText} - ${await response.text()}`);
|
||||||
|
}
|
||||||
|
const rawWarning = response.headers.get('warning');
|
||||||
|
if (rawWarning) {
|
||||||
|
const warningText = rawWarning?.split(' ').slice(2).join(' ');
|
||||||
|
notify.emit('notification', new UpNotification(warningText, 'warning'));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
notify.emit(
|
||||||
|
'notification',
|
||||||
|
new UpNotification(
|
||||||
|
$i18n.t('Failed to open in native application! ({{err}})', { err }),
|
||||||
|
'error'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="upobject"
|
||||||
|
class:left-active={address == $addresses[$index - 1]}
|
||||||
|
class:right-active={address == $addresses[$index + 1]}
|
||||||
|
class:selected={select && $selected.includes(address)}
|
||||||
|
class:plain
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="address"
|
||||||
|
class:identified={inferredIds.length || addressIds.length || labels?.length}
|
||||||
|
class:banner
|
||||||
|
class:show-type={$entityInfo?.t === 'Url' && !addressIds.length}
|
||||||
|
>
|
||||||
|
<HashBadge {address} />
|
||||||
|
<div class="separator" />
|
||||||
|
<div class="label" class:resolving title={displayLabel}>
|
||||||
|
<div class="label-inner">
|
||||||
|
{#if banner && hasFile}
|
||||||
|
<UpObjectLabel label={displayLabel} backpath={resolvedBackpath} />
|
||||||
|
{:else if link}
|
||||||
|
<UpLink to={{ entity: address }}>
|
||||||
|
<UpObjectLabel label={displayLabel} backpath={resolvedBackpath} />
|
||||||
|
</UpLink>
|
||||||
|
{:else}
|
||||||
|
<UpObjectLabel label={displayLabel} backpath={resolvedBackpath} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if $entity?.get('KEY')}
|
||||||
|
<div class="key">{$entity.get('KEY')}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="secondary">
|
||||||
|
<div class="type">
|
||||||
|
{$entityInfo?.t}
|
||||||
|
{#if $entityInfo?.t === 'Url' || $entityInfo?.t === 'Attribute'}
|
||||||
|
— {$entityInfo.c}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if banner}
|
||||||
|
{#if $entityInfo?.t === 'Attribute'}
|
||||||
|
<div class="icon">
|
||||||
|
<UpLink to={{ surfaceAttribute: $entityInfo.c }} title={$i18n.t('Open on surface') || ''}>
|
||||||
|
<Icon name="cross" />
|
||||||
|
</UpLink>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if $entityInfo?.t == 'Hash'}
|
||||||
|
<div
|
||||||
|
class="icon"
|
||||||
|
title={hasFile ? $i18n.t('Download as file') : $i18n.t('File not present in vault')}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="link-button"
|
||||||
|
class:disabled={!hasFile}
|
||||||
|
href="{api.apiUrl}/raw/{address}"
|
||||||
|
download={inferredIds[0]}
|
||||||
|
>
|
||||||
|
<Icon name="download" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{#if $vaultInfo?.desktop && hasFile}
|
||||||
|
<div class="icon">
|
||||||
|
<IconButton
|
||||||
|
name="window-alt"
|
||||||
|
on:click={nativeOpen}
|
||||||
|
title={$i18n.t('Open in default application...') || ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$lib/styles/colors';
|
||||||
|
|
||||||
|
.upobject {
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&.left-active {
|
||||||
|
background: linear-gradient(90deg, colors.$orange 0%, transparent 100%);
|
||||||
|
padding: 2px 0 2px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.right-active {
|
||||||
|
background: linear-gradient(90deg, transparent 0%, colors.$orange 100%);
|
||||||
|
padding: 2px 2px 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.plain .address {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.address {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
padding: 0.1em 0.25em;
|
||||||
|
|
||||||
|
font-family: var(--monospace-font);
|
||||||
|
line-break: anywhere;
|
||||||
|
|
||||||
|
background: var(--background-lighter);
|
||||||
|
border: 0.1em solid var(--foreground-lighter);
|
||||||
|
border-radius: 0.2em;
|
||||||
|
|
||||||
|
&.banner {
|
||||||
|
border: 0.12em solid var(--foreground);
|
||||||
|
padding: 0.5em 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.identified {
|
||||||
|
font-family: var(--default-font);
|
||||||
|
font-size: 0.95em;
|
||||||
|
line-break: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-inner {
|
||||||
|
max-width: 100%;
|
||||||
|
margin-right: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.banner .label {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
font-size: 0.66em;
|
||||||
|
display: none;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key {
|
||||||
|
font-family: var(--monospace-font);
|
||||||
|
color: colors.$yellow;
|
||||||
|
opacity: 0.8;
|
||||||
|
|
||||||
|
&:before {
|
||||||
|
content: '⌘';
|
||||||
|
margin-right: 0.1em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.banner .key {
|
||||||
|
font-size: 0.66em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.banner) .key {
|
||||||
|
flex-grow: 1;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.show-type .secondary,
|
||||||
|
&.banner .secondary {
|
||||||
|
display: unset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
flex-grow: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
:global(a) {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
width: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
margin: 0 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resolving {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-button {
|
||||||
|
opacity: 0.66;
|
||||||
|
transition:
|
||||||
|
opacity 0.2s,
|
||||||
|
color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--active-color, var(--primary));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.upobject {
|
||||||
|
transition:
|
||||||
|
margin 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected {
|
||||||
|
margin: 0.12rem;
|
||||||
|
box-shadow: 0 0 0.1rem 0.11rem colors.$red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,59 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import HashBadge from './HashBadge.svelte';
|
||||||
|
import UpLink from './UpLink.svelte';
|
||||||
|
import BlobPreview from './BlobPreview.svelte';
|
||||||
|
import UpObject from './UpObject.svelte';
|
||||||
|
|
||||||
|
export let address: string;
|
||||||
|
export let labels: string[] | undefined = undefined;
|
||||||
|
export let thumbnail = true;
|
||||||
|
export let banner = true;
|
||||||
|
export let select = true;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="upobjectcard">
|
||||||
|
<UpLink to={{ entity: address }} passthrough>
|
||||||
|
<div class="inner">
|
||||||
|
{#if thumbnail}
|
||||||
|
<BlobPreview {address} />
|
||||||
|
{:else}
|
||||||
|
<div class="badge">
|
||||||
|
<HashBadge {address} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="label">
|
||||||
|
<UpObject {address} {labels} {banner} {select} on:resolved />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UpLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.upobjectcard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-end;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.inner {
|
||||||
|
border: 1px solid var(--foreground-lighter);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.25rem;
|
||||||
|
max-height: 420px;
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 3rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue