diff --git a/.gitignore b/.gitignore index 0b745e2..8f78406 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ -/target -.env \ No newline at end of file +target +.env +dist \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 62753ab..5057823 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.19" @@ -104,12 +119,78 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "async-trait" +version = "0.1.88" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "hyper 1.6.0", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper 1.0.2", + "tokio", + "tower 0.5.2", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper 1.0.2", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "backtrace" version = "0.3.75" @@ -131,6 +212,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64ct" version = "1.8.0" @@ -146,6 +233,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.9.1" @@ -201,36 +294,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" [[package]] -name = "circle_client_ws" -version = "0.1.0" +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" dependencies = [ - "clap", - "dotenv", - "env_logger", - "futures-channel", - "futures-util", - "getrandom 0.2.16", - "gloo-console 0.3.0", - "gloo-net 0.4.0", - "gloo-timers 0.3.0", - "hex", - "http 0.2.12", + "android-tzdata", + "iana-time-zone", "js-sys", - "log", - "native-tls", - "rand", - "secp256k1", + "num-traits", "serde", - "serde_json", - "sha3", - "thiserror", - "tokio", - "tokio-tungstenite", - "url", - "uuid", "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", + "windows-link", ] [[package]] @@ -279,6 +354,50 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "components" +version = "0.1.0" +dependencies = [ + "futures-channel", + "futures-util", + "getrandom 0.2.16", + "gloo 0.11.0", + "gloo-net 0.5.0", + "gloo-timers 0.3.0", + "hero_websocket_client", + "hex", + "js-sys", + "k256", + "log", + "reqwest", + "serde", + "serde-wasm-bindgen 0.6.5", + "serde_json", + "thiserror", + "tokio", + "tokio-test", + "uuid", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test", + "web-sys", + "yew", +] + [[package]] name = "console_error_panic_hook" version = "0.1.7" @@ -289,16 +408,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "console_log" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be8aed40e4edbf4d3b4431ab260b63fdc40f5780a4766824329ea0f1eefe3c0f" -dependencies = [ - "log", - "web-sys", -] - [[package]] name = "const-oid" version = "0.9.6" @@ -430,6 +539,25 @@ dependencies = [ "zeroize", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "env_filter" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" +dependencies = [ + "log", + "regex", +] + [[package]] name = "env_logger" version = "0.10.2" @@ -443,6 +571,19 @@ dependencies = [ "termcolor", ] +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -475,6 +616,22 @@ dependencies = [ "subtle", ] +[[package]] +name = "file_browser_widget" +version = "0.1.0" +dependencies = [ + "components", + "console_error_panic_hook", + "gloo 0.11.0", + "js-sys", + "serde", + "serde-wasm-bindgen 0.6.5", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "yew", +] + [[package]] name = "fnv" version = "1.0.7" @@ -509,26 +666,16 @@ dependencies = [ name = "framework" version = "0.1.0" dependencies = [ - "circle_client_ws", - "futures-channel", - "futures-util", - "getrandom 0.2.16", - "gloo 0.11.0", - "gloo-timers 0.3.0", - "hex", - "js-sys", - "k256", + "axum", + "components", + "env_logger 0.11.8", "log", "serde", "serde_json", "thiserror", "tokio", - "tokio-test", - "uuid", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "yew", + "tower 0.4.13", + "tower-http", ] [[package]] @@ -791,7 +938,6 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97563d71863fb2824b2e974e754a81d19c4a7ec47b09ced8a0e6656b6d54bd1f" dependencies = [ - "futures-channel", "gloo-events 0.2.0", "js-sys", "wasm-bindgen", @@ -1070,6 +1216,25 @@ dependencies = [ "subtle", ] +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.15.4" @@ -1088,6 +1253,54 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hero_job" +version = "0.1.0" +dependencies = [ + "chrono", + "log", + "redis", + "serde", + "serde_json", + "thiserror", + "tokio", + "uuid", +] + +[[package]] +name = "hero_websocket_client" +version = "0.1.0" +dependencies = [ + "clap", + "dotenv", + "env_logger 0.10.2", + "futures-channel", + "futures-util", + "getrandom 0.2.16", + "gloo-console 0.3.0", + "gloo-net 0.4.0", + "gloo-timers 0.3.0", + "hero_job", + "hex", + "http 0.2.12", + "js-sys", + "k256", + "log", + "native-tls", + "rand", + "serde", + "serde_json", + "sha3", + "thiserror", + "tokio", + "tokio-tungstenite", + "url", + "uuid", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "hex" version = "0.4.3" @@ -1125,18 +1338,160 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.3.1", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.3.1", + "http-body 1.0.1", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "humantime" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b112acc8b3adf4b107a8ec20977da0273a8c386765a3ec0229bd500a1443f9f" +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper 0.14.32", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "hyper-util" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" +dependencies = [ + "bytes", + "futures-core", + "http 1.3.1", + "http-body 1.0.1", + "hyper 1.6.0", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.0.0" @@ -1280,11 +1635,17 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013" dependencies = [ - "bitflags", + "bitflags 2.9.1", "cfg-if", "libc", ] +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + [[package]] name = "is-terminal" version = "0.4.16" @@ -1308,6 +1669,30 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "jiff" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde", +] + +[[package]] +name = "jiff-static" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "js-sys" version = "0.3.77" @@ -1375,12 +1760,44 @@ version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "memchr" version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minicov" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27fe9f1cc3c22e1687f9446c2083c4c5fc7f0bcf1c7a86bdbded14985895b4b" +dependencies = [ + "cc", + "walkdir", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1418,6 +1835,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "num_cpus" version = "1.17.0" @@ -1455,7 +1881,7 @@ version = "0.10.73" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" dependencies = [ - "bitflags", + "bitflags 2.9.1", "cfg-if", "foreign-types", "libc", @@ -1581,6 +2007,21 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.2" @@ -1714,13 +2155,34 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "redis" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0d7a6955c7511f60f3ba9e86c6d02b3c3f144f8c24b288d1f4e18074ab8bbec" +dependencies = [ + "async-trait", + "bytes", + "combine", + "futures-util", + "itoa", + "percent-encoding", + "pin-project-lite", + "ryu", + "sha1_smol", + "socket2", + "tokio", + "tokio-util", + "url", +] + [[package]] name = "redox_syscall" version = "0.5.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6" dependencies = [ - "bitflags", + "bitflags 2.9.1", ] [[package]] @@ -1752,6 +2214,46 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + [[package]] name = "rfc6979" version = "0.4.0" @@ -1762,12 +2264,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "route-recognizer" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afab94fb28594581f62d981211a9a4d53cc8130bbcbbb89a0440d9b8e81a7746" - [[package]] name = "rustc-demangle" version = "0.1.25" @@ -1780,13 +2276,22 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" dependencies = [ - "bitflags", + "bitflags 2.9.1", "errno", "libc", "linux-raw-sys", "windows-sys 0.59.0", ] +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64", +] + [[package]] name = "rustversion" version = "1.0.21" @@ -1799,6 +2304,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.27" @@ -1828,32 +2342,13 @@ dependencies = [ "zeroize", ] -[[package]] -name = "secp256k1" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" -dependencies = [ - "rand", - "secp256k1-sys", -] - -[[package]] -name = "secp256k1-sys" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" -dependencies = [ - "cc", -] - [[package]] name = "security-framework" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags", + "bitflags 2.9.1", "core-foundation", "core-foundation-sys", "libc", @@ -1924,6 +2419,16 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" +dependencies = [ + "itoa", + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1947,6 +2452,12 @@ dependencies = [ "digest", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -2064,6 +2575,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "synstructure" version = "0.13.2" @@ -2075,6 +2598,27 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tempfile" version = "3.20.0" @@ -2206,6 +2750,19 @@ dependencies = [ "tungstenite", ] +[[package]] +name = "tokio-util" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml_datetime" version = "0.6.11" @@ -2223,12 +2780,77 @@ dependencies = [ "winnow", ] +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper 1.0.2", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "bitflags 2.9.1", + "bytes", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -2254,6 +2876,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "tungstenite" version = "0.23.0" @@ -2279,6 +2907,12 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + [[package]] name = "unicode-ident" version = "1.0.18" @@ -2296,12 +2930,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - [[package]] name = "utf-8" version = "0.7.6" @@ -2344,6 +2972,25 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2430,6 +3077,30 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-bindgen-test" +version = "0.3.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66c8d5e33ca3b6d9fa3b4676d774c5778031d27a578c2b007f905acf816152c3" +dependencies = [ + "js-sys", + "minicov", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17d5042cc5fa009658f9a7333ef24291b1291a25b6382dd68862a7f3b969f69b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "web-sys" version = "0.3.77" @@ -2449,6 +3120,74 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -2476,6 +3215,21 @@ dependencies = [ "windows-targets 0.53.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -2508,6 +3262,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.0", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -2520,6 +3280,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -2532,6 +3298,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -2556,6 +3328,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -2568,6 +3346,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -2580,6 +3364,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -2592,6 +3382,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -2613,13 +3409,23 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "wit-bindgen-rt" version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ - "bitflags", + "bitflags 2.9.1", ] [[package]] @@ -2668,55 +3474,6 @@ dependencies = [ "syn 2.0.104", ] -[[package]] -name = "yew-router" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca1d5052c96e6762b4d6209a8aded597758d442e6c479995faf0c7b5538e0c6" -dependencies = [ - "gloo 0.10.0", - "js-sys", - "route-recognizer", - "serde", - "serde_urlencoded", - "tracing", - "urlencoding", - "wasm-bindgen", - "web-sys", - "yew", - "yew-router-macro", -] - -[[package]] -name = "yew-router-macro" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42bfd190a07ca8cfde7cd4c52b3ac463803dc07323db8c34daa697e86365978c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", -] - -[[package]] -name = "yew-website-example" -version = "0.1.0" -dependencies = [ - "console_log", - "framework", - "gloo 0.11.0", - "js-sys", - "log", - "serde", - "serde-wasm-bindgen 0.6.5", - "serde_json", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "yew", - "yew-router", -] - [[package]] name = "yoke" version = "0.8.0" diff --git a/Cargo.toml b/Cargo.toml index 8272d39..5daca1f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,52 +1,78 @@ +[workspace] +members = [ + "components", + "widgets/file_browser_widget", +] +resolver = "2" + [package] name = "framework" version = "0.1.0" edition = "2021" -[lib] +[[bin]] name = "framework" -path = "src/lib.rs" +path = "cmd/main.rs" -[dependencies] -# WebSocket client dependency with conditional crypto features -circle_client_ws = { path = "../circles/src/client_ws", default-features = false, features = [] } - -# Core dependencies +[workspace.dependencies] +# Shared dependencies across workspace serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" log = "0.4" thiserror = "1.0" -uuid = { version = "1.0", features = ["v4"] } - -# Async dependencies +uuid = { version = "1.0", features = ["v4", "js"] } futures-util = "0.3" futures-channel = "0.3" # WASM-specific dependencies -[target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" yew = { version = "0.21", features = ["csr"] } gloo = "0.11" gloo-timers = { version = "0.3", features = ["futures"] } -web-sys = { version = "0.3", features = ["Storage", "Window", "FormData", "HtmlFormElement", "HtmlInputElement", "HtmlSelectElement"] } +web-sys = { version = "0.3", features = [ + "Storage", "Window", "FormData", "HtmlFormElement", "HtmlInputElement", "HtmlSelectElement", + "Request", "RequestInit", "RequestMode", "Response", "Headers", "Blob", "Url", + "HtmlAnchorElement", "Document", "Element", "CssStyleDeclaration", "Location", + "UrlSearchParams", "HtmlTextAreaElement", "HtmlButtonElement", "HtmlDivElement", + "Event", "EventTarget", "MouseEvent", "KeyboardEvent", "InputEvent", + "File", "FileList", "AbortController", "AbortSignal", "console", "History", + "HtmlImageElement", "HtmlCanvasElement", "Navigator", "Notification", + "WebSocket", "MessageEvent", "CloseEvent", "ErrorEvent", + "DomException", "Performance", "HtmlMetaElement", "HtmlLinkElement", + "Node", "NodeList", "HtmlCollection", "DomTokenList", + "CustomEvent", "FocusEvent", "WheelEvent", "TouchEvent", "Touch", "TouchList", + "DragEvent", "DataTransfer", "ClipboardEvent", + "HtmlIFrameElement", "MessagePort" +] } js-sys = "0.3" -hex = "0.4" -k256 = { version = "0.13", features = ["ecdsa", "sha256"] } -getrandom = { version = "0.2", features = ["js"] } +wasm-bindgen-test = "0.3" -# Native-specific dependencies -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] -tokio = { version = "1.0", features = ["rt", "macros", "time"] } +# HTTP client +reqwest = { version = "0.11", features = ["json"] } -[dev-dependencies] -tokio-test = "0.4" +# Tokio for async runtime +tokio = { version = "1.0", features = ["full"] } + +# Axum for web server +axum = "0.7" +tower = "0.4" +tower-http = { version = "0.5", features = ["fs", "cors"] } + +# Internal workspace dependencies +components = { path = "components" } + +[dependencies] +# Use workspace dependencies for the binary +components = { workspace = true } +tokio = { workspace = true } +axum = { workspace = true } +tower = { workspace = true } +tower-http = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +log = { workspace = true } +thiserror = { workspace = true } +env_logger = "0.11" -# Features -[features] -default = [] -crypto = ["circle_client_ws/crypto"] -wasm-compatible = [] # For WASM builds without crypto to avoid wasm-opt issues -[workspace] -members = ["examples/website"] diff --git a/README.md b/README.md index c7d2b78..25803b6 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,8 @@ -# Framework WebSocket Connection Manager +# Awesome Web Assembly for Real-time Experiences + +This repository contains a collection of tools and libraries for building real-time experiences using WebAssembly (WASM). It includes a WebSocket connection manager built on top of the robust `circle_client_ws` library, as well as a file browser component for managing files on a server. + +## Framework WebSocket Connection Manager A simplified WebSocket connection manager built on top of the robust `circle_client_ws` library. This framework provides a clean builder pattern API for managing multiple self-managing WebSocket connections with authentication support and script execution capabilities. diff --git a/WASM_OPT_SOLUTION.md b/WASM_OPT_SOLUTION.md deleted file mode 100644 index 40367e0..0000000 --- a/WASM_OPT_SOLUTION.md +++ /dev/null @@ -1,128 +0,0 @@ -# WebSocket Framework - WASM-opt Compatibility Solution - -## Problem - -The WebSocket connection manager framework was causing wasm-opt parsing errors when building for WASM targets with aggressive optimizations: - -``` -[parse exception: invalid code after misc prefix: 17 (at 0:732852)] -Fatal: error parsing wasm (try --debug for more info) -``` - -## Root Cause - -The issue was caused by cryptographic dependencies (`secp256k1` and `sha3`) in the `circle_client_ws` library. These libraries contain complex low-level implementations that are incompatible with wasm-opt's aggressive optimization passes. - -## Solution - -We implemented a feature flag system that allows the framework to work in two modes: - -### 1. Full Mode (with crypto authentication) -- **Use case**: Native applications, server-side usage -- **Features**: Full secp256k1 authentication support -- **Usage**: `framework = { path = "...", features = ["crypto"] }` - -### 2. WASM-Compatible Mode (without crypto) -- **Use case**: WASM/browser applications where wasm-opt compatibility is required -- **Features**: Basic WebSocket connections without cryptographic authentication -- **Usage**: `framework = { path = "...", features = ["wasm-compatible"] }` - -## Implementation Details - -### Framework Cargo.toml -```toml -[dependencies] -circle_client_ws = { path = "../circles/src/client_ws", default-features = false, features = [] } - -[features] -default = [] -crypto = ["circle_client_ws/crypto"] -wasm-compatible = [] # For WASM builds without crypto to avoid wasm-opt issues -``` - -### Conditional Compilation -The authentication code is conditionally compiled based on feature flags: - -```rust -#[cfg(feature = "crypto")] -pub fn create_client(&self, ws_url: String) -> circle_client_ws::CircleWsClient { - circle_client_ws::CircleWsClientBuilder::new(ws_url) - .with_keypair(self.private_key.clone()) - .build() -} - -#[cfg(not(feature = "crypto"))] -pub fn create_client(&self, ws_url: String) -> circle_client_ws::CircleWsClient { - circle_client_ws::CircleWsClientBuilder::new(ws_url).build() -} -``` - -### Website Example Configuration -```toml -[dependencies] -framework = { path = "../..", features = ["wasm-compatible"] } -``` - -## Usage Recommendations - -### For WASM Applications -Use the `wasm-compatible` feature to avoid wasm-opt issues: -```toml -framework = { features = ["wasm-compatible"] } -``` - -### For Native Applications with Authentication -Use the `crypto` feature for full authentication support: -```toml -framework = { features = ["crypto"] } -``` - -### For Development/Testing -You can disable wasm-opt entirely in Trunk.toml for development: -```toml -[tools] -wasm-opt = false -``` - -## Alternative Solutions Considered - -1. **Less aggressive wasm-opt settings**: Tried `-O2` instead of `-Os`, but still failed -2. **Disabling specific wasm-opt passes**: Complex and unreliable -3. **Different crypto libraries**: Would require significant changes to circle_client_ws -4. **WASM-specific crypto implementations**: Would add complexity and maintenance burden - -## Benefits of This Solution - -1. **Backward Compatibility**: Existing native applications continue to work unchanged -2. **WASM Compatibility**: Browser applications can use the framework without wasm-opt issues -3. **Clear Separation**: Feature flags make the trade-offs explicit -4. **Maintainable**: Simple conditional compilation without code duplication -5. **Future-Proof**: Can easily add more features or modes as needed - -## Testing - -The solution was verified by: -1. Building the website example without framework dependency ✅ -2. Adding framework dependency without crypto features ✅ -3. Building with wasm-opt aggressive optimizations ✅ -4. Confirming all functionality works in WASM-compatible mode ✅ - -## Migration Guide - -### Existing Native Applications -No changes required - continue using the framework as before. - -### New WASM Applications -Add the `wasm-compatible` feature: -```toml -framework = { features = ["wasm-compatible"] } -``` - -### Applications Needing Both -Use conditional dependencies: -```toml -[target.'cfg(target_arch = "wasm32")'.dependencies] -framework = { features = ["wasm-compatible"] } - -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] -framework = { features = ["crypto"] } \ No newline at end of file diff --git a/cmd/main.rs b/cmd/main.rs new file mode 100644 index 0000000..c776a92 --- /dev/null +++ b/cmd/main.rs @@ -0,0 +1,42 @@ +//! Framework Binary +//! +//! Main entry point for the framework application. + +use components::prelude::*; +use std::env; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize logging + env_logger::init(); + + let args: Vec = env::args().collect(); + + if args.len() < 2 { + println!("Framework v{}", VERSION); + println!("Usage: framework "); + println!(); + println!("Commands:"); + println!(" serve Start the web server"); + println!(" version Show version information"); + return Ok(()); + } + + match args[1].as_str() { + "serve" => { + println!("Starting framework server..."); + // TODO: Implement server functionality using components + println!("Server functionality not yet implemented"); + } + "version" => { + println!("Framework v{}", VERSION); + } + _ => { + eprintln!("Unknown command: {}", args[1]); + eprintln!("Run 'framework' for usage information"); + std::process::exit(1); + } + } + + Ok(()) +} diff --git a/components/Cargo.toml b/components/Cargo.toml new file mode 100644 index 0000000..81c453a --- /dev/null +++ b/components/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "components" +version = "0.1.0" +edition = "2021" + +[lib] +name = "components" +path = "src/lib.rs" + +[dependencies] +# Use workspace dependencies +serde = { workspace = true } +serde_json = { workspace = true } +log = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true } +futures-util = { workspace = true } +futures-channel = { workspace = true } + +# WASM-specific dependencies +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen = { workspace = true } +wasm-bindgen-futures = { workspace = true } +yew = { workspace = true } +gloo = { workspace = true } +gloo-timers = { workspace = true } +web-sys = { workspace = true } +js-sys = { workspace = true } +serde-wasm-bindgen = "0.6" +hex = "0.4" +k256 = { version = "0.13", features = ["ecdsa", "sha256"] } +getrandom = { version = "0.2", features = ["js"] } +gloo-net = "0.5" +reqwest = { workspace = true } + +# Native-specific dependencies +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +hero_websocket_client = { path = "../../hero/interfaces/websocket/client", default-features = false, features = [] } +tokio = { workspace = true, features = ["rt", "macros", "time"] } + +[dev-dependencies] +tokio-test = "0.4" +wasm-bindgen-test = { workspace = true } + +[features] +default = [] +crypto = ["hero_websocket_client/crypto"] +wasm-compatible = [] # For WASM builds without crypto to avoid wasm-opt issues diff --git a/src/auth.rs b/components/src/auth.rs similarity index 96% rename from src/auth.rs rename to components/src/auth.rs index 52a5544..9206fe5 100644 --- a/src/auth.rs +++ b/components/src/auth.rs @@ -97,9 +97,9 @@ impl AuthConfig { /// /// # Returns /// A basic CircleWsClient without authentication - #[cfg(not(feature = "crypto"))] - pub fn create_client(&self, ws_url: String) -> circle_client_ws::CircleWsClient { - circle_client_ws::CircleWsClientBuilder::new(ws_url).build() + #[cfg(all(not(feature = "crypto"), not(target_arch = "wasm32")))] + pub fn create_client(&self, ws_url: String) -> hero_websocket_client::CircleWsClient { + hero_websocket_client::CircleWsClientBuilder::new(ws_url).build() } } diff --git a/src/browser_auth.rs b/components/src/browser_auth.rs similarity index 95% rename from src/browser_auth.rs rename to components/src/browser_auth.rs index b61cf9f..9889b5e 100644 --- a/src/browser_auth.rs +++ b/components/src/browser_auth.rs @@ -9,9 +9,13 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use yew::prelude::*; use web_sys::Storage; -use k256::{SecretKey, elliptic_curve::{rand_core::OsRng, sec1::ToEncodedPoint}}; +use k256::{SecretKey, elliptic_curve::sec1::ToEncodedPoint}; use getrandom::getrandom; -use crate::error::{WsError, WsResult}; +// Note: error module not available in WASM builds +// use crate::error::{WsError, WsResult}; + +// Define local error types for WASM builds +pub type WsResult = Result; const STORAGE_KEY: &str = "herocode_auth_keys"; @@ -113,22 +117,22 @@ impl BrowserAuthManager { AuthState::Authenticated { key_name: current_key, private_key } if current_key == key_name => { // Convert private key to public key let private_key_bytes = hex::decode(private_key) - .map_err(|_| WsError::auth("Invalid private key hex"))?; + .map_err(|_| "Invalid private key hex")?; if private_key_bytes.len() != 32 { - return Err(WsError::auth("Invalid private key length")); + return Err("Invalid private key length".to_string()); } let mut key_array = [0u8; 32]; key_array.copy_from_slice(&private_key_bytes); let secret_key = SecretKey::from_bytes(&key_array.into()) - .map_err(|e| WsError::auth(&format!("Failed to create secret key: {}", e)))?; + .map_err(|e| format!("Failed to create secret key: {}", e))?; let public_key = secret_key.public_key(); Ok(hex::encode(public_key.to_encoded_point(false).as_bytes())) } - _ => Err(WsError::auth("Key not currently authenticated")) + _ => Err("Key not currently authenticated".to_string()) } } @@ -136,7 +140,7 @@ impl BrowserAuthManager { pub fn register_key(&mut self, name: String, private_key: String, password: String) -> WsResult<()> { // Validate private key format if !self.validate_private_key(&private_key)? { - return Err(WsError::auth("Invalid private key format")); + return Err("Invalid private key format".to_string()); } // Generate a random salt @@ -161,14 +165,14 @@ impl BrowserAuthManager { /// Attempt to login with a specific key and password pub fn login(&mut self, key_name: String, password: String) -> WsResult<()> { let entry = self.encrypted_keys.get(&key_name) - .ok_or_else(|| WsError::auth("Key not found"))?; + .ok_or_else(|| "Key not found".to_string())?; // Decrypt the private key let private_key = self.decrypt_key(&entry.encrypted_key, &password, &entry.salt)?; // Validate the decrypted key if !self.validate_private_key(&private_key)? { - return Err(WsError::auth("Failed to decrypt key or invalid key format")); + return Err("Failed to decrypt key or invalid key format".to_string()); } self.state = AuthState::Authenticated { @@ -187,7 +191,7 @@ impl BrowserAuthManager { /// Remove a registered key pub fn remove_key(&mut self, key_name: &str) -> WsResult<()> { if self.encrypted_keys.remove(key_name).is_none() { - return Err(WsError::auth("Key not found")); + return Err("Key not found".to_string()); } // If we're currently authenticated with this key, logout @@ -207,10 +211,10 @@ impl BrowserAuthManager { // Use getrandom to get cryptographically secure random bytes getrandom(&mut rng_bytes) - .map_err(|e| WsError::auth(format!("Failed to generate random bytes: {}", e)))?; + .map_err(|e| format!("Failed to generate random bytes: {}", e))?; let secret_key = SecretKey::from_bytes(&rng_bytes.into()) - .map_err(|e| WsError::auth(format!("Failed to create secret key: {}", e)))?; + .map_err(|e| format!("Failed to create secret key: {}", e))?; Ok(hex::encode(secret_key.to_bytes())) } @@ -222,7 +226,7 @@ impl BrowserAuthManager { if let Ok(Some(data)) = storage.get_item(STORAGE_KEY) { if !data.is_empty() { let keys: HashMap = serde_json::from_str(&data) - .map_err(|e| WsError::auth(&format!("Failed to parse stored keys: {}", e)))?; + .map_err(|e| format!("Failed to parse stored keys: {}", e))?; self.encrypted_keys = keys; } } @@ -234,10 +238,10 @@ impl BrowserAuthManager { fn save_keys(&self) -> WsResult<()> { let storage = self.get_local_storage()?; let data = serde_json::to_string(&self.encrypted_keys) - .map_err(|e| WsError::auth(&format!("Failed to serialize keys: {}", e)))?; + .map_err(|e| format!("Failed to serialize keys: {}", e))?; storage.set_item(STORAGE_KEY, &data) - .map_err(|_| WsError::auth("Failed to save keys to storage"))?; + .map_err(|_| "Failed to save keys to storage".to_string())?; Ok(()) } @@ -245,11 +249,11 @@ impl BrowserAuthManager { /// Get browser local storage fn get_local_storage(&self) -> WsResult { let window = web_sys::window() - .ok_or_else(|| WsError::auth("No window object available"))?; + .ok_or_else(|| "No window object available".to_string())?; window.local_storage() - .map_err(|_| WsError::auth("Failed to access local storage"))? - .ok_or_else(|| WsError::auth("Local storage not available")) + .map_err(|_| "Failed to access local storage".to_string())? + .ok_or_else(|| "Local storage not available".to_string()) } /// Validate private key format (secp256k1) @@ -282,9 +286,9 @@ impl BrowserAuthManager { // Simple XOR encryption for now - in production, use proper encryption // This is a placeholder implementation let key_bytes = hex::decode(private_key) - .map_err(|_| WsError::auth("Invalid private key hex"))?; + .map_err(|_| "Invalid private key hex".to_string())?; let salt_bytes = hex::decode(salt) - .map_err(|_| WsError::auth("Invalid salt hex"))?; + .map_err(|_| "Invalid salt hex".to_string())?; // Create a key from password and salt using a simple hash let mut password_key = Vec::new(); @@ -303,14 +307,13 @@ impl BrowserAuthManager { Ok(hex::encode(encrypted)) } - /// Decrypt a private key using password and salt fn decrypt_key(&self, encrypted_key: &str, password: &str, salt: &str) -> WsResult { // Simple XOR decryption - matches encrypt_key implementation let encrypted_bytes = hex::decode(encrypted_key) - .map_err(|_| WsError::auth("Invalid encrypted key hex"))?; + .map_err(|_| "Invalid base64 in stored keys".to_string())?; let salt_bytes = hex::decode(salt) - .map_err(|_| WsError::auth("Invalid salt hex"))?; + .map_err(|_| "Invalid salt hex".to_string())?; // Create the same key from password and salt let mut password_key = Vec::new(); diff --git a/src/error.rs b/components/src/error.rs similarity index 95% rename from src/error.rs rename to components/src/error.rs index 867b1d8..9a22111 100644 --- a/src/error.rs +++ b/components/src/error.rs @@ -1,7 +1,8 @@ //! Error types for the WebSocket connection manager use thiserror::Error; -use circle_client_ws::CircleWsClientError; +#[cfg(not(target_arch = "wasm32"))] +use hero_websocket_client::CircleWsClientError; /// Result type alias for WebSocket operations pub type WsResult = Result; @@ -10,6 +11,7 @@ pub type WsResult = Result; #[derive(Error, Debug)] pub enum WsError { /// WebSocket client error from the underlying circle_client_ws library + #[cfg(not(target_arch = "wasm32"))] #[error("WebSocket client error: {0}")] Client(#[from] CircleWsClientError), diff --git a/components/src/file_browser.rs b/components/src/file_browser.rs new file mode 100644 index 0000000..2935aad --- /dev/null +++ b/components/src/file_browser.rs @@ -0,0 +1,1775 @@ +use yew::prelude::*; +use web_sys::{HtmlInputElement, Request, RequestInit, RequestMode, Response, window}; +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::{spawn_local, JsFuture}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use js_sys::{Object, Reflect, Array}; +use wasm_bindgen::JsCast; +use yew::AttrValue; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(js_namespace = console)] + fn log(s: &str); +} + +macro_rules! console_log { + ($($t:tt)*) => (log(&format_args!($($t)*).to_string())) +} + +/// Configuration for the file browser widget +#[derive(Clone, PartialEq, Properties)] +pub struct FileBrowserConfig { + /// Base endpoint for file operations (default: "/files") + #[prop_or_else(|| "/files".to_string())] + pub base_endpoint: String, + + /// Maximum file size in bytes (default: 500MB) + #[prop_or(500 * 1024 * 1024)] + pub max_file_size: u64, + + /// Chunk size for downloads (default: 1MB) + #[prop_or(1024 * 1024)] + pub chunk_size: u32, + + /// Initial directory path to display + #[prop_or_else(|| "".to_string())] + pub initial_path: String, + + /// Whether to show upload functionality + #[prop_or(true)] + pub show_upload: bool, + + /// Whether to show download functionality + #[prop_or(true)] + pub show_download: bool, + + /// Whether to show delete functionality + #[prop_or(true)] + pub show_delete: bool, + + /// Whether to show directory creation + #[prop_or(true)] + pub show_create_dir: bool, + + /// Custom CSS classes for styling + #[prop_or_else(|| "".to_string())] + pub css_classes: String, + + /// Uppy dashboard theme (light/dark) + #[prop_or_else(|| "light".to_string())] + pub theme: String, +} + +/// File/directory item in the browser +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct FileItem { + pub name: String, + pub path: String, + pub is_directory: bool, + pub size: Option, + pub modified: Option, + pub hash: Option, +} + +/// Upload progress information +#[derive(Debug, Clone, PartialEq)] +pub struct UploadProgress { + pub filename: String, + pub progress: f64, + pub total: u64, + pub uploaded: u64, +} + +/// Internal view modes for the file browser +#[derive(Debug, Clone, PartialEq)] +pub enum ViewMode { + Browser, + MarkdownEditor { file_path: String, content: Option, current_text: Option }, + TextEditor { file_path: String, content: Option, current_text: Option }, + FileViewer { file_path: String, content: String }, +} + +/// Messages for the file browser component +#[derive(Debug, Clone)] +pub enum FileBrowserMsg { + LoadDirectory(String), + DirectoryLoaded(Vec), + LoadError(String), + NavigateUp, + NavigateToPath(String), + CreateDirectory(String), + DeleteItem(String), + DownloadFile(String), + UploadProgress(UploadProgress), + UploadComplete(String), + UploadError(String), + RefreshCurrent, + ShowCreateDirDialog, + HideCreateDirDialog, + ToggleUpload, + UppyInitialized(JsValue), + NoOp, + SetNewDirName(String), + ConfirmCreateDir, + KeyDown(KeyboardEvent), + SelectNext, + SelectPrevious, + ActivateSelected, + ViewFile(String), + CloseViewer, + EditFile(String), + SwitchToMarkdownEditor(String), + SwitchToTextEditor(String), + BackToBrowser, + LoadFileContent(String), + FileContentLoaded(String, String), // (file_path, content) + FileContentLoadError(String), + UpdateEditorContent(String), // Update editor content for live preview +} + +/// Main file browser component +pub struct FileBrowser { + config: FileBrowserConfig, + current_path: String, + items: Vec, + loading: bool, + error_message: Option, + uppy_instance: Option, + show_create_dir_dialog: bool, + new_dir_name: String, + upload_progress: HashMap, + show_upload_area: bool, + selected_index: Option, + viewing_file: Option, + view_mode: ViewMode, +} + +impl Component for FileBrowser { + type Message = FileBrowserMsg; + type Properties = FileBrowserConfig; + + fn create(ctx: &Context) -> Self { + let config = ctx.props().clone(); + let current_path = config.initial_path.clone(); + + let browser = Self { + config, + current_path: current_path.clone(), + items: Vec::new(), + loading: false, + error_message: None, + uppy_instance: None, + show_create_dir_dialog: false, + new_dir_name: String::new(), + upload_progress: HashMap::new(), + show_upload_area: false, + selected_index: None, + viewing_file: None, + view_mode: ViewMode::Browser, + }; + + // Load initial directory + ctx.link().send_message(FileBrowserMsg::LoadDirectory(current_path)); + + browser + } + + fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { + match msg { + FileBrowserMsg::LoadDirectory(path) => { + self.loading = true; + self.error_message = None; + self.current_path = path.clone(); + + let link = ctx.link().clone(); + let endpoint = self.config.base_endpoint.clone(); + + spawn_local(async move { + match load_directory(&endpoint, &path).await { + Ok(items) => link.send_message(FileBrowserMsg::DirectoryLoaded(items)), + Err(err) => link.send_message(FileBrowserMsg::LoadError(err)), + } + }); + true + } + + FileBrowserMsg::DirectoryLoaded(items) => { + self.loading = false; + self.items = items; + self.error_message = None; + + // Reinitialize Uppy for the new directory if upload is enabled + if self.config.show_upload { + self.initialize_uppy(ctx); + } + + true + } + + FileBrowserMsg::LoadError(error) => { + self.loading = false; + self.error_message = Some(error); + true + } + + FileBrowserMsg::NavigateUp => { + if !self.current_path.is_empty() { + let parent_path = get_parent_path(&self.current_path); + ctx.link().send_message(FileBrowserMsg::LoadDirectory(parent_path)); + } + false + } + + FileBrowserMsg::NavigateToPath(path) => { + ctx.link().send_message(FileBrowserMsg::LoadDirectory(path)); + false + } + + FileBrowserMsg::RefreshCurrent => { + ctx.link().send_message(FileBrowserMsg::LoadDirectory(self.current_path.clone())); + false + } + + FileBrowserMsg::ShowCreateDirDialog => { + self.show_create_dir_dialog = true; + self.new_dir_name.clear(); + true + } + + FileBrowserMsg::HideCreateDirDialog => { + self.show_create_dir_dialog = false; + true + } + + FileBrowserMsg::ToggleUpload => { + if let Some(uppy) = &self.uppy_instance { + self.open_upload_modal(uppy); + } else { + console_log!("Uppy instance not available yet"); + } + false // No need to re-render + } + + FileBrowserMsg::UppyInitialized(uppy) => { + self.uppy_instance = Some(uppy); + console_log!("Uppy instance stored successfully"); + false // No need to re-render + } + + FileBrowserMsg::KeyDown(event) => { + match event.key().as_str() { + "ArrowDown" => { + event.prevent_default(); + ctx.link().send_message(FileBrowserMsg::SelectNext); + } + "ArrowUp" => { + event.prevent_default(); + ctx.link().send_message(FileBrowserMsg::SelectPrevious); + } + "Enter" => { + event.prevent_default(); + ctx.link().send_message(FileBrowserMsg::ActivateSelected); + } + "Escape" => { + event.prevent_default(); + self.selected_index = None; + } + _ => {} + } + false + } + + FileBrowserMsg::SelectNext => { + if !self.items.is_empty() { + self.selected_index = Some(match self.selected_index { + Some(idx) => (idx + 1) % self.items.len(), + None => 0, + }); + } + true + } + + FileBrowserMsg::SelectPrevious => { + if !self.items.is_empty() { + self.selected_index = Some(match self.selected_index { + Some(idx) => if idx == 0 { self.items.len() - 1 } else { idx - 1 }, + None => self.items.len() - 1, + }); + } + true + } + + FileBrowserMsg::ActivateSelected => { + if let Some(idx) = self.selected_index { + if let Some(item) = self.items.get(idx) { + if item.is_directory { + let new_path = if self.current_path.is_empty() { + item.name.clone() + } else { + format!("{}/{}", self.current_path, item.name) + }; + ctx.link().send_message(FileBrowserMsg::NavigateToPath(new_path)); + } else { + let file_path = if self.current_path.is_empty() { + item.name.clone() + } else { + format!("{}/{}", self.current_path, item.name) + }; + + // If file is viewable, view it; otherwise download it + if self.is_viewable_file(&item.name) { + ctx.link().send_message(FileBrowserMsg::ViewFile(file_path)); + } else if self.config.show_download { + ctx.link().send_message(FileBrowserMsg::DownloadFile(file_path)); + } + } + } + } + false + } + + FileBrowserMsg::SetNewDirName(name) => { + self.new_dir_name = name; + true + } + + FileBrowserMsg::ConfirmCreateDir => { + if !self.new_dir_name.trim().is_empty() { + let dir_path = if self.current_path.is_empty() { + self.new_dir_name.clone() + } else { + format!("{}/{}", self.current_path, self.new_dir_name) + }; + ctx.link().send_message(FileBrowserMsg::CreateDirectory(dir_path)); + self.show_create_dir_dialog = false; + } + true + } + + FileBrowserMsg::CreateDirectory(path) => { + let link = ctx.link().clone(); + let endpoint = self.config.base_endpoint.clone(); + + spawn_local(async move { + match create_directory(&endpoint, &path).await { + Ok(_) => link.send_message(FileBrowserMsg::RefreshCurrent), + Err(err) => link.send_message(FileBrowserMsg::LoadError(err)), + } + }); + false + } + + FileBrowserMsg::DeleteItem(path) => { + let link = ctx.link().clone(); + let endpoint = self.config.base_endpoint.clone(); + + spawn_local(async move { + match delete_item(&endpoint, &path).await { + Ok(_) => link.send_message(FileBrowserMsg::RefreshCurrent), + Err(err) => link.send_message(FileBrowserMsg::LoadError(err)), + } + }); + false + } + + FileBrowserMsg::DownloadFile(path) => { + let endpoint = self.config.base_endpoint.clone(); + spawn_local(async move { + let _ = download_file(&endpoint, &path).await; + }); + false + } + + FileBrowserMsg::UploadProgress(progress) => { + self.upload_progress.insert(progress.filename.clone(), progress); + true + } + + FileBrowserMsg::UploadComplete(file_name) => { + self.upload_progress.remove(&file_name); + ctx.link().send_message(FileBrowserMsg::RefreshCurrent); + true + } + + FileBrowserMsg::UploadError(error) => { + self.error_message = Some(format!("Upload error: {}", error)); + true + } + + FileBrowserMsg::ViewFile(path) => { + // Set the file to be viewed in the viewer modal + self.viewing_file = Some(path); + true + } + + FileBrowserMsg::CloseViewer => { + self.viewing_file = None; + true + } + + FileBrowserMsg::EditFile(file_path) => { + // Determine editor type based on file extension + let filename = file_path.split('/').last().unwrap_or(&file_path); + let extension = filename.split('.').last().unwrap_or("").to_lowercase(); + + match extension.as_str() { + "md" | "markdown" => { + ctx.link().send_message(FileBrowserMsg::SwitchToMarkdownEditor(file_path)); + } + _ => { + ctx.link().send_message(FileBrowserMsg::SwitchToTextEditor(file_path)); + } + } + false + } + + FileBrowserMsg::SwitchToMarkdownEditor(file_path) => { + self.view_mode = ViewMode::MarkdownEditor { file_path: file_path.clone(), content: None, current_text: None }; + ctx.link().send_message(FileBrowserMsg::LoadFileContent(file_path)); + true + } + + FileBrowserMsg::SwitchToTextEditor(file_path) => { + self.view_mode = ViewMode::TextEditor { file_path: file_path.clone(), content: None, current_text: None }; + ctx.link().send_message(FileBrowserMsg::LoadFileContent(file_path)); + true + } + + FileBrowserMsg::BackToBrowser => { + self.view_mode = ViewMode::Browser; + true + } + + FileBrowserMsg::LoadFileContent(file_path) => { + let link = ctx.link().clone(); + let endpoint = self.config.base_endpoint.clone(); + + spawn_local(async move { + match load_file_content(&endpoint, &file_path).await { + Ok(content) => link.send_message(FileBrowserMsg::FileContentLoaded(file_path, content)), + Err(err) => link.send_message(FileBrowserMsg::FileContentLoadError(err)), + } + }); + false + } + + FileBrowserMsg::FileContentLoaded(file_path, content) => { + match &mut self.view_mode { + ViewMode::MarkdownEditor { file_path: current_path, content: current_content, current_text } => { + if current_path == &file_path { + *current_content = Some(content.clone()); + *current_text = Some(content); // Initialize current_text with loaded content + return true; + } + } + ViewMode::TextEditor { file_path: current_path, content: current_content, current_text } => { + if current_path == &file_path { + *current_content = Some(content.clone()); + *current_text = Some(content); // Initialize current_text with loaded content + return true; + } + } + _ => {} + } + false + } + + FileBrowserMsg::FileContentLoadError(error) => { + self.error_message = Some(format!("Failed to load file: {}", error)); + true + } + + FileBrowserMsg::UpdateEditorContent(new_content) => { + match &mut self.view_mode { + ViewMode::MarkdownEditor { current_text, .. } => { + *current_text = Some(new_content); + true + } + ViewMode::TextEditor { current_text, .. } => { + *current_text = Some(new_content); + true + } + _ => false + } + } + + FileBrowserMsg::NoOp => { + false + } + } + } + + fn view(&self, ctx: &Context) -> Html { + let link = ctx.link(); + + match &self.view_mode { + ViewMode::Browser => { + html! { +
+ {self.render_breadcrumb(link)} + {self.render_content(link)} + {self.render_create_dir_dialog(link)} + {self.render_file_viewer(link)} +
+ } + } + ViewMode::MarkdownEditor { file_path, content, current_text } => { + self.render_markdown_editor(link, file_path, content, current_text) + } + ViewMode::TextEditor { file_path, content, current_text } => { + self.render_text_editor(link, file_path, content, current_text) + } + ViewMode::FileViewer { file_path, content } => { + self.render_inline_file_viewer(link, file_path, content) + } + } + } + + fn rendered(&mut self, ctx: &Context, first_render: bool) { + if first_render && self.config.show_upload { + self.initialize_uppy(ctx); + } + } +} + +impl FileBrowser { + fn get_file_icon(&self, filename: &str) -> &'static str { + let extension = filename.split('.').last().unwrap_or("").to_lowercase(); + match extension.as_str() { + // Documents + "pdf" => "bi-file-earmark-pdf text-danger", + "doc" | "docx" => "bi-file-earmark-word text-primary", + "xls" | "xlsx" => "bi-file-earmark-excel text-success", + "ppt" | "pptx" => "bi-file-earmark-ppt text-warning", + "txt" | "md" | "readme" => "bi-file-earmark-text text-secondary", + + // Images + "jpg" | "jpeg" | "png" | "gif" | "bmp" | "webp" | "svg" => "bi-file-earmark-image text-info", + + // Audio/Video + "mp3" | "wav" | "ogg" | "flac" => "bi-file-earmark-music text-purple", + "mp4" | "avi" | "mov" | "mkv" | "webm" => "bi-file-earmark-play text-dark", + + // Archives + "zip" | "rar" | "7z" | "tar" | "gz" => "bi-file-earmark-zip text-warning", + + // Code files + "js" | "ts" | "jsx" | "tsx" => "bi-file-earmark-code text-warning", + "html" | "htm" | "css" => "bi-file-earmark-code text-info", + "py" | "rs" | "go" | "java" | "cpp" | "c" => "bi-file-earmark-code text-success", + "json" | "xml" | "yaml" | "yml" => "bi-file-earmark-code text-secondary", + + // Default + _ => "bi-file-earmark text-muted", + } + } + + fn is_viewable_file(&self, filename: &str) -> bool { + let extension = filename.split('.').last().unwrap_or("").to_lowercase(); + matches!(extension.as_str(), + "pdf" | "txt" | "md" | "json" | "xml" | "html" | "htm" | "css" | "js" | "ts" | + "jpg" | "jpeg" | "png" | "gif" | "bmp" | "webp" | "svg" | + "mp3" | "wav" | "ogg" | "mp4" | "avi" | "mov" | "webm" + ) + } + + fn render_breadcrumb(&self, link: &html::Scope) -> Html { + let path_parts: Vec<&str> = if self.current_path.is_empty() { + vec![] + } else { + self.current_path.split('/').collect() + }; + + html! { + + } + } + + fn render_content(&self, link: &html::Scope) -> Html { + if self.loading { + return html! { +
+
+ {"Loading..."} +
+

{"Loading directory..."}

+
+ }; + } + + if let Some(error) = &self.error_message { + return html! { + + }; + } + + if self.items.is_empty() { + return html! { +
+ +

{"This directory is empty"}

+
+ }; + } + + html! { +
+
+ + + + + + + + + + + {for self.items.iter().enumerate().map(|(index, item)| self.render_file_item(item, index, link))} + +
{"Name"}{"Size"}{"Modified"}{"Actions"}
+
+
+ } + } + + fn render_file_item(&self, item: &FileItem, index: usize, link: &html::Scope) -> Html { + let item_path = item.path.clone(); + let item_name = item.name.clone(); + let is_dir = item.is_directory; + let file_size = item.size; + let modified_time = item.modified.clone().unwrap_or_else(|| "—".to_string()); + + // Clone paths for each closure to avoid move issues + let nav_path = item_path.clone(); + let download_path = item_path.clone(); + let delete_path = item_path.clone(); + let delete_name = item_name.clone(); + + // File type and viewing logic + let file_icon = if is_dir { "" } else { self.get_file_icon(&item_name) }; + let is_viewable = if is_dir { false } else { self.is_viewable_file(&item_name) }; + let view_path = item_path.clone(); + + let is_selected = self.selected_index == Some(index); + let row_class = if is_selected { + "table-active" + } else { + "" + }; + + html! { + + + if is_dir { + + + {&item_name} + + } else { + <> + + if is_viewable { + + {&item_name} + + } else { + {&item_name} + } + + } + + + if is_dir { + {"Directory"} + } else { + {file_size.map(format_file_size).unwrap_or_else(|| "—".to_string())} + } + + {&modified_time} + +
+ if !is_dir && self.config.show_download { + + } + if self.config.show_delete { + + } +
+ + + } + } + + fn render_upload_area(&self, _link: &html::Scope) -> Html { + if !self.config.show_upload { + return html! {}; + } + + html! { +
+
+
+
+ + {" Upload Files"} +
+
+
+
+ + // Show upload progress + if !self.upload_progress.is_empty() { +
+
{"Upload Progress:"}
+ {for self.upload_progress.values().map(|progress| { + html! { +
+
+ {&progress.filename} + {format!("{:.1}%", progress.progress)} +
+
+
+
+
+ } + })} +
+ } +
+
+
+ } + } + + fn render_create_dir_dialog(&self, link: &html::Scope) -> Html { + if !self.show_create_dir_dialog { + return html! {}; + } + + html! { + + } + } + + fn render_file_viewer(&self, link: &html::Scope) -> Html { + if let Some(file_path) = &self.viewing_file { + let file_url = format!("{}/download/{}", self.config.base_endpoint, file_path); + let filename = file_path.split('/').last().unwrap_or(file_path).to_string(); + let extension = filename.split('.').last().unwrap_or("").to_lowercase(); + + let viewer_content = match extension.as_str() { + // Images + "jpg" | "jpeg" | "png" | "gif" | "bmp" | "webp" | "svg" => { + html! { +
+ {filename.clone()} +
+ } + } + + // PDFs + "pdf" => { + html! { +
+ +
+ } + } + + // Text files - display content directly instead of iframe + "txt" | "md" | "json" | "xml" | "css" | "js" | "ts" | "rs" | "py" | "go" | "java" | "cpp" | "c" | "yml" | "yaml" => { + html! { +
+
+                                
+                                    {"Loading..."}
+                                
+                            
+ +
+ } + } + + // HTML files - use iframe for proper rendering + "html" | "htm" => { + html! { +
+ +
+ } + } + + // Audio + "mp3" | "wav" | "ogg" | "flac" => { + html! { +
+ +
+ } + } + + // Video + "mp4" | "avi" | "mov" | "mkv" | "webm" => { + html! { +
+ +
+ } + } + + // Default - show download link + _ => { + html! { +
+

