diff --git a/DEPLOY_VIEWER.md b/DEPLOY_VIEWER.md index 79fe859c1..3b78aaf20 100644 --- a/DEPLOY_VIEWER.md +++ b/DEPLOY_VIEWER.md @@ -22,6 +22,9 @@ export PROTECTED_BUCKET="ngm-protected-prod" # for tilesets restricted by cognit export DOWNLOAD_BUCKET="ngmpub-download-bgdi-ch" # for publishing dataset sources export DATA_EXCHANGE="ngm-data-exchange" # internal, for exchanging data (not accessible) export RELEASES_BUCKET="ngmpub-releases-bgdi-ch" # where the UI releases are published +export PROD_PROJECT_FILES_BUCKET="ngmpub-prod-project-files-bgdi-ch" # prod bucket where the project files saved +export INT_PROJECT_FILES_BUCKET="ngmpub-int-project-files-bgdi-ch" # int bucket where the project files saved +export DEV_PROJECT_FILES_BUCKET="ngmpub-dev-project-files-bgdi-ch" # dev bucket where the project files saved ``` ### Listing content of a bucket: diff --git a/api/.env b/api/.env index 6eab581f6..9822622b4 100644 --- a/api/.env +++ b/api/.env @@ -11,6 +11,7 @@ S3_AWS_REGION=eu-west-1 AWS_ACCESS_KEY_ID=bla AWS_SECRET_ACCESS_KEY=hu S3_BUCKET=ngmpub-userdata-local +PROJECTS_S3_BUCKET=ngmpub-project-files-local S3_ENDPOINT="http://minio:9000" # Database diff --git a/api/Cargo.lock b/api/Cargo.lock index 8f5b2eb1e..d7c35c81f 100644 --- a/api/Cargo.lock +++ b/api/Cargo.lock @@ -143,11 +143,12 @@ dependencies = [ "axum", "axum-macros", "chrono", - "clap 4.4.7", + "clap 4.4.10", "dotenv", "hyper", "jsonwebtoken", "once_cell", + "rand", "reqwest", "serde", "serde_json", @@ -546,6 +547,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "rustversion", @@ -657,9 +659,9 @@ checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" [[package]] name = "bytes-utils" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e47d3a8076e283f3acd27400535992edb3ba4b5bb72f8891ad8fbe7932a7d4b9" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" dependencies = [ "bytes", "either", @@ -712,9 +714,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.4.7" +version = "4.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac495e00dcec98c83465d5ad66c5c4fabd652fd6686e7c6269b117e729a6f17b" +checksum = "41fffed7514f420abec6d183b1d3acfd9099c79c3a10a06ade4f8203f1411272" dependencies = [ "clap_builder", "clap_derive", @@ -722,9 +724,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.4.7" +version = "4.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c77ed9a32a62e6ca27175d00d29d05ca32e396ea1eb5fb01d8256b669cec7663" +checksum = "63361bae7eef3771745f02d8d892bec2fee5f6e34af316ba556e7f97a7069ff1" dependencies = [ "anstream", "anstyle", @@ -913,6 +915,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + [[package]] name = "event-listener" version = "2.5.3" @@ -942,9 +950,9 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "form_urlencoded" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a62bc1cf6f830c2ec14a513a9fb124d0a213a629668a4186f329db21fe045652" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" dependencies = [ "percent-encoding", ] @@ -1037,15 +1045,15 @@ dependencies = [ [[package]] name = "gimli" -version = "0.28.0" +version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fb8d784f27acf97159b40fc4db5ecd8aa23b9ad5ef69cdd136d3bc80665f0c0" +checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" [[package]] name = "h2" -version = "0.3.21" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91fc23aa11be92976ef4729127f1a74adf36d8436f7816b185d18df956790833" +checksum = "4d6250322ef6e60f93f9a2162799302cd6f68f79f6e5d85c8c16f14d1d958178" dependencies = [ "bytes", "fnv", @@ -1053,7 +1061,7 @@ dependencies = [ "futures-sink", "futures-util", "http", - "indexmap", + "indexmap 2.1.0", "slab", "tokio", "tokio-util", @@ -1068,9 +1076,9 @@ checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" [[package]] name = "hashbrown" -version = "0.14.2" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93e7192158dbcda357bdec5fb5788eebf8bbac027f3f33e719d29135ae84156" +checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" dependencies = [ "ahash 0.8.6", "allocator-api2", @@ -1082,7 +1090,7 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" dependencies = [ - "hashbrown 0.14.2", + "hashbrown 0.14.3", ] [[package]] @@ -1168,9 +1176,9 @@ dependencies = [ [[package]] name = "http" -version = "0.2.9" +version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd6effc99afb63425aff9b05836f029929e345a6148a14b7ecd5ab67af944482" +checksum = "8947b1a6fad4393052c7ba1f4cd97bed3e953a95c79c92ad9b051a04611d9fbb" dependencies = [ "bytes", "fnv", @@ -1254,7 +1262,7 @@ dependencies = [ "futures-util", "http", "hyper", - "rustls 0.21.8", + "rustls 0.21.9", "tokio", "tokio-rustls 0.24.1", ] @@ -1284,9 +1292,9 @@ dependencies = [ [[package]] name = "idna" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" dependencies = [ "unicode-bidi", "unicode-normalization", @@ -1302,6 +1310,16 @@ dependencies = [ "hashbrown 0.12.3", ] +[[package]] +name = "indexmap" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" +dependencies = [ + "equivalent", + "hashbrown 0.14.3", +] + [[package]] name = "instant" version = "0.1.12" @@ -1334,9 +1352,9 @@ checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" [[package]] name = "js-sys" -version = "0.3.65" +version = "0.3.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54c0c35952f67de54bb584e9fd912b3023117cbafc0a77d8f3dee1fb5f572fe8" +checksum = "cee9c64da59eae3b50095c18d3e74f8b73c0b86d2792824ff01bbce68ba229ca" dependencies = [ "wasm-bindgen", ] @@ -1457,6 +1475,24 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "multer" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01acbdc23469fd8fe07ab135923371d5f5a422fbf9c522158677c8eb15bc51c2" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "log", + "memchr", + "mime", + "spin 0.9.8", + "version_check", +] + [[package]] name = "nom" version = "7.1.3" @@ -1609,9 +1645,9 @@ dependencies = [ [[package]] name = "percent-encoding" -version = "2.3.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "pin-project" @@ -1683,9 +1719,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.69" +version = "1.0.70" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "134c189feb4956b20f6f547d2cf727d4c0fe06722b20a0eec87ed445a97f92da" +checksum = "39278fbbf5fb4f646ce651690877f89d1c5811a3d4acb27700c1cb3cdb78fd3b" dependencies = [ "unicode-ident", ] @@ -1825,7 +1861,7 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", - "rustls 0.21.8", + "rustls 0.21.9", "rustls-pemfile", "serde", "serde_json", @@ -1838,7 +1874,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 0.25.2", + "webpki-roots 0.25.3", "winreg", ] @@ -1900,9 +1936,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.21.8" +version = "0.21.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446e14c5cda4f3f30fe71863c34ec70f5ac79d6087097ad0bb433e1be5edf04c" +checksum = "629648aced5775d558af50b2b4c7b02983a04b312126d45eeead26e7caa498b9" dependencies = [ "log", "ring 0.17.5", @@ -1924,9 +1960,9 @@ dependencies = [ [[package]] name = "rustls-pemfile" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d3987094b1d07b653b7dfdc3f70ce9a1da9c51ac18c1b06b662e4f9a0e9f4b2" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" dependencies = [ "base64 0.21.5", ] @@ -2009,18 +2045,18 @@ checksum = "836fa6a3e1e547f9a2c4040802ec865b5d85f4014efe00555d7090a3dcaa1090" [[package]] name = "serde" -version = "1.0.192" +version = "1.0.193" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bca2a08484b285dcb282d0f67b26cadc0df8b19f8c12502c13d966bf9482f001" +checksum = "25dd9975e68d0cb5aa1120c288333fc98731bd1dd12f561e468ea4728c042b89" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.192" +version = "1.0.193" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6c7207fbec9faa48073f3e3074cbe553af6ea512d7c21ba46e434e70ea9fbc1" +checksum = "43576ca501357b9b071ac53cdc7da8ef0cbd9493d8df094cd821777ea6e894d3" dependencies = [ "proc-macro2", "quote", @@ -2123,9 +2159,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.11.1" +version = "1.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "942b4a808e05215192e39f4ab80813e599068285906cc91aa64f923db842bd5a" +checksum = "4dccd0940a2dcdf68d092b8cbab7dc0ad8fa938bf95787e1b916b0e3d0e8e970" [[package]] name = "socket2" @@ -2206,7 +2242,7 @@ dependencies = [ "hex", "hkdf", "hmac", - "indexmap", + "indexmap 1.9.3", "itoa", "libc", "log", @@ -2455,9 +2491,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.33.0" +version = "1.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f38200e3ef7995e5ef13baec2f432a6da0aa9ac495b2c0e8f3b7eec2c92d653" +checksum = "d0c014766411e834f7af5b8f4cf46257aab4036ca95e9d2c144a10f59ad6f5b9" dependencies = [ "backtrace", "bytes", @@ -2474,9 +2510,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.1.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e" +checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" dependencies = [ "proc-macro2", "quote", @@ -2500,7 +2536,7 @@ version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" dependencies = [ - "rustls 0.21.8", + "rustls 0.21.9", "tokio", ] @@ -2611,9 +2647,9 @@ dependencies = [ [[package]] name = "tracing-log" -version = "0.1.4" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f751112709b4e791d8ce53e32c4ed2d353565a795ce84da2285393f41557bdf2" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" dependencies = [ "log", "once_cell", @@ -2622,9 +2658,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.17" +version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a651bc37f915e81f087d86e62a18eec5f79550c7faff886f7090b4ea757c77" +checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" dependencies = [ "matchers", "nu-ansi-term", @@ -2703,9 +2739,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "143b538f18257fac9cad154828a57c6bf5157e1aa604d4816b5995bf6de87ae5" +checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" dependencies = [ "form_urlencoded", "idna", @@ -2726,9 +2762,9 @@ checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" [[package]] name = "uuid" -version = "1.5.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88ad59a7560b41a70d191093a945f0b87bc1deeda46fb237479708a1d6b6cdfc" +checksum = "5e395fcf16a7a3d8127ec99782007af141946b4795001f876d54fb0d55978560" dependencies = [ "getrandom", "serde", @@ -2769,9 +2805,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.88" +version = "0.2.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7daec296f25a1bae309c0cd5c29c4b260e510e6d813c286b19eaadf409d40fce" +checksum = "0ed0d4f68a3015cc185aff4db9506a015f4b96f95303897bfa23f846db54064e" dependencies = [ "cfg-if", "wasm-bindgen-macro", @@ -2779,9 +2815,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.88" +version = "0.2.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e397f4664c0e4e428e8313a469aaa58310d302159845980fd23b0f22a847f217" +checksum = "1b56f625e64f3a1084ded111c4d5f477df9f8c92df113852fa5a374dbda78826" dependencies = [ "bumpalo", "log", @@ -2794,9 +2830,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.38" +version = "0.4.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afec9963e3d0994cac82455b2b3502b81a7f40f9a0d32181f7528d9f4b43e02" +checksum = "ac36a15a220124ac510204aec1c3e5db8a22ab06fd6706d881dc6149f8ed9a12" dependencies = [ "cfg-if", "js-sys", @@ -2806,9 +2842,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.88" +version = "0.2.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5961017b3b08ad5f3fe39f1e79877f8ee7c23c5e5fd5eb80de95abc41f1f16b2" +checksum = "0162dbf37223cd2afce98f3d0785506dcb8d266223983e4b5b525859e6e182b2" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2816,9 +2852,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.88" +version = "0.2.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5353b8dab669f5e10f5bd76df26a9360c748f054f862ff5f3f8aae0c7fb3907" +checksum = "f0eb82fcb7930ae6219a7ecfd55b217f5f0893484b7a13022ebb2b2bf20b5283" dependencies = [ "proc-macro2", "quote", @@ -2829,9 +2865,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.88" +version = "0.2.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d046c5d029ba91a1ed14da14dca44b68bf2f124cfbaf741c54151fdb3e0750b" +checksum = "7ab9b36309365056cd639da3134bf87fa8f3d86008abf99e612384a6eecd459f" [[package]] name = "web-sys" @@ -2864,9 +2900,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "0.25.2" +version = "0.25.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14247bb57be4f377dfb94c72830b8ce8fc6beac03cf4bf7b9732eadd414123fc" +checksum = "1778a42e8b3b90bff8d0f5032bf22250792889a5cdc752aa0020c84abe3aaf10" [[package]] name = "whoami" @@ -2993,18 +3029,18 @@ checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" [[package]] name = "zerocopy" -version = "0.7.25" +version = "0.7.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cd369a67c0edfef15010f980c3cbe45d7f651deac2cd67ce097cd801de16557" +checksum = "e97e415490559a91254a2979b4829267a57d2fcd741a98eee8b722fb57289aa0" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.7.25" +version = "0.7.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2f140bda219a26ccc0cdb03dba58af72590c53b22642577d88a927bc5c87d6b" +checksum = "dd7e48ccf166952882ca8bd778a43502c64f33bf94c12ebe2a7f08e5a0f6689f" dependencies = [ "proc-macro2", "quote", @@ -3013,6 +3049,6 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0956f1ba7c7909bfb66c2e9e4124ab6f6482560f6628b5aaeba39207c9aad9" +checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" diff --git a/api/Cargo.toml b/api/Cargo.toml index 41a6a1727..ee20ed99f 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -10,15 +10,15 @@ publish = false [dependencies] # Config -clap = { version = "4.4.7", features = ["derive", "env"]} +clap = { version = "4.4.10", features = ["derive", "env"]} dotenv = "0.15.0" structopt = "0.3" # Web -axum = { version = "0.6.20", features = ["headers"] } +axum = { version = "0.6.20", features = ["headers", "multipart"] } axum-macros = "0.3.8" hyper = { version = "0.14.27", features = ["full"] } -tokio = { version = "1.33.0", features = ["full"] } +tokio = { version = "1.34.0", features = ["full"] } tower = "0.4.13" tower-http = { version = "0.3.5", features = ["cors", "trace"] } @@ -30,12 +30,12 @@ aws-config = "0.51.0" aws-sdk-s3 = "0.21.0" # Serialization -serde = {version = "1.0.192", features = ["derive"]} +serde = {version = "1.0.193", features = ["derive"]} serde_json = "1.0.108" # Logging tracing = "0.1.40" -tracing-subscriber = { version="0.3.17", features = ["env-filter"] } +tracing-subscriber = { version="0.3.18", features = ["env-filter"] } # Errors anyhow = "1.0.75" @@ -45,6 +45,7 @@ thiserror = "1.0.50" chrono = { version = "0.4.31", features = ["serde"]} once_cell = "1.18.0" reqwest = { version = "0.11.22", default-features = false, features = ["rustls-tls", "hyper-rustls", "json"]} -url = "2.4.1" -uuid = { version = "1.5.0", features = ["serde", "v4"] } +url = "2.5.0" +uuid = { version = "1.6.1", features = ["serde", "v4"] } jsonwebtoken = "8.3.0" +rand = "0.8.5" diff --git a/api/Dockerfile b/api/Dockerfile index 95a4b5303..aad08a856 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -44,7 +44,7 @@ RUN cargo fmt --all -- --check # we don't care about linting dependencies, but clippy still does so despite our use # of --package and --no-deps flags # this is relatively fast so not a blocker -RUN cargo clippy --package api --target x86_64-unknown-linux-musl --tests --examples --offline --release -- -D warnings --no-deps +RUN cargo clippy --package api --target x86_64-unknown-linux-musl --tests --examples --offline --release --no-deps # The tests actually requires a live DB so it must be run after the image is built # See "make acceptance" diff --git a/api/src/handlers.rs b/api/src/handlers.rs index 0b3181246..49a320f92 100644 --- a/api/src/handlers.rs +++ b/api/src/handlers.rs @@ -1,7 +1,6 @@ use aws_sdk_s3::Client; -use axum::extract::Path; use axum::{ - extract::{Extension, Json}, + extract::{Extension, Json, Multipart, Path}, http::StatusCode, }; use chrono::{DateTime, Utc}; @@ -11,6 +10,9 @@ use uuid::Uuid; use crate::auth::Claims; use crate::{Error, Result}; +use rand::{distributions::Alphanumeric, Rng}; +use serde_json::Number; +use std::collections::HashSet; #[derive(Serialize, Deserialize, Clone, Debug, FromRow)] pub struct CreateProject { @@ -25,6 +27,8 @@ pub struct CreateProject { pub views: Vec, #[serde(default)] pub assets: Vec, + #[serde(default)] + pub geometries: Vec, } #[derive(Serialize, Deserialize, Clone, Debug, FromRow)] @@ -43,6 +47,8 @@ pub struct Project { pub owner: String, pub viewers: Vec, pub members: Vec, + #[serde(default)] + pub geometries: Vec, } #[derive(Serialize, Deserialize, Clone, Debug, FromRow)] @@ -54,7 +60,63 @@ pub struct View { #[derive(Serialize, Deserialize, Clone, Debug, FromRow)] pub struct Asset { - pub href: String, + pub name: String, + pub key: String, +} + +#[derive(Serialize, Deserialize, Clone, Debug, FromRow)] +#[allow(non_snake_case)] +pub struct Geometry { + r#type: String, + positions: Vec, + id: Option, + name: Option, + show: Option, + area: Option, + perimeter: Option, + sidesLength: Option>, + numberOfSegments: Option, + description: Option, + image: Option, + website: Option, + pointSymbol: Option, + color: Option, + clampPoint: Option, + showSlicingBox: Option, + volumeShowed: Option, + volumeHeightLimits: Option, + swissforagesId: Option, + depth: Option, + editable: Option, + copyable: Option, + fromTopic: Option, +} + +#[derive(Serialize, Deserialize, Clone, Debug, FromRow)] +struct Cartesian3 { + x: Number, + y: Number, + z: Number, +} + +#[derive(Serialize, Deserialize, Clone, Debug, FromRow)] +struct CesiumColor { + red: Number, + green: Number, + blue: Number, + alpha: Number, +} + +#[derive(Serialize, Deserialize, Clone, Debug, FromRow)] +struct GeometryVolumeHeightLimits { + #[allow(non_snake_case)] + lowerLimit: Number, + height: Number, +} + +#[derive(Serialize)] +pub struct UploadResponse { + pub key: String, } // Health check endpoint @@ -75,6 +137,7 @@ pub async fn health_check(Extension(pool): Extension) -> (StatusCode, St #[axum_macros::debug_handler] pub async fn create_project( Extension(pool): Extension, + Extension(client): Extension, claims: Claims, Json(project): Json, ) -> Result> { @@ -86,6 +149,8 @@ pub async fn create_project( )); } + save_assets(client, &project.assets).await; + // Create project let project = Project { id: Uuid::new_v4(), @@ -100,6 +165,7 @@ pub async fn create_project( owner: claims.email, viewers: project.viewers, members: project.members, + geometries: project.geometries, }; let result = sqlx::query_scalar!( @@ -132,10 +198,43 @@ pub async fn get_project( pub async fn update_project( Path(id): Path, Extension(pool): Extension, + Extension(client): Extension, _claims: Claims, Json(mut project): Json, ) -> Result { // TODO: Validate rights + + let bucket = std::env::var("PROJECTS_S3_BUCKET").unwrap(); + + let saved_project: Project = sqlx::query_scalar!( + r#"SELECT project as "project: sqlx::types::Json" FROM projects WHERE id = $1"#, + id + ) + .fetch_one(&pool) + .await? + .0; + + let project_assets = &project.assets; + let saved_project_keys: HashSet<_> = saved_project.assets.into_iter().map(|a| a.key).collect(); + let new_project_keys: HashSet<_> = project_assets.into_iter().map(|a| a.key.clone()).collect(); + + // Find keys that are in saved_project_keys but not in new_project_keys + let keys_to_delete: HashSet<_> = saved_project_keys.difference(&new_project_keys).collect(); + + for key in keys_to_delete { + let path = format!("assets/saved/{}", key); + + client + .delete_object() + .bucket(&bucket) + .key(&path) + .send() + .await + .unwrap(); + } + + save_assets(client, project_assets).await; + project.modified = Some(Utc::now()); sqlx::query_scalar!( "UPDATE projects SET project = project || CAST( $2 as JSONB) WHERE id = $1 RETURNING id", @@ -148,6 +247,37 @@ pub async fn update_project( Ok(StatusCode::NO_CONTENT) } +#[axum_macros::debug_handler] +pub async fn update_project_geometries( + Path(id): Path, + Extension(pool): Extension, + Extension(_client): Extension, + _claims: Claims, + Json(geometries): Json>, +) -> Result { + // TODO: Validate rights + + let mut project: Project = sqlx::query_scalar!( + r#"SELECT project as "project: sqlx::types::Json" FROM projects WHERE id = $1"#, + id + ) + .fetch_one(&pool) + .await? + .0; + + project.geometries = geometries; + + sqlx::query_scalar!( + "UPDATE projects SET project = project || CAST( $2 as JSONB) WHERE id = $1 RETURNING id", + id, + sqlx::types::Json(project) as _ + ) + .fetch_one(&pool) + .await?; + + Ok(StatusCode::NO_CONTENT) +} + #[axum_macros::debug_handler] pub async fn list_projects( Extension(pool): Extension, @@ -203,10 +333,11 @@ pub async fn duplicate_project( owner: claims.email, viewers: Vec::new(), members: Vec::new(), + geometries: project.geometries, }; // // TODO: make static - // let bucket = std::env::var("S3_BUCKET").unwrap(); + // let bucket = std::env::var("PROJECTS_S3_BUCKET").unwrap(); // for asset in &project.assets { // let url = Url::parse(asset.href.as_str()).context("Failed to parse asset url")?; @@ -240,3 +371,81 @@ pub async fn duplicate_project( Ok(Json(result)) } + +pub async fn upload_asset( + Extension(_pool): Extension, + Extension(client): Extension, + mut multipart: Multipart, +) -> Result> { + let bucket = std::env::var("PROJECTS_S3_BUCKET").unwrap(); + let rand_string: String = rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(40) + .map(char::from) + .collect(); + let generated_file_name: String = format!("{}_{}.kml", Utc::now().timestamp(), rand_string); + let temp_name = format!("assets/temp/{}", generated_file_name); + while let Some(field) = multipart.next_field().await.unwrap() { + if field.name() == Some("file") { + let bytes = field.bytes().await.unwrap(); + + client + .put_object() + .bucket(&bucket) + .key(&temp_name) + .body(bytes.into()) + .send() + .await + .unwrap(); + } + } + + return Ok(Json(UploadResponse { + key: generated_file_name, + })); +} + +async fn save_assets(client: Client, project_assets: &Vec) { + let bucket = std::env::var("PROJECTS_S3_BUCKET").unwrap(); + for asset in project_assets { + let temp_key = format!("assets/temp/{}", asset.key); + let permanent_key = format!("assets/saved/{}", asset.key); + + // Check if the file exists in the source directory + let source_exists = client + .head_object() + .bucket(&bucket) + .key(&temp_key) + .send() + .await + .is_ok(); + + // Check if the file does not exist in the destination directory + let destination_exists = client + .head_object() + .bucket(&bucket) + .key(&permanent_key) + .send() + .await + .is_ok(); + + if source_exists && !destination_exists { + client + .copy_object() + .bucket(&bucket) + .copy_source(format!("{}/{}", &bucket, &temp_key)) + .key(&permanent_key) + .send() + .await + .unwrap(); + + client + .delete_object() + .bucket(&bucket) + .key(&temp_key) + .send() + .await + .unwrap(); + } + } +} diff --git a/api/src/lib.rs b/api/src/lib.rs index fb666f143..069a3f38b 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -3,6 +3,7 @@ use axum::{ http::{HeaderValue, Method}, routing::get, routing::post, + routing::put, Router, }; use clap::Parser; @@ -49,6 +50,11 @@ pub async fn app(pool: PgPool) -> Router { "/api/projects/:id", get(handlers::get_project).put(handlers::update_project), ) + .route( + "/api/projects/:id/geometries", + put(handlers::update_project_geometries), + ) + .route("/api/projects/upload_asset", post(handlers::upload_asset)) .layer( ServiceBuilder::new() .layer(TraceLayer::new_for_http()) diff --git a/docker-compose.yaml b/docker-compose.yaml index 7dcd097af..5fd61b0b1 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -11,6 +11,7 @@ services: AWS_ACCESS_KEY_ID: &minio_user minio AWS_SECRET_ACCESS_KEY: &minio_pass minio123 S3_BUCKET: ngmpub-userdata-local + PROJECTS_S3_BUCKET: ngmpub-project-files-local S3_ENDPOINT: http://minio:9000 # Cognito COGNITO_CLIENT_ID: 6brvjsufv7fdubr12r9u0gajnj diff --git a/ui/cypress/e2e/toolbox.cy.ts b/ui/cypress/e2e/toolbox.cy.ts index f3c4502cb..3f6fb8841 100644 --- a/ui/cypress/e2e/toolbox.cy.ts +++ b/ui/cypress/e2e/toolbox.cy.ts @@ -4,7 +4,7 @@ const testGstOutput = () => { }).as('createSection'); cy.get('ngm-gst-interaction .ngm-action-list-item.ngm-geom-item').should('not.have.class', 'disabled'); cy.get('ngm-gst-interaction .ngm-action-list-item.ngm-geom-item').click(); - cy.get('ngm-gst-interaction').click(1, 1); + cy.get('ngm-gst-interaction .ngm-geom-item .ngm-action-list-item-header > div:first-child').click(5, 5); cy.get('.ngm-gst-container .ngm-action-btn').click(); cy.get('.ngm-gst-modal', {timeout: 15000}).should('be.visible'); cy.get('ngm-gst-modal').then(el => { diff --git a/ui/locales/app.de.json b/ui/locales/app.de.json index cc20111aa..6f2276a53 100644 --- a/ui/locales/app.de.json +++ b/ui/locales/app.de.json @@ -20,11 +20,15 @@ "dashboard_description": "Kurzbeschrieb", "dashboard_modified_title": "Geändert am", "dashboard_my_projects": "Meine Projekte", + "dashboard_no_assets_text": "", + "dashboard_no_geom_text": "", "dashboard_overview": "Überblick Projekte", "dashboard_overview_not_logged_in": "Erstelle ein Konto oder logge dich ein, um deine eigenen Projekte zu erstellen und sie mit deinem Team zu teilen!", "dashboard_project_create": "Projekt erstellen", "dashboard_project_create_btn": "Eigene Einstellungen als Vorlage oder Projekt speichern", "dashboard_project_create_error": "Fehler beim Erstellen des Projekts", + "dashboard_project_geometries": "", + "dashboard_project_kml": "", "dashboard_project_view": "Ansicht", "dashboard_recent_swisstopo": "Neue Vorlagen swisstopo", "dashboard_recently_viewed": "Kürzlich besucht", diff --git a/ui/locales/app.en.json b/ui/locales/app.en.json index 02b74cd5b..42e01ed34 100644 --- a/ui/locales/app.en.json +++ b/ui/locales/app.en.json @@ -20,11 +20,15 @@ "dashboard_description": "Description", "dashboard_modified_title": "Modified on", "dashboard_my_projects": "My projects", + "dashboard_no_assets_text": "No uploaded KMLs", + "dashboard_no_geom_text": "No geometries were added. Choose view and navigate draw tool to add some", "dashboard_overview": "Projects overview", "dashboard_overview_not_logged_in": "Create an account or log in to create your own projects and share them to your team!", "dashboard_project_create": "Project create", "dashboard_project_create_btn": "Save personal settings as a template or project", "dashboard_project_create_error": "Error while creating project", + "dashboard_project_geometries": "Geometries", + "dashboard_project_kml": "KML", "dashboard_project_view": "View", "dashboard_recent_swisstopo": "Newest swisstopo projects", "dashboard_recently_viewed": "Recently viewed", diff --git a/ui/locales/app.fr.json b/ui/locales/app.fr.json index c254c2ca3..8f6f4d978 100644 --- a/ui/locales/app.fr.json +++ b/ui/locales/app.fr.json @@ -20,11 +20,15 @@ "dashboard_description": "Description", "dashboard_modified_title": "Modifié le", "dashboard_my_projects": "Mes projets", + "dashboard_no_assets_text": "", + "dashboard_no_geom_text": "", "dashboard_overview": "Vue d'ensemble des projets", "dashboard_overview_not_logged_in": "Créez un compte ou connectez-vous pour créer vos propres projets et les partager avec votre équipe!", "dashboard_project_create": "Créer un projet", "dashboard_project_create_btn": "Enregistrer les paramètres personnels sous forme de modèle ou de projet", "dashboard_project_create_error": "Erreur lors de la création du projet", + "dashboard_project_geometries": "", + "dashboard_project_kml": "", "dashboard_project_view": "Vue", "dashboard_recent_swisstopo": "Projets récents de swisstopo", "dashboard_recently_viewed": "Dernièrement visité", diff --git a/ui/locales/app.it.json b/ui/locales/app.it.json index b6a7f3cd9..b6b20677e 100644 --- a/ui/locales/app.it.json +++ b/ui/locales/app.it.json @@ -20,11 +20,15 @@ "dashboard_description": "Descrizione", "dashboard_modified_title": "Modificato il", "dashboard_my_projects": "I miei progetti", + "dashboard_no_assets_text": "", + "dashboard_no_geom_text": "", "dashboard_overview": "Panoramica dei progetti", "dashboard_overview_not_logged_in": "Crea un account o accedi per creare i tuoi progetti e condividerli con la tua squadra!", "dashboard_project_create": "Creare un progetto", "dashboard_project_create_btn": "Salvare le impostazioni personali come modello o progetto", "dashboard_project_create_error": "Errore durante la creazione del progetto", + "dashboard_project_geometries": "", + "dashboard_project_kml": "", "dashboard_project_view": "Vista", "dashboard_recent_swisstopo": "Progetti swisstopo recenti", "dashboard_recently_viewed": "Visito recentemente", diff --git a/ui/src/api-client.ts b/ui/src/api-client.ts index 2f984e74b..bde32d97c 100644 --- a/ui/src/api-client.ts +++ b/ui/src/api-client.ts @@ -3,10 +3,11 @@ import AuthStore from './store/auth'; import {API_BY_PAGE_HOST} from './constants'; import type {CreateProject, Project} from './elements/dashboard/ngm-dashboard'; import {Subject} from 'rxjs'; +import {NgmGeometry} from './toolbox/interfaces'; class ApiClient { - projectsChange = new Subject(); + projectsChange = new Subject(); token = Auth.getAccessToken(); private apiUrl: string; @@ -15,10 +16,16 @@ class ApiClient { AuthStore.user.subscribe(() => { this.token = Auth.getAccessToken(); - this.projectsChange.next(); + this.refreshProjects(); }); } + async refreshProjects() { + const response = await this.getProjects(); + const projects = await response.json(); + this.projectsChange.next(projects); + } + updateProject(project: Project): Promise { const headers = { 'Content-Type': 'application/json' @@ -32,11 +39,24 @@ class ApiClient { body: JSON.stringify(project), }) .then(response => { - this.projectsChange.next(); + this.refreshProjects(); return response; }); } + updateProjectGeometries(id: string, geometries: NgmGeometry[]): Promise { + const headers = { + 'Content-Type': 'application/json' + }; + + addAuthorization(headers, this.token); + + return fetch(`${this.apiUrl}/projects/${id}/geometries`, { + method: 'PUT', + headers: headers, + body: JSON.stringify(geometries), + }); + } getProject(id: string): Promise { const headers = { @@ -79,7 +99,7 @@ class ApiClient { body: JSON.stringify(project), }) .then(response => { - this.projectsChange.next(); + this.refreshProjects(); return response; }); } @@ -97,9 +117,23 @@ class ApiClient { headers: headers, body: JSON.stringify(project), }); - this.projectsChange.next(); + this.refreshProjects(); return response; } + + async uploadProjectAsset(file: File) { + const headers = {}; + const formData = new FormData(); + formData.append('file', file); + + addAuthorization(headers, this.token); + + return fetch(`${this.apiUrl}/projects/upload_asset`, { + method: 'POST', + headers: headers, + body: formData + }); + } } diff --git a/ui/src/constants.ts b/ui/src/constants.ts index 6406c2c0e..60561d8aa 100644 --- a/ui/src/constants.ts +++ b/ui/src/constants.ts @@ -52,6 +52,10 @@ export const OBJECT_HIGHLIGHT_COLOR = Color.fromCssColorString('#B3FF30', new Co export const SWISSTOPO_IT_HIGHLIGHT_COLOR = Color.fromCssColorString('#ff8000', new Color()); export const OBJECT_ZOOMTO_RADIUS = 500; +const hostname = document.location.hostname; +export const PROJECT_ASSET_URL = hostname === 'localhost' ? + 'http://localhost:9000/ngmpub-project-files-local/assets/saved/' : `https://project-files.${hostname}/assets/saved/`; + export const DEFAULT_VOLUME_HEIGHT_LIMITS = { lowerLimit: -5000, height: 10000 diff --git a/ui/src/elements/dashboard/ngm-dashboard.ts b/ui/src/elements/dashboard/ngm-dashboard.ts index ed8e06bf8..254310039 100644 --- a/ui/src/elements/dashboard/ngm-dashboard.ts +++ b/ui/src/elements/dashboard/ngm-dashboard.ts @@ -1,6 +1,6 @@ import {LitElementI18n, translated} from '../../i18n'; import {customElement, property, query, state} from 'lit/decorators.js'; -import {html} from 'lit'; +import {html, PropertyValues} from 'lit'; import i18next from 'i18next'; import {styleMap} from 'lit/directives/style-map.js'; import {classMap} from 'lit-html/directives/class-map.js'; @@ -14,7 +14,7 @@ import type {Viewer} from 'cesium'; import {CustomDataSource, KmlDataSource} from 'cesium'; import {showSnackbarError, showBannerWarning} from '../../notifications'; import type {Config} from '../../layers/ngm-layers-item'; -import {DEFAULT_LAYER_OPACITY, DEFAULT_PROJECT_COLOR} from '../../constants'; +import {DEFAULT_LAYER_OPACITY, DEFAULT_PROJECT_COLOR, PROJECT_ASSET_URL} from '../../constants'; import {fromGeoJSON} from '../../toolbox/helpers'; import type {NgmGeometry} from '../../toolbox/interfaces'; import {apiClient} from '../../api-client'; @@ -39,7 +39,8 @@ export interface View { } export interface Asset { - href: string, + name: string, + key: string, } export interface Topic { @@ -52,7 +53,7 @@ export interface Topic { color: string, views: View[], assets: Asset[], - geometries?: Array, + geometries?: NgmGeometry[], } export interface CreateProject { @@ -62,7 +63,7 @@ export interface CreateProject { color: string, views: View[], assets: Asset[], - geometries?: Array, + geometries?: NgmGeometry[], owner: string, members: string[], viewers: string[], @@ -93,9 +94,7 @@ export class NgmDashboard extends LitElementI18n { @state() accessor selectedViewIndx: number | undefined; @state() - accessor projectEditMode = false; - @state() - accessor projectCreateMode = false; + accessor projectMode: 'edit' | 'create' | 'view' = 'view'; @state() accessor saveOrCancelWarning = false; @query('.ngm-toast-placeholder') @@ -114,7 +113,12 @@ export class NgmDashboard extends LitElementI18n { MainStore.viewer.subscribe(viewer => this.viewer = viewer); fetch('./src/sampleData/topics.json').then(topicsResponse => topicsResponse.json().then(topics => { - this.topics = topics.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime()); + this.topics = topics.map(topic => { + if (topic.geometries) { + topic.geometries = this.getGeometries(topic.geometries); + } + return topic; + }).sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime()); DashboardStore.topicParam.subscribe(async param => { if (!param) return; const {viewId, topicId} = param; @@ -152,25 +156,30 @@ export class NgmDashboard extends LitElementI18n { // FIXME: extract from claims this.userEmail = user?.username.split('_')[1]; }); - apiClient.projectsChange.subscribe(() => { - apiClient.getProjects() - .then(response => response.json()) - .then(body => this.projects = body); - const project = this.projects.find(p => p.id === this.selectedTopicOrProject?.id); - if (project) {this.selectedTopicOrProject = project;} + apiClient.projectsChange.subscribe((projects) => { + this.refreshProjects(projects); }); - this.refreshProjects(); - } + DashboardStore.geometriesUpdate.subscribe(geometries => { + if (this.selectedTopicOrProject) { + this.selectTopicOrProject({...this.selectedTopicOrProject, geometries}); + } else if (this.projectToCreate) { + this.projectToCreate = {...this.projectToCreate, geometries}; + } + }); + apiClient.refreshProjects(); - refreshProjects() { - if (apiClient.token) { - apiClient.getProjects() - .then(response => response.json()) - .then(body => this.projects = body); - const project = this.projects.find(p => p.id === this.selectedTopicOrProject?.id); - if (project) { - this.selectedTopicOrProject = project; + DashboardStore.onSaveOrCancelWarning.subscribe(show => { + if (this.projectMode !== 'view') { + this.saveOrCancelWarning = show; } + }); + } + + refreshProjects(projects: Project[]) { + this.projects = projects; + const project = this.projects.find(p => p.id === this.selectedTopicOrProject?.id); + if (project) { + this.selectTopicOrProject(project); } } @@ -189,17 +198,18 @@ export class NgmDashboard extends LitElementI18n { if (!this.viewer) return assetsData; for (const asset of assets) { try { - const dataSources = this.viewer.dataSources.getByName(asset.href); + const href = `${PROJECT_ASSET_URL}${asset.key}`; + const dataSources = this.viewer.dataSources.getByName(href); let uploadedLayer: CustomDataSource; if (dataSources.length) { uploadedLayer = dataSources[0]; uploadedLayer.show = true; } else { - const kmlDataSource = await KmlDataSource.load(asset.href, { + const kmlDataSource = await KmlDataSource.load(href, { camera: this.viewer.scene.camera, canvas: this.viewer.scene.canvas }); - uploadedLayer = new CustomDataSource(asset.href); + uploadedLayer = new CustomDataSource(href); let name = kmlDataSource.name; kmlDataSource.entities.values.forEach((ent, indx) => { if (indx === 0 && !name) { @@ -207,7 +217,7 @@ export class NgmDashboard extends LitElementI18n { } uploadedLayer.entities.add(ent); }); - this.assetConfigs[asset.href] = { + this.assetConfigs[href] = { label: name, zoomToBbox: true, opacity: DEFAULT_LAYER_OPACITY, @@ -218,7 +228,7 @@ export class NgmDashboard extends LitElementI18n { } const promise = Promise.resolve(uploadedLayer); assetsData.push({ - ...this.assetConfigs[asset.href], + ...this.assetConfigs[href], displayed: false, load() { return promise; @@ -253,13 +263,12 @@ export class NgmDashboard extends LitElementI18n { await this.setDataFromPermalink(); } - selectTopicOrProject(topic: Topic | Project) { - this.selectedTopicOrProject = topic; - if (this.selectedTopicOrProject.geometries) { - this.geometries = this.getGeometries(this.selectedTopicOrProject.geometries); - } + selectTopicOrProject(topicOrProject: Topic | Project | undefined) { + this.selectedTopicOrProject = topicOrProject; DashboardStore.setSelectedTopicOrProject(this.selectedTopicOrProject); - this.addRecentlyViewedTopicOrProject(topic); + if (topicOrProject) { + this.addRecentlyViewedTopicOrProject(topicOrProject); + } } removeGeometries() { @@ -270,10 +279,9 @@ export class NgmDashboard extends LitElementI18n { deselectTopicOrProject() { this.runIfNotEditCreate(() => { - this.selectedTopicOrProject = undefined; + this.selectTopicOrProject(undefined); this.assets = []; this.removeGeometries(); - DashboardStore.setSelectedTopicOrProject(undefined); }); } @@ -324,40 +332,43 @@ export class NgmDashboard extends LitElementI18n { members: [], viewers: [], }; - this.projectCreateMode = true; + this.projectMode = 'create'; + } + + onProjectEdit() { + this.projectMode = 'edit'; } - async onProjectSave(project: Project) { - if (this.projectEditMode) { - await apiClient.updateProject(project); - this.projectEditMode = false; - } else if (this.projectCreateMode && this.projectToCreate) { + async onProjectSave(project: Project | CreateProject) { + if (this.projectMode === 'edit') { + await apiClient.updateProject(project); + this.projectMode = 'view'; + } else if (this.projectMode === 'create' && this.projectToCreate) { try { - const response = await apiClient.createProject(this.projectToCreate); + const response = await apiClient.createProject(project); const id = await response.json(); const projectResponse = await apiClient.getProject(id); - const project = await projectResponse.json(); - this.selectTopicOrProject(project); + const createdProject = await projectResponse.json(); + this.selectTopicOrProject(createdProject); } catch (e) { console.error(e); showSnackbarError(i18next.t('dashboard_project_create_error')); } - this.projectCreateMode = false; + this.projectMode = 'view'; this.projectToCreate = undefined; } this.saveOrCancelWarning = false; } cancelEditCreate() { - this.refreshProjects(); - this.projectEditMode = false; - this.projectCreateMode = false; + apiClient.refreshProjects(); + this.projectMode = 'view'; this.saveOrCancelWarning = false; this.projectToCreate = undefined; } runIfNotEditCreate(callback: () => void) { - if (this.projectEditMode || this.projectCreateMode) { + if (this.projectMode !== 'view') { this.saveOrCancelWarning = true; } else { callback(); @@ -417,6 +428,13 @@ export class NgmDashboard extends LitElementI18n { return html``; } + updated(changedProperties: PropertyValues) { + if (changedProperties.has('projectMode')) { + DashboardStore.setProjectMode(this.projectMode !== 'view' ? 'edit' : undefined); + } + super.updated(changedProperties); + } + render() { return html`
@@ -478,11 +496,11 @@ export class NgmDashboard extends LitElementI18n {
- ${this.projectEditMode || this.projectCreateMode ? + ${this.projectMode !== 'view' ? html`` : @@ -493,7 +511,7 @@ export class NgmDashboard extends LitElementI18n { .selectedViewIndx="${this.selectedViewIndx}" .userEmail="${this.userEmail}" @onDeselect="${this.deselectTopicOrProject}" - @onEdit="${() => this.projectEditMode = true}" + @onEdit="${this.onProjectEdit}" @onProjectDuplicated="${(evt: {detail: {project: Project}}) => this.onProjectDuplicated(evt.detail.project)}" >`}
diff --git a/ui/src/elements/dashboard/ngm-project-assets-section.ts b/ui/src/elements/dashboard/ngm-project-assets-section.ts new file mode 100644 index 000000000..1f0e248af --- /dev/null +++ b/ui/src/elements/dashboard/ngm-project-assets-section.ts @@ -0,0 +1,86 @@ +import {customElement, property, state} from 'lit/decorators.js'; +import {LitElementI18n} from '../../i18n'; +import {html, PropertyValues} from 'lit'; +import i18next from 'i18next'; +import {classMap} from 'lit/directives/class-map.js'; +import {Asset} from './ngm-dashboard'; + +@customElement('ngm-project-assets-section') +export class NgmProjectAssetsSection extends LitElementI18n { + @property({type: Array}) + accessor assets: Asset[] = []; + @property({type: Object}) + accessor toastPlaceholder!: HTMLElement; + @property({type: Function}) + accessor onKmlUpload: ((file: File) => Promise | void) | undefined; + @property({type: Boolean}) + accessor viewMode: boolean = false; + @state() + accessor kmlEditIndex: number | undefined; + + updated(changedProperties: PropertyValues) { + if (changedProperties.has('assets')) { + this.dispatchEvent(new CustomEvent('assetsChanged', {detail: {assets: this.assets}})); + } + super.updated(changedProperties); + } + + editButtons(index: number) { + return html` +
{ + this.kmlEditIndex = this.kmlEditIndex === index ? undefined : index; + }}> +
+
{ + this.assets.splice(index, 1); + this.assets = [...this.assets]; + }}> +
`; + } + + render() { + return html` +
+
+
+
${i18next.t('dashboard_project_kml')}
+
+
+ ${this.viewMode ? '' : html` + `} + ${this.assets.map((kml, index) => { + return html` +
+
+
+ ${this.kmlEditIndex !== index ? kml.name : html` +
+ { + kml.name = evt.target.value; + this.assets[index] = kml; + this.assets = [...this.assets]; + }}/> +
`} +
+ ${this.viewMode ? '' : this.editButtons(index)} +
+
+ `; + })} +
0}>${i18next.t('dashboard_no_assets_text')}
+
+
`; + } + + createRenderRoot() { + return this; + } + +} \ No newline at end of file diff --git a/ui/src/elements/dashboard/ngm-project-edit.ts b/ui/src/elements/dashboard/ngm-project-edit.ts index 663a8f3c7..e2071b966 100644 --- a/ui/src/elements/dashboard/ngm-project-edit.ts +++ b/ui/src/elements/dashboard/ngm-project-edit.ts @@ -4,9 +4,15 @@ import i18next from 'i18next'; import {classMap} from 'lit/directives/class-map.js'; import {styleMap} from 'lit/directives/style-map.js'; import {COLORS_WITH_BLACK_TICK, PROJECT_COLORS} from '../../constants'; -import {CreateProject, Project} from './ngm-dashboard'; -import {property, customElement} from 'lit/decorators.js'; +import {Asset, CreateProject, Project} from './ngm-dashboard'; +import {customElement, property, query} from 'lit/decorators.js'; import $ from '../../jquery'; +import '../../toolbox/ngm-geometries-list'; +import '../../layers/ngm-layers-upload'; +import {apiClient} from '../../api-client'; +import {showSnackbarError} from '../../notifications'; +import './ngm-project-geoms-section'; +import './ngm-project-assets-section'; @customElement('ngm-project-edit') export class NgmProjectEdit extends LitElementI18n { @@ -16,6 +22,23 @@ export class NgmProjectEdit extends LitElementI18n { accessor saveOrCancelWarning = false; @property({type: Boolean}) accessor createMode = true; + @query('.ngm-proj-toast-placeholder') + accessor toastPlaceholder; + + async onKmlUpload(file: File) { + if (!this.project) return; + try { + const response = await apiClient.uploadProjectAsset(file); + const key: string = (await response.json())?.key; + if (key) { + const assets = [...this.project!.assets, {name: file.name, key}]; + this.project = {...this.project, assets}; + } + } catch (e) { + console.error(e); + showSnackbarError(i18next.t('dtd_cant_upload_kml_error')); + } + } shouldUpdate(_changedProperties: PropertyValues): boolean { return this.project !== undefined; @@ -27,13 +50,15 @@ export class NgmProjectEdit extends LitElementI18n { } render() { - const project: Project = this.project; + if (!this.project) return ''; + const project = this.project; const backgroundImage = project.image?.length ? `url('${project.image}')` : ''; return html`
${i18next.t('project_lost_changes_warning')}
+
project.title} @@ -50,7 +75,7 @@ export class NgmProjectEdit extends LitElementI18n {
- ${`${i18next.t('dashboard_modified_title')} ${toLocaleDateString(project.modified)} ${i18next.t('dashboard_by_swisstopo_title')}`} + ${`${i18next.t('dashboard_modified_title')} ${toLocaleDateString((project).modified)} ${i18next.t('dashboard_by_swisstopo_title')}`}
@@ -83,11 +108,11 @@ export class NgmProjectEdit extends LitElementI18n {
-
+
${i18next.t('dashboard_views')}
-
+
${project.views.map((view, index) => html`
@@ -130,6 +155,18 @@ export class NgmProjectEdit extends LitElementI18n { `)}
+
+ + +
+
this.dispatchEvent(new CustomEvent('onBack'))}>
${i18next.t('dashboard_back_to_topics')} diff --git a/ui/src/elements/dashboard/ngm-project-geoms-section.ts b/ui/src/elements/dashboard/ngm-project-geoms-section.ts new file mode 100644 index 000000000..0e6a19dc8 --- /dev/null +++ b/ui/src/elements/dashboard/ngm-project-geoms-section.ts @@ -0,0 +1,39 @@ +import {customElement, property} from 'lit/decorators.js'; +import {LitElementI18n} from '../../i18n'; +import {html} from 'lit'; +import i18next from 'i18next'; +import {NgmGeometry} from '../../toolbox/interfaces'; + +@customElement('ngm-project-geoms-section') +export class NgmProjectGeomsSection extends LitElementI18n { + @property({type: Boolean}) + accessor viewMode = false; + @property({type: Array}) + accessor geometries: NgmGeometry[] = []; + + render() { + return html` +
+
+
+
${i18next.t('dashboard_project_geometries')}
+
+
+ ${this.geometries?.length ? + html` + + ` : + html` +
${i18next.t('dashboard_no_geom_text')}
`} +
+
`; + } + + createRenderRoot() { + return this; + } + +} \ No newline at end of file diff --git a/ui/src/elements/dashboard/ngm-project-topic-overview.ts b/ui/src/elements/dashboard/ngm-project-topic-overview.ts index d196787d2..3a67166f2 100644 --- a/ui/src/elements/dashboard/ngm-project-topic-overview.ts +++ b/ui/src/elements/dashboard/ngm-project-topic-overview.ts @@ -1,4 +1,4 @@ -import {property, customElement} from 'lit/decorators.js'; +import {customElement, property} from 'lit/decorators.js'; import {LitElementI18n, toLocaleDateString, translated} from '../../i18n'; import {html, PropertyValues} from 'lit'; import i18next from 'i18next'; @@ -10,6 +10,8 @@ import {apiClient} from '../../api-client'; import {showBannerSuccess} from '../../notifications'; import $ from '../../jquery'; import {DEFAULT_PROJECT_COLOR} from '../../constants'; +import './ngm-project-geoms-section'; +import './ngm-project-assets-section'; @customElement('ngm-project-topic-overview') export class NgmProjectTopicOverview extends LitElementI18n { @@ -73,7 +75,7 @@ export class NgmProjectTopicOverview extends LitElementI18n {
-
+
${i18next.t('dashboard_views')}
@@ -92,6 +94,15 @@ export class NgmProjectTopicOverview extends LitElementI18n { `)}
+
+ + +
+
this.dispatchEvent(new CustomEvent('onDeselect'))}>
${i18next.t('dashboard_back_to_topics')} diff --git a/ui/src/elements/ngm-auth.ts b/ui/src/elements/ngm-auth.ts index e5e26fc5d..db816256c 100644 --- a/ui/src/elements/ngm-auth.ts +++ b/ui/src/elements/ngm-auth.ts @@ -6,6 +6,7 @@ import {LitElementI18n} from '../i18n.js'; import auth from '../store/auth'; import {classMap} from 'lit/directives/class-map.js'; import {customElement, property, state} from 'lit/decorators.js'; +import DashboardStore from '../store/dashboard'; /** @@ -49,6 +50,10 @@ export class NgmAuth extends LitElementI18n { } logout() { + if (DashboardStore.projectMode.value === 'edit') { + DashboardStore.showSaveOrCancelWarning(true); + return; + } Auth.logout(); } diff --git a/ui/src/elements/ngm-side-bar.ts b/ui/src/elements/ngm-side-bar.ts index a36fe8fdd..122e9edfd 100644 --- a/ui/src/elements/ngm-side-bar.ts +++ b/ui/src/elements/ngm-side-bar.ts @@ -26,11 +26,11 @@ import { BoundingSphere, ScreenSpaceEventHandler, ScreenSpaceEventType, - Math as CMath + Math as CMath, KmlDataSource, CustomDataSource } from 'cesium'; import {showSnackbarError, showSnackbarInfo} from '../notifications'; import auth from '../store/auth'; -import './ngm-share-link.ts'; +import './ngm-share-link'; import '../layers/ngm-layers-upload'; import MainStore from '../store/main'; import {classMap} from 'lit/directives/class-map.js'; @@ -43,6 +43,7 @@ import NavToolsStore from '../store/navTools'; import {getLayerLabel} from '../swisstopoImagery.js'; import type {Config} from '../layers/ngm-layers-item.js'; +import DashboardStore from '../store/dashboard'; @customElement('ngm-side-bar') export class SideBar extends LitElementI18n { @@ -203,7 +204,7 @@ export class SideBar extends LitElementI18n { this.activePanel = ''} - @layerclick=${evt => this.onCatalogLayerClicked(evt)} + @layerclick=${evt => this.onCatalogLayerClicked(evt.detail.layer)} >
@@ -216,7 +217,7 @@ export class SideBar extends LitElementI18n { ${i18next.t('dtd_configure_data_btn')}
this.onCatalogLayerClicked(evt)}> + @layerclick=${evt => this.onCatalogLayerClicked(evt.detail.layer)}>
@@ -269,8 +270,9 @@ export class SideBar extends LitElementI18n { @layerChanged=${evt => this.onLayerChanged(evt)}>
${i18next.t('dtd_user_content_label')}
- this.onCatalogLayerClicked(evt)}> + this.onKmlUpload(file)}>
${i18next.t('dtd_background_map_label')} @@ -286,6 +288,10 @@ export class SideBar extends LitElementI18n { } togglePanel(panelName, showHeader = true) { + if (DashboardStore.projectMode.value === 'edit') { + DashboardStore.showSaveOrCancelWarning(true); + return; + } this.showHeader = showHeader; if (this.activePanel === panelName) { this.activePanel = null; @@ -419,9 +425,8 @@ export class SideBar extends LitElementI18n { super.updated(changedProperties); } - async onCatalogLayerClicked(evt) { + async onCatalogLayerClicked(layer) { // toggle whether the layer is displayed or not (=listed in the side bar) - const layer = evt.detail.layer; if (layer.displayed) { if (layer.visible) { layer.displayed = false; @@ -634,6 +639,40 @@ export class SideBar extends LitElementI18n { this.layerOrderChangeActive = !this.layerOrderChangeActive; } + async onKmlUpload(file: File) { + if (!this.viewer) return; + const kmlDataSource = await KmlDataSource.load(file, { + camera: this.viewer.scene.camera, + canvas: this.viewer.scene.canvas + }); + const uploadedLayer = new CustomDataSource(); + let name = kmlDataSource.name; + kmlDataSource.entities.values.forEach((ent, indx) => { + if (indx === 0 && !name) { + name = ent.name!; + } + uploadedLayer.entities.add(ent); + }); + // name used as id for datasource + uploadedLayer.name = `${name}_${Date.now()}`; + await this.viewer.dataSources.add(uploadedLayer); + // done like this to have correct rerender of component + const promise = Promise.resolve(uploadedLayer); + const config: Config = { + load() {return promise;}, + label: name, + promise: promise, + zoomToBbox: true, + opacity: DEFAULT_LAYER_OPACITY, + notSaveToPermalink: true, + ownKml: true + }; + + this.requestUpdate(); + await this.onCatalogLayerClicked(config); + await this.viewer.zoomTo(uploadedLayer); + } + createRenderRoot() { return this; } diff --git a/ui/src/elements/project-selector.ts b/ui/src/elements/project-selector.ts index 7b652c390..dd4e6876c 100644 --- a/ui/src/elements/project-selector.ts +++ b/ui/src/elements/project-selector.ts @@ -9,6 +9,7 @@ import AuthStore from '../store/auth'; import $ from '../jquery'; import type {Project, Topic, View} from './dashboard/ngm-dashboard'; +import {getPermalink} from '../permalink'; @customElement('project-selector') export class ProjectSelector extends LitElementI18n { @@ -33,14 +34,8 @@ export class ProjectSelector extends LitElementI18n { this.userEmail = user?.username.split('_')[1]; }); - apiClient.projectsChange.subscribe(() => this.refreshProjects()); - this.refreshProjects(); - } - - refreshProjects() { - apiClient.getProjects() - .then(response => response.json()) - .then(body => this.projects = body); + apiClient.projectsChange.subscribe((projects) => this.projects = projects); + apiClient.refreshProjects(); } connectedCallback() { @@ -66,7 +61,7 @@ export class ProjectSelector extends LitElementI18n { const view: View = { id: crypto.randomUUID(), title: `${i18next.t('view')} ${project?.views.length + 1}`, - permalink: window.location.href, + permalink: getPermalink(), }; project.views.push(view); diff --git a/ui/src/elements/view-menu.ts b/ui/src/elements/view-menu.ts index ceba3a37b..4855a616a 100644 --- a/ui/src/elements/view-menu.ts +++ b/ui/src/elements/view-menu.ts @@ -8,6 +8,7 @@ import DashboardStore from '../store/dashboard'; import $ from '../jquery'; import type {Project, Topic, View} from './dashboard/ngm-dashboard'; +import {getPermalink} from '../permalink'; @customElement('view-menu') export class ViewMenu extends LitElementI18n { @@ -46,7 +47,7 @@ export class ViewMenu extends LitElementI18n { const view: View = { id: crypto.randomUUID(), title: `${i18next.t('view')} ${this.viewIndex + 2}`, - permalink: window.location.href, + permalink: getPermalink(), }; project.views.splice(this.viewIndex + 1, 0, view); await apiClient.updateProject(project); diff --git a/ui/src/layers/ngm-layers-upload.ts b/ui/src/layers/ngm-layers-upload.ts index 772575afe..0d67baaa4 100644 --- a/ui/src/layers/ngm-layers-upload.ts +++ b/ui/src/layers/ngm-layers-upload.ts @@ -2,20 +2,16 @@ import {html} from 'lit'; import {customElement, property, query, state} from 'lit/decorators.js'; import {LitElementI18n} from '../i18n.js'; import i18next from 'i18next'; -import {KmlDataSource, CustomDataSource} from 'cesium'; import {showBannerError, showSnackbarInfo} from '../notifications'; -import {DEFAULT_LAYER_OPACITY} from '../constants'; -import type {Config} from './ngm-layers-item'; import $ from '../jquery.js'; -import type {Viewer} from 'cesium'; import {classMap} from 'lit-html/directives/class-map.js'; @customElement('ngm-layers-upload') export default class LayersUpload extends LitElementI18n { - @property({type: Object}) - accessor viewer!: Viewer; @property({type: Object}) accessor toastPlaceholder!: HTMLElement; + @property({type: Function}) + accessor onKmlUpload!: (file: File) => Promise | void; @state() accessor loading = false; @query('.ngm-upload-kml') @@ -29,40 +25,7 @@ export default class LayersUpload extends LitElementI18n { } else { try { this.loading = true; - const kmlDataSource = await KmlDataSource.load(file, { - camera: this.viewer.scene.camera, - canvas: this.viewer.scene.canvas - }); - const uploadedLayer = new CustomDataSource(); - let name = kmlDataSource.name; - kmlDataSource.entities.values.forEach((ent, indx) => { - if (indx === 0 && !name) { - name = ent.name!; - } - uploadedLayer.entities.add(ent); - }); - // name used as id for datasource - uploadedLayer.name = `${name}_${Date.now()}`; - await this.viewer.dataSources.add(uploadedLayer); - // done like this to have correct rerender of component - const promise = Promise.resolve(uploadedLayer); - const config: Config = { - load() {return promise;}, - label: name, - promise: promise, - zoomToBbox: true, - opacity: DEFAULT_LAYER_OPACITY, - notSaveToPermalink: true, - ownKml: true - }; - - this.requestUpdate(); - this.dispatchEvent(new CustomEvent('layerclick', { - detail: { - layer: config - } - })); - await this.viewer.zoomTo(uploadedLayer); + await this.onKmlUpload(file); this.uploadKmlInput.value = ''; this.loading = false; } catch (e) { diff --git a/ui/src/store/dashboard.ts b/ui/src/store/dashboard.ts index 620bbcf8d..bd79d8a7f 100644 --- a/ui/src/store/dashboard.ts +++ b/ui/src/store/dashboard.ts @@ -1,12 +1,23 @@ import {BehaviorSubject, Subject} from 'rxjs'; import type {Project, Topic} from '../elements/dashboard/ngm-dashboard'; +import {NgmGeometry} from '../toolbox/interfaces'; export type TopicParam = { topicId: string, viewId?: string | null } +/** + * 'edit' - edit from dashboard (create / edit project) + * 'viewEdit' - view selected and geometries can be edited in the toolbox + * 'private' - user has no rights to edit the project + */ +export type ProjectMode = 'edit' | 'viewEdit' | 'private' | undefined + export default class DashboardStore { private static selectedTopicOrProjectSubject = new BehaviorSubject(undefined); private static viewIndexSubject = new Subject(); private static topicParamSubject = new BehaviorSubject(undefined); + private static projectModeSubject = new BehaviorSubject(undefined); + private static geometriesSubject = new Subject(); + private static showSaveOrCancelWarningSubject = new Subject(); static get selectedTopicOrProject(): BehaviorSubject { return this.selectedTopicOrProjectSubject; @@ -18,6 +29,7 @@ export default class DashboardStore { } static setViewIndex(value: number | undefined): void { + this.setProjectMode(value !== undefined ? 'viewEdit' : undefined); this.viewIndexSubject.next(value); } @@ -32,4 +44,28 @@ export default class DashboardStore { static get topicParam(): BehaviorSubject { return this.topicParamSubject; } + + static setProjectMode(value: ProjectMode): void { + this.projectModeSubject.next(value); + } + + static get projectMode(): BehaviorSubject { + return this.projectModeSubject; + } + + static setGeometries(geometries: NgmGeometry[]) { + this.geometriesSubject.next(geometries); + } + + static get geometriesUpdate() { + return this.geometriesSubject; + } + + static showSaveOrCancelWarning(show: boolean) { + this.showSaveOrCancelWarningSubject.next(show); + } + + static get onSaveOrCancelWarning() { + return this.showSaveOrCancelWarningSubject; + } } diff --git a/ui/src/store/toolbox.ts b/ui/src/store/toolbox.ts index 4f504ba08..f192c1cbf 100644 --- a/ui/src/store/toolbox.ts +++ b/ui/src/store/toolbox.ts @@ -11,7 +11,9 @@ export interface GeometryAction { id?: string, type?: GeometryTypes, file?: File, - action: 'remove' | 'zoom' | 'hide' | 'show' | 'copy' | 'showAll' | 'hideAll' | 'pick' | 'downloadAll' | 'profile' | 'add' | 'upload' | 'measure' | 'clearMeasure' + newName?: string + action: 'remove' | 'zoom' | 'hide' | 'show' | 'copy' | 'showAll' | 'hideAll' | 'pick' | 'downloadAll' | 'profile' | + 'add' | 'upload' | 'measure' | 'clearMeasure' | 'changeName' } export default class ToolboxStore { diff --git a/ui/src/style/ngm-action-list-item.css b/ui/src/style/ngm-action-list-item.css index 84f25eb0b..f0cffaeb9 100644 --- a/ui/src/style/ngm-action-list-item.css +++ b/ui/src/style/ngm-action-list-item.css @@ -41,6 +41,14 @@ ngm-measure .ngm-action-list-item .ngm-action-list-item-header > div:nth-child(1 color: var(--ngm-interaction-hover); } +.ngm-action-list-item-header.view { + cursor: default; +} + +.ngm-action-list-item-header.view > div:nth-child(1):hover { + color: var(--ngm-interaction); +} + .ngm-action-list-item-header .ngm-action-menu-icon:hover { background-color: var(--ngm-interaction-hover); } diff --git a/ui/src/style/ngm-dashboard.css b/ui/src/style/ngm-dashboard.css index a04303ead..9f828803f 100644 --- a/ui/src/style/ngm-dashboard.css +++ b/ui/src/style/ngm-dashboard.css @@ -1,11 +1,11 @@ ngm-dashboard .ngm-panel-header, .ngm-proj-title, -.ngm-proj-views-title, +.ngm-proj-title-icon, .ngm-projects-list { margin-bottom: 12px; } -.ngm-proj-views-title, +.ngm-proj-title-icon, .ngm-proj-title { display: flex; color: #212529; @@ -16,10 +16,15 @@ ngm-dashboard .ngm-panel-header, justify-content: space-between; } -.ngm-proj-views-title > div:first-child { +.ngm-proj-title-icon > div:first-child { margin-right: 15px; } +.ngm-proj-title-icon .ngm-screenshot-icon { + width: 24px; + height: 24px; +} + .ngm-dashboard-tabs { color: var(--ngm-interaction); display: flex; @@ -206,11 +211,19 @@ ngm-dashboard .ngm-label-btn { justify-content: start; } +ngm-dashboard .ngm-delete-icon, ngm-dashboard .ngm-label-btn .ngm-back-icon { background-color: var(--ngm-interaction); +} + +ngm-dashboard .ngm-label-btn .ngm-back-icon { margin-right: 8px; } +ngm-dashboard .ngm-delete-icon { + margin-left: 5px; +} + ngm-dashboard .ngm-label-btn:hover .ngm-back-icon { background-color: var(--ngm-interaction-hover); } @@ -220,6 +233,7 @@ ngm-dashboard .ngm-label-btn:hover .ngm-back-icon { cursor: pointer; } +ngm-dashboard .ngm-delete-icon:hover, .ngm-view-icon:hover, .ngm-view-icon:focus, .ngm-edit-icon:hover, .edit-project:hover > .ngm-edit-icon { background-color: var(--ngm-interaction-hover); } @@ -244,7 +258,8 @@ ngm-dashboard .ngm-label-btn:hover .ngm-back-icon { margin: 0 10px; } -.edit-project.active > .ngm-edit-icon { +.edit-project.active > .ngm-edit-icon, +.ngm-edit-icon.active { background-color: var(--ngm-interaction-active); } @@ -260,8 +275,8 @@ ngm-dashboard .ngm-label-btn:hover .ngm-back-icon { width: 100%; } -.project-views-edit { - margin-left: 32px; +.project-edit-fields { + margin-left: 39px; } .project-view-edit { @@ -289,3 +304,28 @@ ngm-dashboard .ngm-label-btn:hover .ngm-back-icon { color: #0C7285; box-shadow: none; } + +.ngm-proj-edit-assets { + display: flex; + width: 100%; + gap: 22px +} + +ngm-project-assets-section, +ngm-project-geoms-section { + width: 360px; +} + +ngm-project-edit .ngm-action-list-item .ngm-action-list-item-header { + display: flex; + align-items: center; +} + +ngm-project-edit .ngm-action-list-item .ngm-input { + margin-right: 5px; +} + +ngm-project-edit .ngm-action-list-item .ngm-input input { + height: 36px; + padding: 0; +} \ No newline at end of file diff --git a/ui/src/toolbox/GeometryController.ts b/ui/src/toolbox/GeometryController.ts index 68fa06245..e48ee30f8 100644 --- a/ui/src/toolbox/GeometryController.ts +++ b/ui/src/toolbox/GeometryController.ts @@ -68,7 +68,7 @@ export class GeometryController { MainStore.viewer.subscribe(viewer => { this.viewer = viewer; if (viewer) { - this.addStoredGeometries(LocalStorageController.getStoredAoi()); + this.setGeometries(LocalStorageController.getStoredAoi()); this.screenSpaceEventHandler = new ScreenSpaceEventHandler(this.viewer!.canvas); this.screenSpaceEventHandler.setInputAction(this.onClick_.bind(this), ScreenSpaceEventType.LEFT_CLICK); viewer.dataSources.add(this.measureDataSource); @@ -393,6 +393,16 @@ export class GeometryController { break; case 'upload': this.uploadFile(options.file); + break; + case 'changeName': + this.changeName(options.id!, options.newName!); + } + } + + changeName(id: string, newName: string) { + const entity = this.geometriesDataSource.entities.getById(id); + if (entity) { + entity.name = newName; } } @@ -557,15 +567,18 @@ export class GeometryController { } - addStoredGeometries(areas) { - areas.forEach(area => { - if (!area.positions) return; - const splittedName = area.name.split(' '); - const areaNumber = Number(splittedName[1]); - if (splittedName[0] !== 'Area' && !isNaN(areaNumber) && areaNumber > this.geometriesCounter[area.type]) { - this.geometriesCounter[area.type] = areaNumber; + setGeometries(geometries: NgmGeometry[]) { + this.geometriesDataSource.entities.removeAll(); + geometries.forEach(geom => { + if (!geom.positions) return; + if (geom.name) { + const splittedName = geom.name.split(' '); + const areaNumber = Number(splittedName[1]); + if (splittedName[0] !== 'Area' && !isNaN(areaNumber) && areaNumber > this.geometriesCounter[geom.type]) { + this.geometriesCounter[geom.type] = areaNumber; + } } - this.addGeometry(area); + this.addGeometry(geom); }); } } diff --git a/ui/src/toolbox/data-download.ts b/ui/src/toolbox/data-download.ts index 999f204c6..307f44eb4 100644 --- a/ui/src/toolbox/data-download.ts +++ b/ui/src/toolbox/data-download.ts @@ -106,7 +106,8 @@ export class DataDownload extends LitElementI18n { return html`
- this.downloadOptionsTemplate(geom)} @@ -114,7 +115,7 @@ export class DataDownload extends LitElementI18n { @geometriesadded=${evt => this.onGeometryAdded(evt.detail.newGeometries)} > geom.fromTopic} > diff --git a/ui/src/toolbox/interfaces.ts b/ui/src/toolbox/interfaces.ts index 688c548c9..a1d5c5c04 100644 --- a/ui/src/toolbox/interfaces.ts +++ b/ui/src/toolbox/interfaces.ts @@ -19,15 +19,15 @@ export interface AreasCounter { export type GeometryTypes = 'point' | 'line' | 'rectangle' | 'polygon' export interface NgmGeometry { + type: GeometryTypes; + positions: Array; id?: string; name?: string; show?: boolean; - positions: Array; area?: string | number; perimeter?: string | number; sidesLength?: Array; numberOfSegments?: number; - type: GeometryTypes; description?: string; image?: string; website?: string; diff --git a/ui/src/toolbox/ngm-draw-tool.ts b/ui/src/toolbox/ngm-draw-tool.ts index eb1860cc8..0024444f2 100644 --- a/ui/src/toolbox/ngm-draw-tool.ts +++ b/ui/src/toolbox/ngm-draw-tool.ts @@ -28,6 +28,7 @@ export class NgmAreaOfInterestDrawer extends LitElementI18n {
) => { ToolboxStore.nextGeometryAction({id: evt.detail.id, action: 'zoom'}); @@ -35,7 +36,7 @@ export class NgmAreaOfInterestDrawer extends LitElementI18n { }}> geom.fromTopic} >`; } diff --git a/ui/src/toolbox/ngm-geometries-list.ts b/ui/src/toolbox/ngm-geometries-list.ts index 4d1a1322e..b7f327a50 100644 --- a/ui/src/toolbox/ngm-geometries-list.ts +++ b/ui/src/toolbox/ngm-geometries-list.ts @@ -13,7 +13,7 @@ export default class NgmGeometriesList extends LitElementI18n { @property({type: String}) accessor selectedId = ''; @property({type: String}) - accessor title = i18next.t('tbx_my_geometries'); + accessor listTitle: string | undefined; @property({type: Object}) accessor geometryFilter: (geom: NgmGeometry) => boolean = (geom) => !geom.fromTopic; @property({type: Object}) @@ -22,23 +22,32 @@ export default class NgmGeometriesList extends LitElementI18n { accessor disabledTypes: string[] = []; @property({type: Object}) accessor disabledCallback: ((geom: NgmGeometry) => boolean) | undefined; - @state() + // in view mode will be shown geometries passed be property and any actions with geometry will be disabled + @property({type: Boolean}) + accessor viewMode = false; + @property({type: Array}) accessor geometries: NgmGeometry[] = []; + // hides zoomTo, info, edit buttons from context menu + @property({type: Boolean}) + accessor hideMapInteractionButtons = false; + // allows to edit geometry name directly in the list + @property({type: Boolean}) + accessor directNameEdit = false; @state() accessor editingEnabled = false; @state() accessor selectedFilter: GeometryTypes | undefined; + @state() + accessor nameEditIndex: number | undefined; private scrollDown = false; - constructor() { - super(); - ToolboxStore.geometries.subscribe(geoms => { - this.geometries = geoms; - }); - ToolboxStore.openedGeometryOptions.subscribe(options => this.editingEnabled = !!(options?.editing)); - } - protected firstUpdated() { + if (!this.viewMode) { + ToolboxStore.geometries.subscribe(geoms => { + this.geometries = geoms; + }); + ToolboxStore.openedGeometryOptions.subscribe(options => this.editingEnabled = !!(options?.editing)); + } this.querySelectorAll('.ngm-action-menu').forEach(el => $(el).dropdown()); } @@ -73,9 +82,19 @@ export default class NgmGeometriesList extends LitElementI18n { this.selectedFilter = type; } + onEditNameClick(index: number) { + if (this.nameEditIndex !== undefined) { + const geometry = this.geometries[this.nameEditIndex]; + ToolboxStore.nextGeometryAction({id: geometry.id!, newName: geometry.name!, action: 'changeName'}); + } + this.nameEditIndex = this.nameEditIndex === index ? undefined : index; + } + actionMenuTemplate(geom: NgmGeometry) { + if (this.viewMode) return ''; return html`