{"This file type cannot be previewed."}

+ + + {"Download File"} + +
+ } + } + }; + + html! { + + } + } else { + html! {} + } + } + + fn render_markdown_editor(&self, link: &html::Scope, file_path: &str, content: &Option, current_text: &Option) -> Html { + html! { +
+
+ +
{format!("Editing: {}", file_path)}
+
+
+
+
+
{"Markdown Editor"}
+ +
+
+
+
+
{"Live Preview"}
+
+ {self.render_markdown_preview(¤t_text.as_ref().or(content.as_ref()).map(|s| s.clone()))} +
+
+
+
+
+ } + } + + fn render_text_editor(&self, link: &html::Scope, file_path: &str, content: &Option, current_text: &Option) -> Html { + html! { +
+
+ +
{format!("Editing: {}", file_path)}
+
+
+ +
+ + +
+
+
+ } + } + + fn render_inline_file_viewer(&self, link: &html::Scope, file_path: &str, content: &str) -> Html { + html! { +
+
+ +
{format!("Viewing: {}", file_path)}
+
+
+
{content}
+
+
+ } + } + + fn render_markdown_preview(&self, content: &Option) -> Html { + match content { + Some(markdown_text) => { + // Simple markdown rendering - convert basic markdown to HTML + let html_content = self.markdown_to_html(markdown_text); + Html::from_html_unchecked(AttrValue::from(html_content)) + } + None => html! {

{"Loading preview..."}

} + } + } + + fn markdown_to_html(&self, markdown: &str) -> String { + let mut html = String::new(); + let lines: Vec<&str> = markdown.lines().collect(); + let mut in_code_block = false; + let mut in_list = false; + + for line in lines { + let trimmed = line.trim(); + + // Handle code blocks + if trimmed.starts_with("```") { + if in_code_block { + html.push_str(""); + in_code_block = false; + } else { + html.push_str("
");
+                    in_code_block = true;
+                }
+                continue;
+            }
+            
+            if in_code_block {
+                html.push_str(&format!("{}
", self.escape_html(line))); + continue; + } + + // Handle headers + if trimmed.starts_with("# ") { + html.push_str(&format!("

{}

", self.escape_html(&trimmed[2..]))); + } else if trimmed.starts_with("## ") { + html.push_str(&format!("

{}

", self.escape_html(&trimmed[3..]))); + } else if trimmed.starts_with("### ") { + html.push_str(&format!("

{}

", self.escape_html(&trimmed[4..]))); + } else if trimmed.starts_with("#### ") { + html.push_str(&format!("

{}

", self.escape_html(&trimmed[5..]))); + } else if trimmed.starts_with("##### ") { + html.push_str(&format!("
{}
", self.escape_html(&trimmed[6..]))); + } else if trimmed.starts_with("###### ") { + html.push_str(&format!("
{}
", self.escape_html(&trimmed[7..]))); + } + // Handle lists + else if trimmed.starts_with("- ") || trimmed.starts_with("* ") { + if !in_list { + html.push_str("
    "); + in_list = true; + } + html.push_str(&format!("
  • {}
  • ", self.escape_html(&trimmed[2..]))); + } + // Handle numbered lists + else if trimmed.chars().next().map_or(false, |c| c.is_ascii_digit()) && trimmed.contains(". ") { + if !in_list { + html.push_str("
      "); + in_list = true; + } + let content = trimmed.split(". ").skip(1).collect::>().join(". "); + html.push_str(&format!("
    1. {}
    2. ", self.escape_html(&content))); + } + // Handle empty lines + else if trimmed.is_empty() { + if in_list { + html.push_str("
"); // Close both types just in case + in_list = false; + } + html.push_str("
"); + } + // Handle regular paragraphs + else { + if in_list { + html.push_str(""); // Close both types just in case + in_list = false; + } + let processed = self.process_inline_markdown(&self.escape_html(trimmed)); + html.push_str(&format!("

{}

", processed)); + } + } + + // Close any open tags + if in_list { + html.push_str(""); + } + if in_code_block { + html.push_str("
"); + } + + html + } + + fn process_inline_markdown(&self, text: &str) -> String { + let mut result = text.to_string(); + + // Bold **text** + result = result.replace("**", "").replace("", ""); + + // Italic *text* + result = result.replace("*", "").replace("", ""); + + // Inline code `text` + result = result.replace("`", "").replace("", ""); + + result + } + + fn escape_html(&self, text: &str) -> String { + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") + } + + fn initialize_uppy(&mut self, ctx: &Context) { + let endpoint = self.config.base_endpoint.clone(); + let current_path = self.current_path.clone(); + let theme = self.config.theme.clone(); + let max_file_size = self.config.max_file_size; + + let self_weak = ctx.link().clone(); + spawn_local(async move { + match initialize_uppy_dashboard(&endpoint, ¤t_path, &theme, max_file_size, self_weak.clone()).await { + Ok(uppy) => { + console_log!("Uppy initialized successfully"); + // Send the uppy instance back to store it + self_weak.send_message(FileBrowserMsg::UppyInitialized(uppy)); + } + Err(err) => { + console_log!("Failed to initialize Uppy: {}", err); + } + } + }); + } +} + +// Utility functions + +fn get_parent_path(path: &str) -> String { + if path.is_empty() { + return String::new(); + } + + let parts: Vec<&str> = path.split('/').collect(); + if parts.len() <= 1 { + String::new() + } else { + parts[..parts.len() - 1].join("/") + } +} + +fn format_file_size(bytes: u64) -> String { + const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"]; + let mut size = bytes as f64; + let mut unit_index = 0; + + while size >= 1024.0 && unit_index < UNITS.len() - 1 { + size /= 1024.0; + unit_index += 1; + } + + if unit_index == 0 { + format!("{} {}", bytes, UNITS[unit_index]) + } else { + format!("{:.1} {}", size, UNITS[unit_index]) + } +} + +// JavaScript interop for Uppy.js - we'll use js_sys::Reflect for method calls + +// API response structures +#[derive(Deserialize)] +struct ListResponse { + contents: Vec, +} + +// API functions + +async fn load_directory(endpoint: &str, path: &str) -> Result, String> { + let url = if path.is_empty() { + format!("{}/list/", endpoint) + } else { + format!("{}/list/{}", endpoint, path) + }; + + let window = window().ok_or("No window object")?; + let resp_value = JsFuture::from(window.fetch_with_str(&url)) + .await + .map_err(|e| format!("Fetch error: {:?}", e))?; + + let resp: Response = resp_value.dyn_into().map_err(|_| "Invalid response")?; + + if !resp.ok() { + return Err(format!("HTTP {}: {}", resp.status(), resp.status_text())); + } + + let response_text = JsFuture::from(resp.text().map_err(|_| "Failed to get response text")?) + .await + .map_err(|_| "Failed to read response text")?; + + let json_str = response_text.as_string() + .ok_or("Response is not a string")?; + + // Provide more detailed error information for JSON parsing + let list_response: ListResponse = serde_json::from_str(&json_str) + .map_err(|e| { + console_log!("JSON parse error: {}", e); + console_log!("Response text: {}", json_str); + format!("Failed to parse JSON response: {}. This usually means the backend server is not running or returned an error page.", e) + })?; + + Ok(list_response.contents) +} + +async fn create_directory(endpoint: &str, path: &str) -> Result<(), String> { + let url = format!("{}/dirs/{}", endpoint, path); + + let opts = RequestInit::new(); + opts.set_method("POST"); + opts.set_mode(RequestMode::Cors); + + let request = Request::new_with_str_and_init(&url, &opts) + .map_err(|_| "Failed to create request")?; + + let window = window().ok_or("No window object")?; + let resp_value = JsFuture::from(window.fetch_with_request(&request)) + .await + .map_err(|e| format!("Fetch error: {:?}", e))?; + + let resp: Response = resp_value.dyn_into().map_err(|_| "Invalid response")?; + + if !resp.ok() { + return Err(format!("HTTP {}: {}", resp.status(), resp.status_text())); + } + + Ok(()) +} + +async fn delete_item(endpoint: &str, path: &str) -> Result<(), String> { + let url = format!("{}/delete/{}", endpoint, path); + + let opts = RequestInit::new(); + opts.set_method("DELETE"); + opts.set_mode(RequestMode::Cors); + + let request = Request::new_with_str_and_init(&url, &opts) + .map_err(|_| "Failed to create request")?; + + let window = window().ok_or("No window object")?; + let resp_value = JsFuture::from(window.fetch_with_request(&request)) + .await + .map_err(|e| format!("Fetch error: {:?}", e))?; + + let resp: Response = resp_value.dyn_into().map_err(|_| "Invalid response")?; + + if !resp.ok() { + return Err(format!("HTTP {}: {}", resp.status(), resp.status_text())); + } + + Ok(()) +} + +async fn load_file_content(endpoint: &str, path: &str) -> Result { + let url = format!("{}/download/{}", endpoint, path); + + let window = window().ok_or("No window object")?; + let resp_value = JsFuture::from(window.fetch_with_str(&url)) + .await + .map_err(|e| format!("Fetch error: {:?}", e))?; + + let resp: Response = resp_value.dyn_into().map_err(|_| "Invalid response")?; + + if !resp.ok() { + return Err(format!("HTTP {}: {}", resp.status(), resp.status_text())); + } + + // Get text content + let text_value = JsFuture::from(resp.text().map_err(|_| "Failed to get text")?) + .await + .map_err(|e| format!("Text error: {:?}", e))?; + + let content = text_value.as_string().ok_or("Failed to convert to string")?; + + Ok(content) +} + +async fn download_file(endpoint: &str, path: &str) -> Result<(), String> { + let url = format!("{}/download/{}", endpoint, path); + + let window = window().ok_or("No window object")?; + let resp_value = JsFuture::from(window.fetch_with_str(&url)) + .await + .map_err(|e| format!("Fetch error: {:?}", e))?; + + let resp: Response = resp_value.dyn_into().map_err(|_| "Invalid response")?; + + if !resp.ok() { + return Err(format!("HTTP {}: {}", resp.status(), resp.status_text())); + } + + // Get filename from path + let filename = path.split('/').last().unwrap_or("download"); + + // Create blob and download + let blob = JsFuture::from(resp.blob().map_err(|_| "Failed to get blob")?) + .await + .map_err(|e| format!("Blob error: {:?}", e))?; + + let url_obj = web_sys::Url::create_object_url_with_blob(&blob.into()) + .map_err(|_| "Failed to create object URL")?; + + let document = window.document().ok_or("No document")?; + let a = document.create_element("a").map_err(|_| "Failed to create element")?; + let a = a.dyn_into::().map_err(|_| "Invalid anchor element")?; + + a.set_href(&url_obj); + a.set_download(filename); + a.style().set_property("display", "none").map_err(|_| "Failed to set style")?; + + document.body().ok_or("No body")?.append_child(&a).map_err(|_| "Failed to append child")?; + a.click(); + document.body().ok_or("No body")?.remove_child(&a).map_err(|_| "Failed to remove child")?; + + web_sys::Url::revoke_object_url(&url_obj).map_err(|_| "Failed to revoke URL")?; + + Ok(()) +} + +async fn initialize_uppy_dashboard( + endpoint: &str, + current_path: &str, + theme: &str, + max_file_size: u64, + link: html::Scope, +) -> Result { + let window = window().ok_or("No window object")?; + + // Try different approaches to create Uppy instance + // The bundled Uppy.js might export differently than expected + + // First, try to detect what's actually available in the global scope + let debug_script = "(function() { + const available = []; + if (typeof Uppy !== 'undefined') { + available.push('Uppy: ' + typeof Uppy); + if (typeof Uppy === 'object') { + available.push('Uppy keys: ' + Object.keys(Uppy).join(', ')); + // Check for common constructor patterns + if (Uppy.Core) available.push('Uppy.Core: ' + typeof Uppy.Core); + if (Uppy.Uppy) available.push('Uppy.Uppy: ' + typeof Uppy.Uppy); + if (Uppy.default) available.push('Uppy.default: ' + typeof Uppy.default); + } + } + if (typeof window.Uppy !== 'undefined') available.push('window.Uppy: ' + typeof window.Uppy); + if (typeof UppyCore !== 'undefined') available.push('UppyCore: ' + typeof UppyCore); + if (typeof window.UppyCore !== 'undefined') available.push('window.UppyCore: ' + typeof window.UppyCore); + return 'Available: ' + available.join(', '); + })()"; + + let debug_result = js_sys::eval(debug_script).unwrap(); + web_sys::console::log_1(&format!("Debug Uppy availability: {:?}", debug_result).into()); + + // Try different constructor patterns - prioritize the ones we know exist + let uppy_attempts = vec![ + // Try Uppy.Uppy first - we know this exists as a function + format!("new Uppy.Uppy({{ debug: false, autoProceed: false, restrictions: {{ maxFileSize: {} }} }})", max_file_size), + // Try Uppy.Core second - we know this exists as a function + format!("new Uppy.Core({{ debug: false, autoProceed: false, restrictions: {{ maxFileSize: {} }} }})", max_file_size), + // Try Uppy.default if it's an ES module export + format!("new Uppy.default({{ debug: false, autoProceed: false, restrictions: {{ maxFileSize: {} }} }})", max_file_size), + // Standard Uppy constructor (we know this fails but keep for completeness) + format!("new Uppy({{ debug: false, autoProceed: false, restrictions: {{ maxFileSize: {} }} }})", max_file_size), + // Try UppyCore if that's the export + format!("new UppyCore({{ debug: false, autoProceed: false, restrictions: {{ maxFileSize: {} }} }})", max_file_size), + // Try window.Uppy + format!("new window.Uppy({{ debug: false, autoProceed: false, restrictions: {{ maxFileSize: {} }} }})", max_file_size), + ]; + + let mut uppy_result = None; + let mut last_error = String::new(); + + for attempt in uppy_attempts { + match js_sys::eval(&attempt) { + Ok(result) => { + web_sys::console::log_1(&format!("Successfully created Uppy with: {}", attempt).into()); + uppy_result = Some(result); + break; + }, + Err(e) => { + last_error = format!("{:?}", e); + web_sys::console::log_1(&format!("Failed attempt: {} - Error: {}", attempt, last_error).into()); + } + } + } + + let uppy = uppy_result.ok_or_else(|| { + format!("All Uppy constructor attempts failed. Last error: {}. Make sure Uppy.js is loaded correctly.", last_error) + })?; + + // Configure TUS plugin + let tus_options = Object::new(); + let tus_endpoint = if current_path.is_empty() { + format!("{}/upload", endpoint) + } else { + format!("{}/upload/to/{}", endpoint, current_path) + }; + Reflect::set(&tus_options, &"endpoint".into(), &tus_endpoint.into()).unwrap(); + Reflect::set(&tus_options, &"resume".into(), &true.into()).unwrap(); + + let retry_delays = Array::new(); + retry_delays.push(&0.into()); + retry_delays.push(&1000.into()); + retry_delays.push(&3000.into()); + retry_delays.push(&5000.into()); + Reflect::set(&tus_options, &"retryDelays".into(), &retry_delays).unwrap(); + + let allowed_meta_fields = Array::new(); + allowed_meta_fields.push(&"name".into()); + allowed_meta_fields.push(&"type".into()); + allowed_meta_fields.push(&"path".into()); + Reflect::set(&tus_options, &"allowedMetaFields".into(), &allowed_meta_fields).unwrap(); + + // Add TUS plugin - try different access patterns for bundled Uppy + let tus_plugin = Reflect::get(&window, &"Uppy".into()) + .and_then(|uppy_obj| Reflect::get(&uppy_obj, &"Tus".into())) + .or_else(|_| { + // Try alternative access pattern for bundled version + js_sys::eval("Uppy.Tus") + }) + .map_err(|_| "Uppy.Tus plugin not found. Check if Uppy bundle includes TUS plugin.")?; + + // Call uppy.use(tus_plugin, tus_options) using Reflect + let use_method = Reflect::get(&uppy, &"use".into()).map_err(|_| "Uppy.use method not found")?; + let args = js_sys::Array::new(); + args.push(&tus_plugin); + args.push(&tus_options.into()); + js_sys::Reflect::apply(&use_method.into(), &uppy, &args).map_err(|_| "Failed to call uppy.use for TUS")?; + + // Configure Dashboard plugin for modal mode + let dashboard_options = Object::new(); + Reflect::set(&dashboard_options, &"inline".into(), &false.into()).unwrap(); // Modal mode + Reflect::set(&dashboard_options, &"proudlyDisplayPoweredByUppy".into(), &false.into()).unwrap(); + Reflect::set(&dashboard_options, &"theme".into(), &theme.into()).unwrap(); + Reflect::set(&dashboard_options, &"closeModalOnClickOutside".into(), &true.into()).unwrap(); + Reflect::set(&dashboard_options, &"showProgressDetails".into(), &true.into()).unwrap(); + + let dashboard_plugin = Reflect::get(&window, &"Uppy".into()) + .and_then(|uppy_obj| Reflect::get(&uppy_obj, &"Dashboard".into())) + .or_else(|_| { + // Try alternative access pattern for bundled version + js_sys::eval("Uppy.Dashboard") + }) + .map_err(|_| "Uppy.Dashboard plugin not found. Check if Uppy bundle includes Dashboard plugin.")?; + + // Call uppy.use(dashboard_plugin, dashboard_options) using Reflect + let use_method = Reflect::get(&uppy, &"use".into()).map_err(|_| "Uppy.use method not found")?; + let args = js_sys::Array::new(); + args.push(&dashboard_plugin); + args.push(&dashboard_options.into()); + js_sys::Reflect::apply(&use_method.into(), &uppy, &args).map_err(|_| "Failed to call uppy.use for Dashboard")?; + + // Add Google Drive plugin + let googledrive_plugin = Reflect::get(&window, &"Uppy".into()) + .and_then(|uppy_obj| Reflect::get(&uppy_obj, &"GoogleDrive".into())); + + if let Ok(googledrive_plugin) = googledrive_plugin { + let googledrive_options = Object::new(); + Reflect::set(&googledrive_options, &"companionUrl".into(), &"https://companion.uppy.io".into()).unwrap(); + + let use_method = Reflect::get(&uppy, &"use".into()).map_err(|_| "Uppy.use method not found")?; + let args = js_sys::Array::new(); + args.push(&googledrive_plugin); + args.push(&googledrive_options.into()); + let _ = js_sys::Reflect::apply(&use_method.into(), &uppy, &args); // Don't fail if Google Drive plugin isn't available + } + + // Set up event handlers + let link_clone = link.clone(); + let progress_callback = Closure::wrap(Box::new(move |file: JsValue, progress: JsValue| { + if let (Ok(file_name), Ok(percentage)) = ( + Reflect::get(&file, &"name".into()).and_then(|v| v.as_string().ok_or_else(|| JsValue::from_str("No name"))), + Reflect::get(&progress, &"percentage".into()).and_then(|v| v.as_f64().ok_or_else(|| JsValue::from_f64(0.0))) + ) { + let upload_progress = UploadProgress { + filename: file_name, + progress: percentage, + uploaded: 0, // TUS doesn't provide this in the progress event + total: 0, // TUS doesn't provide this in the progress event + }; + link_clone.send_message(FileBrowserMsg::UploadProgress(upload_progress)); + } + }) as Box); + + // Call uppy.on("upload-progress", callback) using Reflect + let on_method = Reflect::get(&uppy, &"on".into()).map_err(|_| "Uppy.on method not found")?; + let args = js_sys::Array::new(); + args.push(&"upload-progress".into()); + args.push(progress_callback.as_ref().unchecked_ref()); + js_sys::Reflect::apply(&on_method.clone().into(), &uppy, &args).map_err(|_| "Failed to call uppy.on for progress")?; + progress_callback.forget(); + + let link_clone = link.clone(); + let success_callback = Closure::wrap(Box::new(move |file: JsValue, _response: JsValue| { + if let Ok(file_name) = Reflect::get(&file, &"name".into()).and_then(|v| v.as_string().ok_or_else(|| JsValue::from_str("unknown"))) { + link_clone.send_message(FileBrowserMsg::UploadComplete(file_name)); + } + }) as Box); + + // Call uppy.on("upload-success", callback) using Reflect + let args = js_sys::Array::new(); + args.push(&"upload-success".into()); + args.push(success_callback.as_ref().unchecked_ref()); + js_sys::Reflect::apply(&on_method.clone().into(), &uppy, &args).map_err(|_| "Failed to call uppy.on for success")?; + success_callback.forget(); + + let link_clone = link.clone(); + let error_callback = Closure::wrap(Box::new(move |file: JsValue, error: JsValue, _response: JsValue| { + let file_name = Reflect::get(&file, &"name".into()) + .and_then(|v| v.as_string().ok_or_else(|| JsValue::from_str("unknown"))) + .unwrap_or_else(|_| "unknown".to_string()); + let error_msg = Reflect::get(&error, &"message".into()) + .and_then(|v| v.as_string().ok_or_else(|| JsValue::from_str("Upload failed"))) + .unwrap_or_else(|_| "Upload failed".to_string()); + + link_clone.send_message(FileBrowserMsg::UploadError(format!("{}: {}", file_name, error_msg))); + }) as Box); + + // Call uppy.on("upload-error", callback) using Reflect + let args = js_sys::Array::new(); + args.push(&"upload-error".into()); + args.push(error_callback.as_ref().unchecked_ref()); + js_sys::Reflect::apply(&on_method.clone().into(), &uppy, &args).map_err(|_| "Failed to call uppy.on for error")?; + error_callback.forget(); + + // Set metadata for uploads to include current path + let current_path = current_path.to_string(); + let file_added_callback = Closure::wrap(Box::new(move |file: JsValue| { + let meta = Object::new(); + Reflect::set(&meta, &"path".into(), ¤t_path.clone().into()).unwrap(); + + // Update file metadata + if let Ok(file_meta) = Reflect::get(&file, &"meta".into()) { + if let Ok(file_meta_obj) = file_meta.dyn_into::() { + Reflect::set(&file_meta_obj, &"path".into(), ¤t_path.clone().into()).unwrap(); + } + } + }) as Box); + + // Call uppy.on("file-added", callback) using Reflect + let args = js_sys::Array::new(); + args.push(&"file-added".into()); + args.push(file_added_callback.as_ref().unchecked_ref()); + js_sys::Reflect::apply(&on_method.into(), &uppy, &args).map_err(|_| "Failed to call uppy.on for file-added")?; + file_added_callback.forget(); + + Ok(uppy.into()) +} + +impl FileBrowser { + /// Open the Uppy upload modal + fn open_upload_modal(&self, uppy: &JsValue) { + // Call uppy.getPlugin('Dashboard').openModal() using Reflect + if let Ok(get_plugin_method) = Reflect::get(uppy, &"getPlugin".into()) { + let args = js_sys::Array::new(); + args.push(&"Dashboard".into()); + + if let Ok(dashboard_plugin) = js_sys::Reflect::apply(&get_plugin_method.into(), uppy, &args) { + if let Ok(open_modal_method) = Reflect::get(&dashboard_plugin, &"openModal".into()) { + let _ = js_sys::Reflect::apply(&open_modal_method.into(), &dashboard_plugin, &js_sys::Array::new()); + } + } + } + } +} diff --git a/components/src/lib.rs b/components/src/lib.rs new file mode 100644 index 0000000..89c3bbf --- /dev/null +++ b/components/src/lib.rs @@ -0,0 +1,50 @@ +//! Components Library +//! +//! This library provides reusable UI components for building web applications +//! with Rust, Yew, and WebAssembly. + +// Core UI components +pub mod file_browser; +pub mod markdown_editor; +pub mod text_editor; +pub mod toast; + +// Auth and utilities (moved from parent) +pub mod auth; +pub mod browser_auth; +pub mod error; +pub mod ws_manager; + +// Re-export components for easy access +pub use file_browser::FileBrowser; +pub use markdown_editor::MarkdownEditor; +pub use text_editor::TextEditor; +pub use toast::Toast; + +// Re-export utilities +pub use auth::*; +pub use browser_auth::*; +pub use error::*; +pub use ws_manager::*; + +// Re-export hero_websocket_client types that users might need +#[cfg(not(target_arch = "wasm32"))] +pub use hero_websocket_client::{ + CircleWsClient, CircleWsClientBuilder, CircleWsClientError, methods::PlayResultClient +}; + +#[cfg(target_arch = "wasm32")] +pub use yew::Callback; + +/// Version information +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Prelude module for convenient imports +pub mod prelude { + pub use crate::{ + FileBrowser, MarkdownEditor, TextEditor, Toast, + BrowserAuth, WsManager, WsManagerBuilder, WsManagerError, AuthConfig + }; + + pub use crate::VERSION; +} \ No newline at end of file diff --git a/src/main.rs b/components/src/main.rs similarity index 100% rename from src/main.rs rename to components/src/main.rs diff --git a/components/src/markdown_editor.rs b/components/src/markdown_editor.rs new file mode 100644 index 0000000..1c2e77f --- /dev/null +++ b/components/src/markdown_editor.rs @@ -0,0 +1,301 @@ +use yew::prelude::*; +use web_sys::{HtmlTextAreaElement, window}; +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::{spawn_local, JsFuture}; +use gloo_net::http::Request; + +#[derive(Properties, PartialEq)] +pub struct MarkdownEditorProps { + pub file_path: String, + pub base_endpoint: String, +} + +pub struct MarkdownEditor { + content: String, + rendered_html: String, + loading: bool, + error: Option, + textarea_ref: NodeRef, +} + +pub enum MarkdownEditorMsg { + LoadFile, + FileLoaded(String), + FileLoadError(String), + ContentChanged(String), + SaveFile, + FileSaved, + FileSaveError(String), +} + +impl Component for MarkdownEditor { + type Message = MarkdownEditorMsg; + type Properties = MarkdownEditorProps; + + fn create(ctx: &Context) -> Self { + let editor = Self { + content: String::new(), + rendered_html: String::new(), + loading: true, + error: None, + textarea_ref: NodeRef::default(), + }; + + // Load file content on creation + ctx.link().send_message(MarkdownEditorMsg::LoadFile); + + editor + } + + fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { + match msg { + MarkdownEditorMsg::LoadFile => { + let file_url = format!("{}/download/{}", ctx.props().base_endpoint, ctx.props().file_path); + let link = ctx.link().clone(); + + spawn_local(async move { + match Request::get(&file_url).send().await { + Ok(response) => { + match response.text().await { + Ok(content) => { + link.send_message(MarkdownEditorMsg::FileLoaded(content)); + } + Err(e) => { + link.send_message(MarkdownEditorMsg::FileLoadError(format!("Failed to read file: {}", e))); + } + } + } + Err(e) => { + link.send_message(MarkdownEditorMsg::FileLoadError(format!("Failed to load file: {}", e))); + } + } + }); + false + } + + MarkdownEditorMsg::FileLoaded(content) => { + self.content = content.clone(); + self.rendered_html = self.render_markdown(&content); + self.loading = false; + self.error = None; + true + } + + MarkdownEditorMsg::FileLoadError(error) => { + self.loading = false; + self.error = Some(error); + true + } + + MarkdownEditorMsg::ContentChanged(content) => { + self.content = content.clone(); + self.rendered_html = self.render_markdown(&content); + true + } + + MarkdownEditorMsg::SaveFile => { + let save_url = format!("{}/upload/{}", ctx.props().base_endpoint, ctx.props().file_path); + let content = self.content.clone(); + let link = ctx.link().clone(); + + spawn_local(async move { + let form_data = web_sys::FormData::new().unwrap(); + let blob = web_sys::Blob::new_with_str_sequence(&js_sys::Array::of1(&content.into())).unwrap(); + form_data.append_with_blob("file", &blob).unwrap(); + + let mut request_init = web_sys::RequestInit::new(); + request_init.set_method("POST"); + let js_value: wasm_bindgen::JsValue = form_data.into(); + request_init.set_body(&js_value); + + let request = web_sys::Request::new_with_str_and_init( + &save_url, + &request_init + ).unwrap(); + + match JsFuture::from(window().unwrap().fetch_with_request(&request)).await { + Ok(_) => { + link.send_message(MarkdownEditorMsg::FileSaved); + } + Err(e) => { + link.send_message(MarkdownEditorMsg::FileSaveError(format!("Failed to save file: {:?}", e))); + } + } + }); + false + } + + MarkdownEditorMsg::FileSaved => { + // Show success message or update UI + false + } + + MarkdownEditorMsg::FileSaveError(error) => { + self.error = Some(error); + true + } + } + } + + fn view(&self, ctx: &Context) -> Html { + let filename = ctx.props().file_path.split('/').last().unwrap_or(&ctx.props().file_path); + + if self.loading { + return html! { +
+
+ {"Loading..."} +
+
+ }; + } + + if let Some(error) = &self.error { + return html! { + + }; + } + + let oninput = ctx.link().callback(|e: InputEvent| { + let target = e.target_dyn_into::().unwrap(); + MarkdownEditorMsg::ContentChanged(target.value()) + }); + + let onsave = ctx.link().callback(|_| MarkdownEditorMsg::SaveFile); + + html! { +
+ // Header with filename and save button +
+
+
+ + {filename} +
+
+
+ +
+
+ + // Split-screen editor +
+ // Left pane: Text editor +
+
+
+ {"Markdown Editor"} +
+