diff --git a/.gitignore b/.gitignore index 2ad00ae..064f75c 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ logs/ .moon/docker worktrees/ .worktrees/ +frontend-leptos/frontend/dist/ +target/ diff --git a/services/frontend-leptos/.env.example b/services/frontend-leptos/.env.example new file mode 100644 index 0000000..3634e80 --- /dev/null +++ b/services/frontend-leptos/.env.example @@ -0,0 +1,3 @@ +# API/WS endpoints — set these before building +VITE_BE_API_URL=http://localhost:3001 +VITE_BE_WS_URL=ws://localhost:3001/ws diff --git a/services/frontend-leptos/Cargo.lock b/services/frontend-leptos/Cargo.lock new file mode 100644 index 0000000..e98e1ff --- /dev/null +++ b/services/frontend-leptos/Cargo.lock @@ -0,0 +1,2548 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "any_spawner" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41058deaa38c9d9dd933d6d238d825227cffa668e2839b52879f6619c63eee3b" +dependencies = [ + "futures", + "thiserror 2.0.18", + "wasm-bindgen-futures", +] + +[[package]] +name = "any_spawner" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1384d3fe1eecb464229fcf6eebb72306591c56bf27b373561489458a7c73027d" +dependencies = [ + "futures", + "thiserror 2.0.18", + "wasm-bindgen-futures", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-once-cell" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "attribute-derive" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05832cdddc8f2650cc2cc187cc2e952b8c133a48eb055f35211f61ee81502d77" +dependencies = [ + "attribute-derive-macro", + "derive-where", + "manyhow", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "attribute-derive-macro" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7cdbbd4bd005c5d3e2e9c885e6fa575db4f4a3572335b974d8db853b6beb61" +dependencies = [ + "collection_literals", + "interpolator", + "manyhow", + "proc-macro-utils", + "proc-macro2", + "quote", + "quote-use", + "syn", +] + +[[package]] +name = "base16" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27c3610c36aee21ce8ac510e6224498de4228ad772a171ed65643a24693a5a8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "camino" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "codee" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d3ad3122b0001c7f140cf4d605ef9a9e2c24d96ab0b4fb4347b76de2425f445" +dependencies = [ + "thiserror 1.0.69", +] + +[[package]] +name = "codee" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9dbbdc4b4d349732bc6690de10a9de952bd39ba6a065c586e26600b6b0b91f5" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "collection_literals" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2550f75b8cfac212855f6b1885455df8eaee8fe8e246b647d69146142e016084" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "config" +version = "0.15.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85f248a4de22d204ceabc6299d89d2c70fbd7f09fea53c06c852369652d8139" +dependencies = [ + "convert_case 0.6.0", + "pathdiff", + "serde_core", + "toml", + "winnow", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "const-str" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f" + +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "const_str_slice_concat" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case_extras" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589c70f0faf8aa9d17787557d5eae854d7755cac50f5c3d12c81d3d57661cebb" +dependencies = [ + "convert_case 0.11.0", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "default-struct-builder" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0df63c21a4383f94bd5388564829423f35c316aed85dc4f8427aded372c7c0d" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive-where" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "drain_filter_polyfill" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "669a445ee724c5c69b1b06fe0b63e70a1c84bc9bb7d9696cd4f4e3ec45050408" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "either_of" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5060e0a4cbf26a87550792688ade88e6b8aec9208613631a7a363bda7bc2d4cd" +dependencies = [ + "paste", + "pin-project-lite", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1731451909bde27714eacba19c2566362a7f35224f52b153d3f42cf60f72472" + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "frontend" +version = "0.1.0" +dependencies = [ + "console_error_panic_hook", + "gloo-net", + "gloo-timers", + "js-sys", + "leptos 0.7.8", + "leptos-use", + "lucide-leptos", + "regex", + "serde", + "serde-wasm-bindgen", + "serde_json", + "shared-types", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-logger", + "web-sys", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "gloo-net" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils", + "http", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "gloo-utils" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "guardian" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e2ac29387b1aa07a1e448f7bb4f35b500787971e965b02842b900afa5c8f6f" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "html-escape" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" +dependencies = [ + "utf8-width", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "hydration_context" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35485b3dcbf7e044b8f28c73f04f13e7b509c2466fd10cb2a8a447e38f8a93a" +dependencies = [ + "futures", + "once_cell", + "or_poisoned", + "pin-project-lite", + "serde", + "throw_error 0.2.0", +] + +[[package]] +name = "hydration_context" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bbbeb23ee808258cef2c5585ff0dc8e41da21a8dde943f6b290da153a042a96" +dependencies = [ + "futures", + "or_poisoned", + "pin-project-lite", + "serde", + "throw_error 0.3.1", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "interpolator" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71dd52191aae121e8611f1e8dc3e324dd0dd1dee1e6dd91d10ee07a3cfb4d9d8" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leptos" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b8731cb00f3f0894058155410b95c8955b17273181d2bc72600ab84edd24f1" +dependencies = [ + "any_spawner 0.2.0", + "cfg-if", + "either_of", + "futures", + "hydration_context 0.2.1", + "leptos_config 0.7.8", + "leptos_dom 0.7.8", + "leptos_hot_reload 0.7.8", + "leptos_macro 0.7.9", + "leptos_server 0.7.8", + "oco_ref", + "or_poisoned", + "paste", + "reactive_graph 0.1.8", + "rustc-hash", + "send_wrapper", + "serde", + "serde_qs 0.13.0", + "server_fn 0.7.8", + "slotmap", + "tachys 0.1.9", + "thiserror 2.0.18", + "throw_error 0.2.0", + "typed-builder 0.20.1", + "typed-builder-macro 0.20.1", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "leptos" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "705e2951f3688e0c4f66bbb7a2702282782dcee716971dbd6209c2619d272479" +dependencies = [ + "any_spawner 0.3.0", + "cfg-if", + "either_of", + "futures", + "hydration_context 0.3.1", + "leptos_config 0.8.10", + "leptos_dom 0.8.8", + "leptos_hot_reload 0.8.6", + "leptos_macro 0.8.17", + "leptos_server 0.8.7", + "oco_ref", + "or_poisoned", + "paste", + "reactive_graph 0.2.14", + "rustc-hash", + "rustc_version", + "send_wrapper", + "serde", + "serde_json", + "serde_qs 0.15.0", + "server_fn 0.8.13", + "slotmap", + "tachys 0.2.18", + "thiserror 2.0.18", + "throw_error 0.3.1", + "typed-builder 0.23.2", + "typed-builder-macro 0.23.2", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm_split_helpers", + "web-sys", +] + +[[package]] +name = "leptos-use" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b50a99041c6685fdbca516bf6f90e75013e00a093d231c5f6a73b7b9dfff633" +dependencies = [ + "cfg-if", + "codee 0.2.0", + "cookie", + "default-struct-builder", + "futures-util", + "gloo-timers", + "js-sys", + "lazy_static", + "leptos 0.7.8", + "paste", + "send_wrapper", + "thiserror 2.0.18", + "unic-langid", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "leptos_config" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bae3e0ead5a7a814c8340eef7cb8b6cba364125bd8174b15dc9fe1b3cab7e03" +dependencies = [ + "config", + "regex", + "serde", + "thiserror 2.0.18", + "typed-builder 0.20.1", +] + +[[package]] +name = "leptos_config" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c06f751315bccc0d193fab302ac01d25bcfcd97474d4676440e7e3250dc3fc3" +dependencies = [ + "config", + "regex", + "serde", + "thiserror 2.0.18", + "typed-builder 0.23.2", +] + +[[package]] +name = "leptos_dom" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89d4eb263bd5a9e7c49f780f17063f15aca56fd638c90b9dfd5f4739152e87d" +dependencies = [ + "js-sys", + "or_poisoned", + "reactive_graph 0.1.8", + "send_wrapper", + "tachys 0.1.9", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "leptos_dom" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35742e9ed8f8aaf9e549b454c68a7ac0992536e06856365639b111f72ab07884" +dependencies = [ + "js-sys", + "or_poisoned", + "reactive_graph 0.2.14", + "send_wrapper", + "tachys 0.2.18", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "leptos_hot_reload" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e80219388501d99b246f43b6e7d08a28f327cdd34ba630a35654d917f3e1788e" +dependencies = [ + "anyhow", + "camino", + "indexmap", + "parking_lot", + "proc-macro2", + "quote", + "rstml", + "serde", + "syn", + "walkdir", +] + +[[package]] +name = "leptos_hot_reload" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d2a0f220c8a5ef3c51199dfb9cdd702bc0eb80d52fbe70c7890adfaaae8a4b1" +dependencies = [ + "anyhow", + "camino", + "indexmap", + "or_poisoned", + "proc-macro2", + "quote", + "rstml", + "serde", + "syn", + "walkdir", +] + +[[package]] +name = "leptos_macro" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e621f8f5342b9bdc93bb263b839cee7405027a74560425a2dabea9de7952b1fd" +dependencies = [ + "attribute-derive", + "cfg-if", + "convert_case 0.7.1", + "html-escape", + "itertools", + "leptos_hot_reload 0.7.8", + "prettyplease", + "proc-macro-error2", + "proc-macro2", + "quote", + "rstml", + "server_fn_macro 0.7.8", + "syn", + "uuid", +] + +[[package]] +name = "leptos_macro" +version = "0.8.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de6e8da9d4f1a7b74b447b317d590ebabb38709f588f7ee20564b773ccbcce" +dependencies = [ + "attribute-derive", + "cfg-if", + "convert_case 0.11.0", + "convert_case_extras", + "html-escape", + "itertools", + "leptos_hot_reload 0.8.6", + "prettyplease", + "proc-macro-error2", + "proc-macro2", + "quote", + "rstml", + "rustc_version", + "server_fn_macro 0.8.10", + "syn", + "uuid", +] + +[[package]] +name = "leptos_server" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66985242812ec95e224fb48effe651ba02728beca92c461a9464c811a71aab11" +dependencies = [ + "any_spawner 0.2.0", + "base64", + "codee 0.3.5", + "futures", + "hydration_context 0.2.1", + "or_poisoned", + "reactive_graph 0.1.8", + "send_wrapper", + "serde", + "serde_json", + "server_fn 0.7.8", + "tachys 0.1.9", +] + +[[package]] +name = "leptos_server" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da974775c5ccbb6bd64be7f53f75e8321542e28f21563a416574dbe4d5447eae" +dependencies = [ + "any_spawner 0.3.0", + "base64", + "codee 0.3.5", + "futures", + "hydration_context 0.3.1", + "or_poisoned", + "reactive_graph 0.2.14", + "send_wrapper", + "serde", + "serde_json", + "server_fn 0.8.13", + "tachys 0.2.18", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linear-map" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfae20f6b19ad527b550c223fddc3077a547fc70cda94b9b566575423fd303ee" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lucide-leptos" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cd884208fcbdffa621eb2b867e51a1ef9d016c599ccb195195aedf42fb29c1" +dependencies = [ + "leptos 0.8.20", +] + +[[package]] +name = "manyhow" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587" +dependencies = [ + "manyhow-macros", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "manyhow-macros" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "next_tuple" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60993920e071b0c9b66f14e2b32740a4e27ffc82854dcd72035887f336a09a28" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "oco_ref" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed0423ff9973dea4d6bd075934fdda86ebb8c05bdf9d6b0507067d4a1226371d" +dependencies = [ + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "or_poisoned" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c04f5d74368e4d0dfe06c45c8627c81bd7c317d52762d118fb9b3076f6420fd" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "proc-macro-utils" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071" +dependencies = [ + "proc-macro2", + "quote", + "smallvec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "version_check", + "yansi", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "quote-use" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9619db1197b497a36178cfc736dc96b271fe918875fbf1344c436a7e93d0321e" +dependencies = [ + "quote", + "quote-use-macros", +] + +[[package]] +name = "quote-use-macros" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82ebfb7faafadc06a7ab141a6f67bcfb24cb8beb158c6fe933f2f035afa99f35" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "reactive_graph" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a0ccddbc11a648bd09761801dac9e3f246ef7641130987d6120fced22515e6" +dependencies = [ + "any_spawner 0.2.0", + "async-lock", + "futures", + "guardian", + "hydration_context 0.2.1", + "or_poisoned", + "pin-project-lite", + "rustc-hash", + "send_wrapper", + "serde", + "slotmap", + "thiserror 2.0.18", + "web-sys", +] + +[[package]] +name = "reactive_graph" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00c5a025366836190c7030e883cc2bcd9e384ff555336e3c7954741ca411b177" +dependencies = [ + "any_spawner 0.3.0", + "async-lock", + "futures", + "guardian", + "hydration_context 0.3.1", + "indexmap", + "or_poisoned", + "paste", + "pin-project-lite", + "rustc-hash", + "rustc_version", + "send_wrapper", + "serde", + "slotmap", + "thiserror 2.0.18", + "web-sys", +] + +[[package]] +name = "reactive_stores" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aadc7c19e3a360bf19cd595d2dc8b58ce67b9240b95a103fbc1317a8ff194237" +dependencies = [ + "guardian", + "itertools", + "or_poisoned", + "paste", + "reactive_graph 0.1.8", + "reactive_stores_macro 0.1.8", + "rustc-hash", +] + +[[package]] +name = "reactive_stores" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c30fd35b7d299c591293bb69fed47a703eb2703b1cff0493e78b16ed007e5382" +dependencies = [ + "guardian", + "indexmap", + "itertools", + "or_poisoned", + "paste", + "reactive_graph 0.2.14", + "reactive_stores_macro 0.4.3", + "rustc-hash", + "send_wrapper", +] + +[[package]] +name = "reactive_stores_macro" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "221095cb028dc51fbc2833743ea8b1a585da1a2af19b440b3528027495bf1f2d" +dependencies = [ + "convert_case 0.7.1", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "reactive_stores_macro" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68072edd607edd30b9ebf57d984ba45d8ab8809e598d0f6046278373fb76a5a0" +dependencies = [ + "convert_case 0.11.0", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rstml" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61cf4616de7499fc5164570d40ca4e1b24d231c6833a88bff0fe00725080fd56" +dependencies = [ + "derive-where", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn", + "syn_derive", + "thiserror 2.0.18", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[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 = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +dependencies = [ + "futures-core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_qs" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd34f36fe4c5ba9654417139a9b3a20d2e1de6012ee678ad14d240c22c78d8d6" +dependencies = [ + "percent-encoding", + "serde", + "thiserror 1.0.69", +] + +[[package]] +name = "serde_qs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3faaf9e727533a19351a43cc5a8de957372163c7d35cc48c90b75cdda13c352" +dependencies = [ + "percent-encoding", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "server_fn" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d05a9e3fd8d7404985418db38c6617cc793a1a27f398d4fbc9dfe8e41b804e6" +dependencies = [ + "bytes", + "const_format", + "dashmap", + "futures", + "gloo-net", + "http", + "js-sys", + "once_cell", + "pin-project-lite", + "send_wrapper", + "serde", + "serde_json", + "serde_qs 0.13.0", + "server_fn_macro_default 0.7.8", + "thiserror 2.0.18", + "throw_error 0.2.0", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", + "xxhash-rust", +] + +[[package]] +name = "server_fn" +version = "0.8.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be8559dd05af1b5b7e363a150616589d5a88af5187273f7f331ba0dae8922812" +dependencies = [ + "base64", + "bytes", + "const-str", + "const_format", + "futures", + "gloo-net", + "http", + "js-sys", + "or_poisoned", + "pin-project-lite", + "rustc_version", + "rustversion", + "send_wrapper", + "serde", + "serde_json", + "serde_qs 0.15.0", + "server_fn_macro_default 0.8.5", + "thiserror 2.0.18", + "throw_error 0.3.1", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", + "xxhash-rust", +] + +[[package]] +name = "server_fn_macro" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "504b35e883267b3206317b46d02952ed7b8bf0e11b2e209e2eb453b609a5e052" +dependencies = [ + "const_format", + "convert_case 0.6.0", + "proc-macro2", + "quote", + "syn", + "xxhash-rust", +] + +[[package]] +name = "server_fn_macro" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1295b54815397d30d986b63f93cfd515fa86d5e528e0bb589ce9d530502f9e0f" +dependencies = [ + "const_format", + "convert_case 0.11.0", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "xxhash-rust", +] + +[[package]] +name = "server_fn_macro_default" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb8b274f568c94226a8045668554aace8142a59b8bca5414ac5a79627c825568" +dependencies = [ + "server_fn_macro 0.7.8", + "syn", +] + +[[package]] +name = "server_fn_macro_default" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63eb08f80db903d3c42f64e60ebb3875e0305be502bdc064ec0a0eab42207f00" +dependencies = [ + "server_fn_macro 0.8.10", + "syn", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shared-types" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb066a04799e45f5d582e8fc6ec8e6d6896040d00898eb4e6a835196815b219" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tachys" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f66c3b70c32844a6f1e2943c72a33ebb777ad6acbeb20d1329d62e3a7806d6ec" +dependencies = [ + "any_spawner 0.2.0", + "async-trait", + "const_str_slice_concat", + "drain_filter_polyfill", + "dyn-clone", + "either_of", + "futures", + "html-escape", + "indexmap", + "itertools", + "js-sys", + "linear-map", + "next_tuple", + "oco_ref", + "once_cell", + "or_poisoned", + "parking_lot", + "paste", + "reactive_graph 0.1.8", + "reactive_stores 0.1.8", + "rustc-hash", + "send_wrapper", + "slotmap", + "throw_error 0.2.0", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "tachys" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92ba81187437cc5df4281f2326a2e13cc81e8f96448292d1112388e2025ca66" +dependencies = [ + "any_spawner 0.3.0", + "async-trait", + "const_str_slice_concat", + "drain_filter_polyfill", + "either_of", + "erased", + "futures", + "html-escape", + "indexmap", + "itertools", + "js-sys", + "next_tuple", + "oco_ref", + "or_poisoned", + "paste", + "reactive_graph 0.2.14", + "reactive_stores 0.4.3", + "rustc-hash", + "rustc_version", + "send_wrapper", + "slotmap", + "throw_error 0.3.1", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "throw_error" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4ef8bf264c6ae02a065a4a16553283f0656bd6266fc1fcb09fd2e6b5e91427b" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "throw_error" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0ed6038fcbc0795aca7c92963ddda636573b956679204e044492d2b13c8f64" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "typed-builder" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd9d30e3a08026c78f246b173243cf07b3696d274debd26680773b6773c2afc7" +dependencies = [ + "typed-builder-macro 0.20.1", +] + +[[package]] +name = "typed-builder" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31aa81521b70f94402501d848ccc0ecaa8f93c8eb6999eb9747e72287757ffda" +dependencies = [ + "typed-builder-macro 0.23.2", +] + +[[package]] +name = "typed-builder-macro" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c36781cc0e46a83726d9879608e4cf6c2505237e263a8eb8c24502989cfdb28" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typed-builder-macro" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "tinystr", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8-width" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +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 = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-logger" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "074649a66bb306c8f2068c9016395fa65d8e08d2affcbf95acf3c24c3ab19718" +dependencies = [ + "log", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm_split_helpers" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab578aae2fe2916edaea06843187d50f87b0965622da0ceef648edca27b385ba" +dependencies = [ + "async-once-cell", + "wasm_split_macros", +] + +[[package]] +name = "wasm_split_macros" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e653af7ee4a9ef0fce481a9ec6f43cb78de20d0cdb4f4f5862e1dc6e407e6c8" +dependencies = [ + "base16", + "quote", + "sha2", + "syn", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xxhash-rust" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d93c89cdc2d3a63c3ec48ffe926931bdc069eafa8e4402fe6d8f790c9d1e576" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/services/frontend-leptos/Cargo.toml b/services/frontend-leptos/Cargo.toml new file mode 100644 index 0000000..3ff0eb7 --- /dev/null +++ b/services/frontend-leptos/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +resolver = "2" +members = ["shared-types", "frontend"] diff --git a/services/frontend-leptos/frontend/Cargo.toml b/services/frontend-leptos/frontend/Cargo.toml new file mode 100644 index 0000000..dc44509 --- /dev/null +++ b/services/frontend-leptos/frontend/Cargo.toml @@ -0,0 +1,63 @@ +[package] +name = "frontend" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +leptos = { version = "0.7", features = ["csr"] } +leptos-use = "0.14" +lucide-leptos = "3" +shared-types = { path = "../shared-types" } +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +js-sys = "0.3" +web-sys = { version = "0.3", features = [ + "WebSocket", + "MessageEvent", + "CloseEvent", + "ErrorEvent", + "CanvasRenderingContext2d", + "AudioContext", + "AudioBuffer", + "AudioBufferSourceNode", + "AudioDestinationNode", + "AudioNode", + "AudioProcessingEvent", + "MediaStreamAudioSourceNode", + "ScriptProcessorNode", + "Window", + "Document", + "Element", + "HtmlElement", + "HtmlSelectElement", + "KeyboardEvent", + "Storage", + "IntersectionObserver", + "ResizeObserver", + "Url", + "Headers", + "Request", + "RequestInit", + "RequestMode", + "Response", + "HtmlInputElement", + "HtmlAudioElement", + "HtmlCanvasElement", + "MediaDevices", + "MediaStream", + "MediaStreamConstraints", + "MediaStreamTrack", + "Navigator", + "console", +] } +gloo-net = "0.6" +gloo-timers = "0.3" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde-wasm-bindgen = "0.6" +wasm-logger = "0.2" +console_error_panic_hook = "0.1" +regex = "1" diff --git a/services/frontend-leptos/frontend/Trunk.toml b/services/frontend-leptos/frontend/Trunk.toml new file mode 100644 index 0000000..1ee94b8 --- /dev/null +++ b/services/frontend-leptos/frontend/Trunk.toml @@ -0,0 +1,7 @@ +[build] +target = "index.html" +dist = "dist" + +[serve] +port = 8080 +open = false diff --git a/services/frontend-leptos/frontend/index.html b/services/frontend-leptos/frontend/index.html new file mode 100644 index 0000000..bcc7ac8 --- /dev/null +++ b/services/frontend-leptos/frontend/index.html @@ -0,0 +1,21 @@ + + + + + + + IMPHNEN -- Discord Moderation + + + + + + + + + + + + + + diff --git a/services/frontend-leptos/frontend/public/.gitkeep b/services/frontend-leptos/frontend/public/.gitkeep new file mode 100644 index 0000000..2f48bcb --- /dev/null +++ b/services/frontend-leptos/frontend/public/.gitkeep @@ -0,0 +1 @@ +# Trunk copies this directory to dist/ diff --git a/services/frontend-leptos/frontend/src/api/auth.rs b/services/frontend-leptos/frontend/src/api/auth.rs new file mode 100644 index 0000000..8fbf9ae --- /dev/null +++ b/services/frontend-leptos/frontend/src/api/auth.rs @@ -0,0 +1,21 @@ +use crate::api::client::{request, ApiError}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize)] +struct LoginPayload { + password: String, +} + +#[derive(Deserialize)] +struct LoginResponse { + ok: bool, +} + +pub async fn login(password: &str) -> Result { + let payload = LoginPayload { + password: password.to_string(), + }; + let body = serde_json::to_string(&payload).unwrap(); + let resp: LoginResponse = request("POST", "/api/auth/login", Some(&body)).await?; + Ok(resp.ok) +} diff --git a/services/frontend-leptos/frontend/src/api/client.rs b/services/frontend-leptos/frontend/src/api/client.rs new file mode 100644 index 0000000..88a5de7 --- /dev/null +++ b/services/frontend-leptos/frontend/src/api/client.rs @@ -0,0 +1,134 @@ +use serde::de::DeserializeOwned; +use wasm_bindgen::prelude::*; +use web_sys::{Request, RequestInit, RequestMode, Headers, Response}; +use wasm_bindgen_futures::JsFuture; + +#[derive(Debug)] +pub struct ApiError { + pub message: String, + pub status_code: u16, +} + +impl std::fmt::Display for ApiError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "API error {}: {}", self.status_code, self.message) + } +} + +impl std::error::Error for ApiError {} + +fn get_base_url() -> String { + if let Some(window) = web_sys::window() { + let location = window.location(); + let protocol = location.protocol().unwrap_or_else(|_| "http:".to_string()); + let protocol = protocol.trim_end_matches(':'); + let host = location.host().unwrap_or_else(|_| "localhost:3001".to_string()); + format!("{}://{}", protocol, host) + } else { + "http://localhost:3001".to_string() + } +} + +fn get_auth_header() -> Option { + // Read password from sessionStorage + let storage = web_sys::window()?.local_storage().ok()??; + storage.get_item("admin-password").ok()? +} + +pub async fn request( + method: &str, + path: &str, + body: Option<&str>, +) -> Result { + let url = format!("{}{}", get_base_url(), path); + + let headers = Headers::new().map_err(|_| ApiError { + message: "Failed to create headers".to_string(), + status_code: 0, + })?; + + if let Some(password) = get_auth_header() { + headers.set("X-Admin-Password", &password).ok(); + } + + if body.is_some() { + headers.set("Content-Type", "application/json").ok(); + } + + let opts = RequestInit::new(); + opts.set_method(method); + opts.set_headers(&headers); + opts.set_mode(RequestMode::Cors); + + if let Some(json_body) = body { + opts.set_body(&JsValue::from_str(json_body)); + } + + let request = Request::new_with_str_and_init(&url, &opts).map_err(|e| ApiError { + message: format!("Failed to create request: {:?}", e), + status_code: 0, + })?; + + let window = web_sys::window().ok_or(ApiError { + message: "No window".to_string(), + status_code: 0, + })?; + + let resp_value = JsFuture::from(window.fetch_with_request(&request)) + .await + .map_err(|e| ApiError { + message: format!("Fetch failed: {:?}", e), + status_code: 0, + })?; + + let response: Response = resp_value.dyn_into().map_err(|_| ApiError { + message: "Invalid response".to_string(), + status_code: 0, + })?; + + let status = response.status(); + if status >= 400 { + let text = JsFuture::from( + response.text().map_err(|_| ApiError { + message: "Failed to read error body".to_string(), + status_code: status, + })? + ) + .await + .ok() + .and_then(|v| v.as_string()) + .unwrap_or_default(); + + return Err(ApiError { + message: text, + status_code: status, + }); + } + + let text = JsFuture::from( + response.text().map_err(|_| ApiError { + message: "Failed to read response body".to_string(), + status_code: status, + })? + ) + .await + .map_err(|_| ApiError { + message: "Failed to await response".to_string(), + status_code: status, + })? + .as_string() + .ok_or(ApiError { + message: "Response is not text".to_string(), + status_code: status, + })?; + + serde_json::from_str(&text).map_err(|e| ApiError { + message: format!("JSON parse error: {} — body: {}", e, &text[..text.len().min(200)]), + status_code: status, + }) +} + +pub async fn request_no_body(method: &str, path: &str) -> Result<(), ApiError> { + request::(method, path, None).await?; + Ok(()) +} diff --git a/services/frontend-leptos/frontend/src/api/dashboard.rs b/services/frontend-leptos/frontend/src/api/dashboard.rs new file mode 100644 index 0000000..622b356 --- /dev/null +++ b/services/frontend-leptos/frontend/src/api/dashboard.rs @@ -0,0 +1,63 @@ +use crate::api::client::{request, ApiError}; +use shared_types::dashboard::*; + +/// GET /api/dashboard/stats +pub async fn get_dashboard_stats() -> Result { + request("GET", "/api/dashboard/stats", None).await +} + +/// GET /api/dashboard/users?limit=&cursor=&search= +pub async fn get_dashboard_users( + limit: Option, + cursor: Option<&str>, + search: Option<&str>, +) -> Result { + let mut path = "/api/dashboard/users".to_string(); + let mut params = vec![]; + if let Some(l) = limit { params.push(format!("limit={}", l)); } + if let Some(c) = cursor { params.push(format!("cursor={}", c)); } + if let Some(s) = search { params.push(format!("search={}", s)); } + if !params.is_empty() { path.push_str(&format!("?{}", params.join("&"))); } + request("GET", &path, None).await +} + +#[derive(serde::Deserialize)] +pub struct PaginatedUsers { + pub data: Vec, + #[serde(rename = "nextCursor")] + pub next_cursor: Option, +} + +/// GET /api/dashboard/users/{userId} +pub async fn get_dashboard_user_detail(user_id: &str) -> Result { + request("GET", &format!("/api/dashboard/users/{}", user_id), None).await +} + +/// GET /api/dashboard/channels?limit=&cursor=&search=&guild_id= +pub async fn get_dashboard_channels( + limit: Option, + cursor: Option<&str>, + search: Option<&str>, + guild_id: Option<&str>, +) -> Result { + let mut path = "/api/dashboard/channels".to_string(); + let mut params = vec![]; + if let Some(l) = limit { params.push(format!("limit={}", l)); } + if let Some(c) = cursor { params.push(format!("cursor={}", c)); } + if let Some(s) = search { params.push(format!("search={}", s)); } + if let Some(g) = guild_id { params.push(format!("guild_id={}", g)); } + if !params.is_empty() { path.push_str(&format!("?{}", params.join("&"))); } + request("GET", &path, None).await +} + +#[derive(serde::Deserialize)] +pub struct PaginatedChannels { + pub data: Vec, + #[serde(rename = "nextCursor")] + pub next_cursor: Option, +} + +/// GET /api/dashboard/channels/{channelId} +pub async fn get_dashboard_channel_detail(channel_id: &str) -> Result { + request("GET", &format!("/api/dashboard/channels/{}", channel_id), None).await +} diff --git a/services/frontend-leptos/frontend/src/api/mascot.rs b/services/frontend-leptos/frontend/src/api/mascot.rs new file mode 100644 index 0000000..3b1d266 --- /dev/null +++ b/services/frontend-leptos/frontend/src/api/mascot.rs @@ -0,0 +1,21 @@ +use crate::api::client::{request, ApiError}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize)] +struct MascotChatRequest<'a> { + message: &'a str, +} + +#[derive(Debug, Deserialize)] +pub struct MascotChatResponse { + pub response: String, + pub timestamp: String, +} + +pub async fn send_mascot_message(message: &str) -> Result { + let body = serde_json::to_string(&MascotChatRequest { message }).map_err(|err| ApiError { + message: format!("Failed to serialize mascot request: {}", err), + status_code: 0, + })?; + request("POST", "/api/mascot/chat", Some(&body)).await +} diff --git a/services/frontend-leptos/frontend/src/api/messages.rs b/services/frontend-leptos/frontend/src/api/messages.rs new file mode 100644 index 0000000..dde8e3f --- /dev/null +++ b/services/frontend-leptos/frontend/src/api/messages.rs @@ -0,0 +1,57 @@ +use crate::api::client::{request, ApiError}; +use shared_types::message::{MessageRecord, PageResult}; + +/// GET /api/messages?guildId=&limit=&channelId=&cursor= +pub async fn get_messages( + guild_id: &str, + limit: Option, + channel_id: Option<&str>, + cursor: Option<&str>, +) -> Result, ApiError> { + let mut path = format!("/api/messages?guildId={}", guild_id); + if let Some(l) = limit { path.push_str(&format!("&limit={}", l)); } + if let Some(c) = channel_id { path.push_str(&format!("&channelId={}", c)); } + if let Some(c) = cursor { path.push_str(&format!("&cursor={}", c)); } + request("GET", &path, None).await +} + +/// GET /api/review?params +pub async fn get_review_messages( + guild_id: &str, + limit: Option, + channel_id: Option<&str>, +) -> Result, ApiError> { + let mut path = format!("/api/review?guildId={}", guild_id); + if let Some(l) = limit { path.push_str(&format!("&limit={}", l)); } + if let Some(c) = channel_id { path.push_str(&format!("&channelId={}", c)); } + request("GET", &path, None).await +} + +/// GET /api/messages/detail/{id} +pub async fn get_message_detail(id: &str) -> Result, ApiError> { + request("GET", &format!("/api/messages/detail/{}", id), None).await +} + +/// POST /api/messages/{id}/reanalyze +pub async fn reanalyze_message(id: &str) -> Result<(), ApiError> { + let _: serde_json::Value = request("POST", &format!("/api/messages/{}/reanalyze", id), Some("{}")).await?; + Ok(()) +} + +/// POST /api/messages/reanalyze-batch +pub async fn reanalyze_batch() -> Result { + #[derive(serde::Deserialize)] + struct BatchResp { ok: bool, count: u64 } + let resp: BatchResp = request("POST", "/api/messages/reanalyze-batch", Some("{}")).await?; + Ok(resp.count) +} + +/// GET /api/analysis/search?q=&limit= +pub async fn search_messages(query: &str, limit: Option) -> Result, ApiError> { + #[derive(serde::Deserialize)] + struct SearchResult { results: Vec } + let mut path = format!("/api/analysis/search?q={}", query); + if let Some(l) = limit { path.push_str(&format!("&limit={}", l)); } + let resp: SearchResult = request("GET", &path, None).await?; + Ok(resp.results) +} diff --git a/services/frontend-leptos/frontend/src/api/mod.rs b/services/frontend-leptos/frontend/src/api/mod.rs new file mode 100644 index 0000000..843970d --- /dev/null +++ b/services/frontend-leptos/frontend/src/api/mod.rs @@ -0,0 +1,7 @@ +pub mod client; +pub mod auth; +pub mod messages; +pub mod voice; +pub mod dashboard; +pub mod mascot; +pub mod recordings; diff --git a/services/frontend-leptos/frontend/src/api/recordings.rs b/services/frontend-leptos/frontend/src/api/recordings.rs new file mode 100644 index 0000000..7d05657 --- /dev/null +++ b/services/frontend-leptos/frontend/src/api/recordings.rs @@ -0,0 +1,20 @@ +use crate::api::client::{request, request_no_body, ApiError}; +use shared_types::recording::VoiceRecordingListResponse; + +/// GET /api/recordings?limit=&cursor= +pub async fn get_recordings( + limit: Option, + cursor: Option<&str>, +) -> Result { + let mut path = "/api/recordings".to_string(); + let mut params = vec![]; + if let Some(l) = limit { params.push(format!("limit={}", l)); } + if let Some(c) = cursor { params.push(format!("cursor={}", c)); } + if !params.is_empty() { path.push_str(&format!("?{}", params.join("&"))); } + request("GET", &path, None).await +} + +/// DELETE /api/recordings/{id} +pub async fn delete_recording(id: &str) -> Result<(), ApiError> { + request_no_body("DELETE", &format!("/api/recordings/{}", id)).await +} diff --git a/services/frontend-leptos/frontend/src/api/voice.rs b/services/frontend-leptos/frontend/src/api/voice.rs new file mode 100644 index 0000000..617132d --- /dev/null +++ b/services/frontend-leptos/frontend/src/api/voice.rs @@ -0,0 +1,82 @@ +use crate::api::client::{request, request_no_body, ApiError}; +use shared_types::voice::VoiceStatus; +use shared_types::media::MediaState; +use shared_types::guild::{Guild, Channel}; +use serde::Serialize; + +/// GET /api/guilds +pub async fn get_guilds() -> Result, ApiError> { + request("GET", "/api/guilds", None).await +} + +/// GET /api/guilds/{guildId}/voice-channels +pub async fn get_voice_channels(guild_id: &str) -> Result, ApiError> { + request("GET", &format!("/api/guilds/{}/voice-channels", guild_id), None).await +} + +/// GET /api/guilds/{guildId}/channels +pub async fn get_text_channels(guild_id: &str) -> Result, ApiError> { + request("GET", &format!("/api/guilds/{}/channels", guild_id), None).await +} + +/// GET /api/voice/status +pub async fn get_voice_status() -> Result { + request("GET", "/api/voice/status", None).await +} + +/// POST /api/voice/connect { guildId, channelId } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ConnectPayload { + guild_id: String, + channel_id: String, +} +pub async fn connect_voice(guild_id: &str, channel_id: &str) -> Result { + let body = serde_json::to_string(&ConnectPayload { + guild_id: guild_id.to_string(), + channel_id: channel_id.to_string(), + }).unwrap(); + request("POST", "/api/voice/connect", Some(&body)).await +} + +/// POST /api/voice/disconnect +pub async fn disconnect_voice() -> Result { + request("POST", "/api/voice/disconnect", Some("{}")).await +} + +/// GET /api/media/status +pub async fn get_media_status() -> Result { + request("GET", "/api/media/status", None).await +} + +/// POST /api/media/queue { source, mode } +#[derive(Serialize)] +struct MediaQueuePayload { + source: String, + mode: String, +} +pub async fn media_queue(source: &str, mode: &str) -> Result { + let body = serde_json::to_string(&MediaQueuePayload { + source: source.to_string(), + mode: mode.to_string(), + }).unwrap(); + request("POST", "/api/media/queue", Some(&body)).await +} + +/// POST /api/media/skip +pub async fn media_skip() -> Result { + request("POST", "/api/media/skip", Some("{}")).await +} + +/// POST /api/media/stop +pub async fn media_stop() -> Result { + request("POST", "/api/media/stop", Some("{}")).await +} + +/// POST /api/media/volume { volume } +#[derive(Serialize)] +struct VolumePayload { volume: f64 } +pub async fn media_volume(volume: f64) -> Result { + let body = serde_json::to_string(&VolumePayload { volume }).unwrap(); + request("POST", "/api/media/volume", Some(&body)).await +} diff --git a/services/frontend-leptos/frontend/src/app.css b/services/frontend-leptos/frontend/src/app.css new file mode 100644 index 0000000..ac1e117 --- /dev/null +++ b/services/frontend-leptos/frontend/src/app.css @@ -0,0 +1,1355 @@ +/* ── Design Tokens ────────────────────────────────────── */ +:root { + /* Brand colors */ + --color-primary: #23a1eb; + --color-primary-hover: #1d8fd1; + --color-secondary: #1877f2; + --color-tertiary: #5865f2; + + /* Surface colors (light) */ + --surface-base: #ffffff; + --surface-raised: #f8fafc; + --surface-overlay: #f1f5f9; + --surface-border: #e2e8f0; + --surface-hover: #f1f5f9; + + /* Text colors (light) */ + --text-primary: #0f172a; + --text-secondary: #475569; + --text-tertiary: #94a3b8; + --text-inverse: #ffffff; + + /* Semantic colors */ + --color-success: #22c55e; + --color-warning: #f59e0b; + --color-error: #ef4444; + --color-info: #3b82f6; + + /* AI semantic colors */ + --color-ai-flagged: #ef4444; + --color-ai-clean: #22c55e; + --color-ai-warn: #f59e0b; + --color-ai-pending: #94a3b8; + --color-ai-processing: #3b82f6; + --color-ai-error: #dc2626; + --color-ai-deleted: #6b7280; + + /* Shadows */ + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1); + --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1); + + /* Glows */ + --glow-primary: 0 0 20px rgba(35, 161, 235, 0.3); + --glow-error: 0 0 20px rgba(239, 68, 68, 0.3); + + /* Border radius */ + --radius-sm: 0.25rem; + --radius-md: 0.5rem; + --radius-lg: 0.75rem; + --radius-xl: 1rem; + --radius-full: 9999px; + + /* Spacing */ + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.25rem; + --space-6: 1.5rem; + --space-8: 2rem; + --space-10: 2.5rem; + --space-12: 3rem; + + /* Sidebar */ + --sidebar-width: 256px; + --sidebar-collapsed-width: 64px; + + /* Header */ + --header-height: 56px; + + /* Transitions */ + --transition-fast: 150ms ease; + --transition-normal: 250ms ease; + --transition-slow: 400ms ease; + + /* Z-index layers */ + --z-sidebar: 30; + --z-header: 20; + --z-overlay: 40; + --z-modal: 50; + --z-toast: 60; +} + +[data-theme="dark"] { + --surface-base: #0f172a; + --surface-raised: #1e293b; + --surface-overlay: #334155; + --surface-border: #334155; + --surface-hover: #1e293b; + + --text-primary: #f1f5f9; + --text-secondary: #94a3b8; + --text-tertiary: #64748b; + --text-inverse: #0f172a; + + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.4); + --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.4); + + --glow-primary: 0 0 20px rgba(35, 161, 235, 0.15); + --glow-error: 0 0 20px rgba(239, 68, 68, 0.15); +} + +/* ── Base ─────────────────────────────────────────────── */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 16px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + font-family: 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: var(--surface-base); + color: var(--text-primary); + line-height: 1.5; + min-height: 100vh; + overflow-x: hidden; +} + +a { + color: var(--color-primary); + text-decoration: none; +} +a:hover { + text-decoration: underline; +} + +img { + max-width: 100%; + height: auto; +} + +/* ── Scrollbar ────────────────────────────────────────── */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--color-primary); + border-radius: var(--radius-full); +} + +/* ── Layout Utilities ─────────────────────────────────── */ +.flex { display: flex; } +.flex-col { flex-direction: column; } +.flex-wrap { flex-wrap: wrap; } +.items-center { align-items: center; } +.items-start { align-items: flex-start; } +.items-end { align-items: flex-end; } +.justify-center { justify-content: center; } +.justify-between { justify-content: space-between; } +.justify-end { justify-content: flex-end; } +.gap-1 { gap: var(--space-1); } +.gap-2 { gap: var(--space-2); } +.gap-3 { gap: var(--space-3); } +.gap-4 { gap: var(--space-4); } +.gap-6 { gap: var(--space-6); } +.gap-8 { gap: var(--space-8); } + +.grid { display: grid; } +.grid-cols-2 { grid-template-columns: repeat(2, 1fr); } +.grid-cols-3 { grid-template-columns: repeat(3, 1fr); } + +.w-full { width: 100%; } +.h-full { height: 100%; } + +.truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.text-center { text-align: center; } +.text-sm { font-size: 0.875rem; } +.text-xs { font-size: 0.75rem; } +.text-lg { font-size: 1.125rem; } +.text-xl { font-size: 1.25rem; } +.text-2xl { font-size: 1.5rem; } +.text-3xl { font-size: 1.875rem; } + +.font-medium { font-weight: 500; } +.font-semibold { font-weight: 600; } +.font-bold { font-weight: 700; } + +.text-secondary { color: var(--text-secondary); } +.text-tertiary { color: var(--text-tertiary); } +.text-primary-color { color: var(--color-primary); } +.text-error { color: var(--color-error); } +.text-success { color: var(--color-success); } +.text-warning { color: var(--color-warning); } + +.ml-auto { margin-left: auto; } +.mr-2 { margin-right: var(--space-2); } +.mb-2 { margin-bottom: var(--space-2); } +.mb-4 { margin-bottom: var(--space-4); } +.mb-6 { margin-bottom: var(--space-6); } +.mt-2 { margin-top: var(--space-2); } +.mt-4 { margin-top: var(--space-4); } + +.p-2 { padding: var(--space-2); } +.p-3 { padding: var(--space-3); } +.p-4 { padding: var(--space-4); } +.p-6 { padding: var(--space-6); } +.px-3 { padding-left: var(--space-3); padding-right: var(--space-3); } +.px-4 { padding-left: var(--space-4); padding-right: var(--space-4); } +.py-2 { padding-top: var(--space-2); padding-bottom: var(--space-2); } + +.relative { position: relative; } +.absolute { position: absolute; } +.fixed { position: fixed; } +.overflow-auto { overflow: auto; } +.overflow-hidden { overflow: hidden; } +.overflow-y-auto { overflow-y: auto; } + +.hidden { display: none; } +.invisible { visibility: hidden; } +.cursor-pointer { cursor: pointer; } +.select-none { user-select: none; } + +/* ── Button ───────────────────────────────────────────── */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + padding: 0.5rem 1rem; + border: 1px solid transparent; + border-radius: var(--radius-md); + font-family: inherit; + font-size: 0.875rem; + font-weight: 500; + line-height: 1.25rem; + cursor: pointer; + transition: all var(--transition-fast); + white-space: nowrap; + user-select: none; +} +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.btn-primary { + background: var(--color-primary); + color: white; +} +.btn-primary:hover:not(:disabled) { + background: var(--color-primary-hover); +} +.btn-secondary { + background: var(--surface-raised); + color: var(--text-primary); + border-color: var(--surface-border); +} +.btn-secondary:hover:not(:disabled) { + background: var(--surface-hover); + border-color: var(--color-primary); +} +.btn-tertiary { + background: transparent; + color: var(--text-primary); +} +.btn-tertiary:hover:not(:disabled) { + background: var(--surface-hover); +} +.btn-destructive { + background: var(--color-error); + color: white; +} +.btn-destructive:hover:not(:disabled) { + background: #dc2626; +} +.btn-outline { + background: transparent; + border-color: var(--surface-border); + color: var(--text-primary); +} +.btn-outline:hover:not(:disabled) { + background: var(--surface-hover); + border-color: var(--color-primary); +} +.btn-ghost { + background: transparent; + color: var(--text-secondary); + border: none; +} +.btn-ghost:hover:not(:disabled) { + background: var(--surface-hover); + color: var(--text-primary); +} +.btn-link { + background: none; + border: none; + color: var(--color-primary); + padding: 0; + text-decoration: none; +} +.btn-link:hover:not(:disabled) { + text-decoration: underline; +} +.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; } +.btn-lg { padding: 0.75rem 1.5rem; font-size: 1rem; } +.btn-icon { padding: 0.5rem; } +.btn-icon-sm { padding: 0.25rem; } + +/* ── Badge ────────────────────────────────────────────── */ +.badge { + display: inline-flex; + align-items: center; + padding: 0.125rem 0.625rem; + border-radius: var(--radius-full); + font-size: 0.75rem; + font-weight: 500; + line-height: 1.25rem; + background: var(--surface-raised); + color: var(--text-secondary); + border: 1px solid var(--surface-border); +} +.badge-primary { background: color-mix(in srgb, var(--color-primary) 15%, transparent); color: var(--color-primary); border-color: transparent; } +.badge-success { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); border-color: transparent; } +.badge-warning { background: color-mix(in srgb, var(--color-warning) 15%, transparent); color: var(--color-warning); border-color: transparent; } +.badge-destructive { background: color-mix(in srgb, var(--color-error) 15%, transparent); color: var(--color-error); border-color: transparent; } +.badge-outline { background: transparent; border-color: var(--surface-border); } +.badge-info { background: color-mix(in srgb, var(--color-info) 15%, transparent); color: var(--color-info); border-color: transparent; } + +/* ── Card ─────────────────────────────────────────────── */ +.card { + background: var(--surface-base); + border: 1px solid var(--surface-border); + border-radius: var(--radius-lg); + overflow: hidden; +} +.card-elevated { + box-shadow: var(--shadow-md); +} +.card-bordered { + border-width: 1px; +} +.card-header { + padding: var(--space-4) var(--space-6); + border-bottom: 1px solid var(--surface-border); +} +.card-title { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); +} +.card-description { + font-size: 0.875rem; + color: var(--text-secondary); + margin-top: var(--space-1); +} +.card-content { + padding: var(--space-6); +} +.card-footer { + padding: var(--space-4) var(--space-6); + border-top: 1px solid var(--surface-border); +} + +/* ── Input ────────────────────────────────────────────── */ +.input { + display: block; + width: 100%; + padding: 0.5rem 0.75rem; + background: var(--surface-raised); + border: 1px solid var(--surface-border); + border-radius: var(--radius-md); + color: var(--text-primary); + font-family: inherit; + font-size: 0.875rem; + line-height: 1.25rem; + transition: all var(--transition-fast); + outline: none; +} +.input::placeholder { + color: var(--text-tertiary); +} +.input:focus { + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(35, 161, 235, 0.15); +} +.input-soft { + background: var(--surface-overlay); + border-color: transparent; +} +.input-soft:focus { + background: var(--surface-base); + border-color: var(--color-primary); +} +.input[aria-invalid="true"], +.input-error { + border-color: var(--color-error); + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15); +} + +.select { + display: block; + width: 100%; + padding: 0.5rem 2rem 0.5rem 0.75rem; + background: var(--surface-raised); + border: 1px solid var(--surface-border); + border-radius: var(--radius-md); + color: var(--text-primary); + font-family: inherit; + font-size: 0.875rem; + line-height: 1.25rem; + cursor: pointer; + transition: all var(--transition-fast); + outline: none; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.5rem center; +} +.select:focus { + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(35, 161, 235, 0.15); +} + +/* ── Skeleton ─────────────────────────────────────────── */ +.skeleton { + background: linear-gradient(90deg, var(--surface-overlay) 25%, var(--surface-hover) 50%, var(--surface-overlay) 75%); + background-size: 200% 100%; + animation: shimmer 1.5s ease-in-out infinite; + border-radius: var(--radius-md); +} +.skeleton-circular { + border-radius: 50%; +} +.skeleton-rectangular { + border-radius: 0; +} +@keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +/* ── Tabs ─────────────────────────────────────────────── */ +.tabs { display: flex; flex-direction: column; } +.tab-list { + display: flex; + border-bottom: 1px solid var(--surface-border); + gap: 0; +} +.tab-trigger { + padding: 0.5rem 1rem; + background: none; + border: none; + border-bottom: 2px solid transparent; + color: var(--text-secondary); + font-family: inherit; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all var(--transition-fast); +} +.tab-trigger:hover { + color: var(--text-primary); +} +.tab-trigger[aria-selected="true"] { + color: var(--color-primary); + border-bottom-color: var(--color-primary); +} +.tab-content { + padding-top: var(--space-4); +} + +/* ── ScrollArea ───────────────────────────────────────── */ +.scroll-area { + overflow: auto; + scrollbar-width: thin; + scrollbar-color: var(--color-primary) transparent; +} + +/* ── Status Badge ─────────────────────────────────────── */ +.status-badge { + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: 0.125rem 0.5rem; + border-radius: var(--radius-full); + font-size: 0.75rem; + font-weight: 500; +} +.status-badge::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; +} +.status-badge-flagged { + background: color-mix(in srgb, var(--color-ai-flagged) 15%, transparent); + color: var(--color-ai-flagged); +} +.status-badge-flagged::before { background: var(--color-ai-flagged); } +.status-badge-clean { + background: color-mix(in srgb, var(--color-ai-clean) 15%, transparent); + color: var(--color-ai-clean); +} +.status-badge-clean::before { background: var(--color-ai-clean); } +.status-badge-warn { + background: color-mix(in srgb, var(--color-ai-warn) 15%, transparent); + color: var(--color-ai-warn); +} +.status-badge-warn::before { background: var(--color-ai-warn); } +.status-badge-pending { + background: color-mix(in srgb, var(--color-ai-pending) 15%, transparent); + color: var(--color-ai-pending); +} +.status-badge-pending::before { background: var(--color-ai-pending); } +.status-badge-processing { + background: color-mix(in srgb, var(--color-ai-processing) 15%, transparent); + color: var(--color-ai-processing); +} +.status-badge-processing::before { + background: var(--color-ai-processing); + animation: pulse-dot 1.5s ease-in-out infinite; +} +.status-badge-error { + background: color-mix(in srgb, var(--color-ai-error) 15%, transparent); + color: var(--color-ai-error); +} +.status-badge-error::before { background: var(--color-ai-error); } +.status-badge-deleted { + background: color-mix(in srgb, var(--color-ai-deleted) 15%, transparent); + color: var(--color-ai-deleted); +} +.status-badge-deleted::before { background: var(--color-ai-deleted); } +.status-badge-none { + background: var(--surface-raised); + color: var(--text-tertiary); +} +.status-badge-none::before { background: var(--text-tertiary); } + +@keyframes pulse-dot { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +/* ── Toast ────────────────────────────────────────────── */ +.toast-container { + position: fixed; + top: var(--space-4); + right: var(--space-4); + z-index: var(--z-toast); + display: flex; + flex-direction: column; + gap: var(--space-2); + pointer-events: none; +} +.toast { + display: flex; + align-items: center; + gap: var(--space-3); + padding: 0.75rem 1rem; + background: var(--surface-base); + border: 1px solid var(--surface-border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + min-width: 300px; + max-width: 420px; + pointer-events: auto; + animation: slide-in-right var(--transition-normal) ease-out; +} +.toast-success { border-left: 3px solid var(--color-success); } +.toast-error { border-left: 3px solid var(--color-error); } +.toast-warning { border-left: 3px solid var(--color-warning); } +.toast-info { border-left: 3px solid var(--color-info); } +.toast-close { + margin-left: auto; + background: none; + border: none; + color: var(--text-tertiary); + cursor: pointer; + padding: 0.25rem; +} + +/* ── Modal ────────────────────────────────────────────── */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: var(--z-modal); + animation: fade-in var(--transition-fast) ease-out; +} +.modal-content { + background: var(--surface-base); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-xl); + max-width: 90vw; + max-height: 85vh; + overflow: auto; + animation: scale-in var(--transition-normal) ease-out; +} +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-4) var(--space-6); + border-bottom: 1px solid var(--surface-border); +} +.modal-body { + padding: var(--space-6); +} +.modal-footer { + display: flex; + justify-content: flex-end; + gap: var(--space-2); + padding: var(--space-4) var(--space-6); + border-top: 1px solid var(--surface-border); +} + +/* ── Empty State ──────────────────────────────────────── */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-12); + text-align: center; + color: var(--text-tertiary); +} +.empty-state-icon { + font-size: 2.5rem; + margin-bottom: var(--space-4); + opacity: 0.5; +} +.empty-state-title { + font-size: 1rem; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: var(--space-2); +} +.empty-state-description { + font-size: 0.875rem; + max-width: 300px; +} + +/* ── Animations ───────────────────────────────────────── */ +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} +@keyframes fade-in-up { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} +@keyframes scale-in { + from { opacity: 0; transform: scale(0.95); } + to { opacity: 1; transform: scale(1); } +} +@keyframes slide-in-right { + from { opacity: 0; transform: translateX(100%); } + to { opacity: 1; transform: translateX(0); } +} +@keyframes bar-pulse { + 0%, 100% { transform: scaleY(1); } + 50% { transform: scaleY(0.6); } +} +@keyframes glow-pulse { + 0%, 100% { box-shadow: var(--glow-primary); } + 50% { box-shadow: 0 0 30px rgba(35, 161, 235, 0.5); } +} +@keyframes notification-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} +@keyframes mascot-wiggle { + 0%, 100% { transform: rotate(0deg); } + 25% { transform: rotate(-5deg); } + 75% { transform: rotate(5deg); } +} + +.animate-fade-in { animation: fade-in var(--transition-normal) ease-out; } +.animate-fade-in-up { animation: fade-in-up var(--transition-normal) ease-out; } +.animate-scale-in { animation: scale-in var(--transition-normal) ease-out; } +.animate-slide-in-right { animation: slide-in-right var(--transition-normal) ease-out; } + +/* Reduced motion */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +/* ── Grid Pattern Background ──────────────────────────── */ +.grid-pattern { + background-image: + linear-gradient(var(--surface-border) 1px, transparent 1px), + linear-gradient(90deg, var(--surface-border) 1px, transparent 1px); + background-size: 40px 40px; +} + +/* ── Particle Background ──────────────────────────────── */ +.particle-bg { + position: fixed; + inset: 0; + overflow: hidden; + pointer-events: none; + z-index: 0; +} +.particle-orb { + position: absolute; + border-radius: 50%; + filter: blur(80px); + opacity: 0.15; +} +.particle-orb:nth-child(1) { + width: 500px; + height: 500px; + top: -100px; + right: -100px; + background: var(--color-primary); +} +.particle-orb:nth-child(2) { + width: 400px; + height: 400px; + bottom: -100px; + left: -100px; + background: var(--color-tertiary); +} +.particle-orb:nth-child(3) { + width: 300px; + height: 300px; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: var(--color-secondary); +} +@media (max-width: 768px) { + .particle-bg { display: none; } +} + +/* ── Messages Panel ────────────────────────────────────── */ +.messages-panel { + display: flex; + flex-direction: column; + gap: var(--space-6); +} + +.message-stats { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); +} + +.search-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); +} + +.filter-chip { + padding: 0.25rem 0.75rem; + border-radius: var(--radius-full); + font-size: 0.75rem; + font-weight: 500; + border: 1px solid var(--surface-border); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + transition: all var(--transition-fast); +} +.filter-chip:hover { + color: var(--text-primary); + background: var(--surface-overlay); +} +.filter-chip.active { + background: var(--color-primary); + color: white; + border-color: var(--color-primary); +} + +/* ── Message Card ──────────────────────────────────────── */ +.message-card { + background: var(--surface-base); + border: 1px solid var(--surface-border); + border-radius: var(--radius-lg); + overflow: hidden; +} + +.message-row { + padding: var(--space-2) 0; + border-bottom: 1px solid var(--surface-border); +} +.message-row:last-child { + border-bottom: none; +} + +.message-timestamp { + font-size: 0.6875rem; + color: var(--text-tertiary); + min-width: 48px; +} + +.custom-emoji { + display: inline-block; + height: 22px; + width: 22px; + vertical-align: middle; + object-fit: contain; +} + +/* ── AI Analysis Box ──────────────────────────────────── */ +.border-l-3 { border-left-width: 3px; } + +/* ── Image Grid ────────────────────────────────────────── */ +.image-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: var(--space-2); +} +.image-grid-item { + aspect-ratio: 1; + border-radius: var(--radius-md); + overflow: hidden; + cursor: pointer; + transition: opacity var(--transition-fast); +} +.image-grid-item:hover { + opacity: 0.85; +} +.image-grid-item img { + width: 100%; + height: 100%; + object-fit: cover; +} + +/* ── App Shell Polish ─────────────────────────────────── */ +.app-shell { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + height: 100vh; + background: color-mix(in srgb, var(--surface-base) 92%, transparent); +} + +.app-header { + height: var(--header-height); + border-bottom: 1px solid var(--surface-border); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + padding: 0 var(--space-4); + background: color-mix(in srgb, var(--surface-base) 82%, transparent); + backdrop-filter: blur(14px); +} + +.app-brand { + display: flex; + align-items: baseline; + gap: var(--space-2); + min-width: 0; +} + +.app-brand-mark { + font-weight: 800; + color: var(--color-primary); + letter-spacing: -0.025em; +} + +.app-brand-subtitle { + color: var(--text-secondary); + font-size: 0.875rem; +} + +.app-main { + flex: 1; + min-height: 0; + display: flex; +} + +.app-sidebar { + width: var(--sidebar-width); + flex-shrink: 0; + border-right: 1px solid var(--surface-border); + padding: var(--space-4); + background: color-mix(in srgb, var(--surface-base) 78%, transparent); +} + +.app-content { + flex: 1; + min-width: 0; + overflow: auto; + padding: var(--space-6); +} + +.theme-toggle { + width: 2.25rem; + height: 2.25rem; + border: 1px solid var(--surface-border); + border-radius: var(--radius-lg); + background: var(--surface-raised); + color: var(--text-secondary); + cursor: pointer; + font-size: 1rem; + transition: all var(--transition-fast); +} + +.theme-toggle:hover { + color: var(--color-primary); + border-color: var(--color-primary); + box-shadow: var(--glow-primary); +} + +/* ── Mascot Chatbot ───────────────────────────────────── */ +.mascot-widget { + position: fixed; + right: var(--space-6); + bottom: var(--space-6); + z-index: var(--z-toast); +} + +.mascot-launcher { + width: 3.5rem; + height: 3.5rem; + border: none; + border-radius: var(--radius-full); + background: linear-gradient(135deg, var(--color-primary), var(--color-secondary)); + color: white; + box-shadow: var(--shadow-xl), var(--glow-primary); + cursor: pointer; + font-size: 1.5rem; + transition: transform var(--transition-fast); +} + +.mascot-launcher:hover { + transform: translateY(-2px) scale(1.03); +} + +.mascot-panel { + width: min(24rem, calc(100vw - 2rem)); + height: min(32.5rem, calc(100vh - 7rem)); + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid var(--surface-border); + border-radius: var(--radius-xl); + background: var(--surface-base); + box-shadow: var(--shadow-xl); + animation: fade-in-up var(--transition-normal) ease-out; +} + +.mascot-panel.minimized { + height: 3.5rem; +} + +.mascot-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + padding: var(--space-3); + background: linear-gradient(135deg, var(--color-primary), var(--color-secondary)); + color: white; + flex-shrink: 0; +} + +.mascot-header-icon { + display: flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + border-radius: var(--radius-md); + background: rgba(255,255,255,0.18); +} + +.mascot-title { + font-size: 0.875rem; + font-weight: 700; + line-height: 1.15; +} + +.mascot-subtitle { + margin-top: 0.125rem; + font-size: 0.6875rem; + opacity: 0.78; +} + +.mascot-icon-button { + width: 1.75rem; + height: 1.75rem; + border: none; + border-radius: var(--radius-md); + background: transparent; + color: white; + cursor: pointer; +} + +.mascot-icon-button:hover { + background: rgba(255,255,255,0.18); +} + +.mascot-messages { + flex: 1; + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: var(--space-3); + padding: var(--space-4); +} + +.mascot-message-row { + display: flex; + gap: var(--space-2); +} + +.mascot-message-row.user { + justify-content: flex-end; +} + +.mascot-avatar { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 1.5rem; + height: 1.5rem; + border-radius: var(--radius-full); + background: var(--surface-overlay); + font-size: 0.875rem; +} + +.mascot-bubble { + max-width: 17.5rem; + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-xl); + font-size: 0.875rem; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.mascot-bubble.user { + color: white; + background: var(--color-primary); + border-bottom-right-radius: var(--radius-sm); +} + +.mascot-bubble.mascot { + color: var(--text-primary); + background: var(--surface-raised); + border-bottom-left-radius: var(--radius-sm); +} + +.mascot-bubble.typing { + display: flex; + gap: 0.25rem; + padding-top: 0.7rem; + padding-bottom: 0.7rem; +} + +.mascot-bubble.typing span { + width: 0.4rem; + height: 0.4rem; + border-radius: var(--radius-full); + background: var(--text-tertiary); + animation: notification-pulse 0.8s infinite; +} + +.mascot-bubble.typing span:nth-child(2) { animation-delay: 0.1s; } +.mascot-bubble.typing span:nth-child(3) { animation-delay: 0.2s; } + +.mascot-form { + display: flex; + gap: var(--space-2); + padding: var(--space-3); + border-top: 1px solid var(--surface-border); +} + +.mascot-input { + flex: 1; + min-width: 0; + border: 1px solid var(--surface-border); + border-radius: var(--radius-lg); + padding: var(--space-2) var(--space-3); + background: var(--surface-base); + color: var(--text-primary); + font-size: 0.875rem; +} + +.mascot-input:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(35, 161, 235, 0.14); +} + +.mascot-send { + width: 2.25rem; + border: none; + border-radius: var(--radius-lg); + background: var(--color-primary); + color: white; + cursor: pointer; +} + +.mascot-send:disabled, +.mascot-input:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +/* ── Dashboard Panel ──────────────────────────────────── */ +.dashboard-panel { + display: flex; + flex-direction: column; + gap: var(--space-6); +} + +.dashboard-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-4); +} + +.dashboard-stats-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--space-4); +} + +.dashboard-metric-card { + padding: var(--space-4); + overflow: hidden; +} + +.dashboard-metric-content { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} + +.dashboard-metric-label { + color: var(--text-secondary); + font-size: 0.75rem; + font-weight: 500; +} + +.dashboard-metric-value { + margin-top: var(--space-1); + font-size: 1.5rem; + font-weight: 700; + letter-spacing: -0.025em; +} + +.dashboard-metric-icon { + display: flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + border-radius: var(--radius-lg); + font-size: 1.25rem; +} + +.dashboard-wide-card { + grid-column: span 2; +} + +.dashboard-top-channels, +.dashboard-summary-list { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.dashboard-top-channel-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-md); + background: var(--surface-raised); + color: var(--text-secondary); + font-size: 0.875rem; +} + +.dashboard-moderation-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-3); +} + +.dashboard-queue-card { + padding: var(--space-4); + border: 1px solid var(--surface-border); + border-radius: var(--radius-lg); + background: var(--surface-raised); + text-align: center; +} + +.dashboard-queue-value { + font-size: 1.5rem; + font-weight: 700; +} + +.dashboard-queue-label { + margin-top: var(--space-1); + color: var(--text-secondary); + font-size: 0.75rem; +} + +.dashboard-list-card { + overflow: hidden; +} + +.dashboard-list-toolbar { + margin-bottom: var(--space-4); +} + +.dashboard-summary-row { + display: flex; + align-items: flex-start; + gap: var(--space-3); + padding: var(--space-3); + border: 1px solid var(--surface-border); + border-radius: var(--radius-lg); + background: var(--surface-base); + transition: background var(--transition-fast), border-color var(--transition-fast); +} + +.dashboard-summary-row:hover { + border-color: var(--color-primary); + background: var(--surface-raised); +} + +.dashboard-summary-avatar { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 2.5rem; + height: 2.5rem; + border-radius: var(--radius-full); + background: var(--surface-overlay); + color: var(--text-secondary); + font-weight: 700; + overflow: hidden; +} + +.dashboard-summary-avatar-img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.dashboard-channel-avatar { + color: var(--color-primary); + background: rgba(35, 161, 235, 0.1); +} + +.dashboard-summary-main { + min-width: 0; + flex: 1; +} + +.dashboard-summary-title { + color: var(--text-primary); + font-size: 0.875rem; + font-weight: 600; +} + +.dashboard-summary-text { + margin-top: var(--space-1); + color: var(--text-secondary); + font-size: 0.8125rem; + line-height: 1.45; +} + +.dashboard-summary-meta { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin-top: var(--space-2); + color: var(--text-tertiary); + font-size: 0.75rem; +} + +.dashboard-list-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-3); + padding: var(--space-10) var(--space-4); + text-align: center; +} + +.tone-primary { background: rgba(35, 161, 235, 0.1); color: var(--color-primary); } +.tone-success { background: rgba(34, 197, 94, 0.1); color: var(--color-success); } +.tone-error { background: rgba(239, 68, 68, 0.1); color: var(--color-error); } +.tone-warning { background: rgba(245, 158, 11, 0.1); color: var(--color-warning); } +.tone-info { background: rgba(59, 130, 246, 0.1); color: var(--color-info); } +.tone-secondary { background: var(--surface-overlay); color: var(--text-secondary); } + +/* ── Responsive ───────────────────────────────────────── */ +@media (max-width: 1024px) { + .dashboard-stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} +@media (max-width: 768px) { + .grid-cols-2 { grid-template-columns: 1fr; } + .grid-cols-3 { grid-template-columns: 1fr; } + .app-main { flex-direction: column; } + .app-sidebar { + width: 100%; + border-right: none; + border-bottom: 1px solid var(--surface-border); + padding: var(--space-3); + } + .app-sidebar .flex-col { + flex-direction: row; + overflow-x: auto; + } + .app-content { padding: var(--space-4); } + .app-brand-subtitle { display: none; } + .dashboard-stats-grid { grid-template-columns: 1fr; } + .dashboard-wide-card { grid-column: span 1; } + .dashboard-moderation-grid { grid-template-columns: 1fr; } + .mascot-widget { + right: var(--space-4); + bottom: var(--space-4); + } + .mascot-panel { + width: calc(100vw - 2rem); + height: min(32.5rem, calc(100vh - 5rem)); + } + .hide-mobile { display: none !important; } +} +@media (min-width: 769px) { + .hide-desktop { display: none !important; } +} diff --git a/services/frontend-leptos/frontend/src/app.rs b/services/frontend-leptos/frontend/src/app.rs new file mode 100644 index 0000000..a232869 --- /dev/null +++ b/services/frontend-leptos/frontend/src/app.rs @@ -0,0 +1,147 @@ +use leptos::prelude::*; +use shared_types::ui_state::Tab; +use crate::auth::AuthOverlay; +use crate::ws::context::WsContext; +use crate::features::dashboard::DashboardPanel; +use crate::features::live::LivePanel; +use crate::features::messages::MessagesPanel; +use crate::features::polish::{initial_theme, ThemeContext}; +use crate::features::polish::components::{MascotChatbot, ParticleBackground, ThemeToggle}; + +#[derive(Clone)] +pub struct AppConfig { + pub monitor_guild_id: Option, +} + +// ── Contexts ──────────────────────────────────────────── + +#[derive(Clone)] +pub struct AuthContext { + pub authenticated: RwSignal, + pub password: RwSignal, +} + +#[derive(Clone)] +pub struct UiContext { + pub active_tab: RwSignal, + pub selected_guild: RwSignal>, +} + +// ── App ───────────────────────────────────────────────── + +#[component] +pub fn App() -> impl IntoView { + // Initialize contexts + let auth = AuthContext { + authenticated: create_rw_signal(false), + password: create_rw_signal(String::new()), + }; + let ui = UiContext { + active_tab: create_rw_signal(Tab::Messages), + selected_guild: create_rw_signal(None), + }; + let theme = ThemeContext { + theme: create_rw_signal(initial_theme()), + }; + + provide_context(auth.clone()); + provide_context(ui.clone()); + provide_context(theme.clone()); + + let config = AppConfig { + monitor_guild_id: None, + }; + provide_context(config); + + let ws = WsContext::new("ws://localhost:3001/ws"); + provide_context(ws.clone()); + + // Auth check: redirect "live" tab to "messages" if not authenticated + create_effect(move |_| { + if !auth.authenticated.get() && ui.active_tab.get() == Tab::Live { + ui.active_tab.set(Tab::Messages); + } + }); + + { + let ws = ws.clone(); + let auth = auth.clone(); + create_effect(move |_| { + if auth.authenticated.get() { + ws.connect(); + } + }); + } + + view! { +
+ + + // Auth overlay + {move || (!auth.authenticated.get()).then(|| { + view! { } + })} + + // Main content +
+
+
+ "IMPHNEN" + "Discord Moderation" +
+ +
+ +
+ + +
+ {move || match ui.active_tab.get() { + Tab::Messages => view! { }.into_any(), + Tab::Live => view! { }.into_any(), + Tab::Dashboard => view! { }.into_any(), + }} +
+
+
+ + {move || auth.authenticated.get().then(|| view! { })} +
+ } +} + +// ── Tab Button Helper ─────────────────────────────────── + +#[component] +fn TabButton( + tab: Tab, + ui: UiContext, + label: &'static str, +) -> impl IntoView { + let active_tab = ui.active_tab.clone(); + let tab1 = tab.clone(); + let tab2 = tab.clone(); + let tab3 = tab.clone(); + let tab4 = tab; + + view! { + + } +} diff --git a/services/frontend-leptos/frontend/src/auth.rs b/services/frontend-leptos/frontend/src/auth.rs new file mode 100644 index 0000000..c718809 --- /dev/null +++ b/services/frontend-leptos/frontend/src/auth.rs @@ -0,0 +1,92 @@ +// services/frontend-leptos/frontend/src/auth.rs +use leptos::prelude::*; +use wasm_bindgen_futures::spawn_local; +use crate::app::AuthContext; +use crate::api::auth as auth_api; + +#[component] +pub fn AuthOverlay() -> impl IntoView { + let auth = use_context::().expect("AuthContext not provided"); + let (password, set_password) = create_signal(String::new()); + let (error, set_error) = create_signal(Option::::None); + let (loading, set_loading) = create_signal(false); + + let handle_submit = move |ev: leptos::ev::SubmitEvent| { + ev.prevent_default(); + let pwd = password.get(); + if pwd.is_empty() { + set_error.set(Some("Password diperlukan".to_string())); + return; + } + set_loading.set(true); + set_error.set(None); + + let auth_clone = auth.clone(); + let pwd_clone = pwd.clone(); + let set_loading_clone = set_loading.clone(); + let set_error_clone = set_error.clone(); + + spawn_local(async move { + match auth_api::login(&pwd_clone).await { + Ok(true) => { + // Store password in sessionStorage + if let Some(storage) = web_sys::window() + .and_then(|w| w.local_storage().ok()) + .flatten() + { + let _ = storage.set_item("admin-password", &pwd_clone); + } + auth_clone.authenticated.set(true); + auth_clone.password.set(pwd_clone); + } + Ok(false) => { + set_error_clone.set(Some("Login gagal — password salah".to_string())); + } + Err(e) => { + set_error_clone.set(Some(format!("Error: {}", e.message))); + } + } + set_loading_clone.set(false); + }); + }; + + view! { + + } +} diff --git a/services/frontend-leptos/frontend/src/features/dashboard/components/channel_summary_list.rs b/services/frontend-leptos/frontend/src/features/dashboard/components/channel_summary_list.rs new file mode 100644 index 0000000..5e39777 --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/dashboard/components/channel_summary_list.rs @@ -0,0 +1,137 @@ +use leptos::prelude::*; +use shared_types::dashboard::DashboardChannel; + +#[component] +pub fn ChannelSummaryList( + channels: Vec, + loading: bool, + error: Option, + search: String, + has_more: bool, + on_search_change: Box, + on_load_more: Box, + on_retry: Box, +) -> impl IntoView { + let search_cb = StoredValue::new(on_search_change); + let load_more_cb = StoredValue::new(on_load_more); + let retry_cb = StoredValue::new(on_retry); + + view! { +
+
+
"Kanal"
+

"Ringkasan aktivitas, flagged message, dan budaya kanal."

+
+
+
+ +
+ + {move || { + if loading && channels.is_empty() { + view! { }.into_any() + } else if let Some(err) = error.clone() { + view! { +
+
"⚠"
+

{err}

+ +
+ }.into_any() + } else if channels.is_empty() { + view! { +
+
"#"
+

"No channels found."

+
+ }.into_any() + } else { + view! { +
+ {channels.clone().into_iter().map(|channel| view! { + + }).collect::>()} +
+ }.into_any() + } + }} + + {move || { + (has_more && !loading).then(|| view! { +
+ +
+ }) + }} +
+
+ } +} + +#[component] +fn ChannelRow(channel: DashboardChannel) -> impl IntoView { + let name = channel.channel_name.clone().unwrap_or_else(|| channel.channel_id.clone()); + let summary = channel + .culture_summary + .clone() + .unwrap_or_else(|| format!("{} messages", format_number(channel.total_messages))); + let last_seen = channel.last_message_at.map(format_timestamp); + + view! { +
+
+ "#" +
+
+
{format!("#{}", name)}
+
{summary}
+
+ {format!("{} messages", format_number(channel.total_messages))} + {format!("{} flagged", format_number(channel.flagged_count))} + {last_seen.map(|t| view! { {format!("Last: {}", t)} })} +
+
+
+ } +} + +#[component] +fn ListSkeleton() -> impl IntoView { + view! { +
+ {(0..5).map(|_| view! { +
+
+
+
+
+
+
+ }).collect::>()} +
+ } +} + +fn format_number(value: u64) -> String { + let raw = value.to_string(); + let mut out = String::new(); + for (idx, ch) in raw.chars().rev().enumerate() { + if idx > 0 && idx % 3 == 0 { out.push(','); } + out.push(ch); + } + out.chars().rev().collect() +} + +fn format_timestamp(ts: i64) -> String { + let d = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64((ts as f64) * 1000.0)); + d.to_locale_date_string("en-US", &wasm_bindgen::JsValue::UNDEFINED).into() +} diff --git a/services/frontend-leptos/frontend/src/features/dashboard/components/mod.rs b/services/frontend-leptos/frontend/src/features/dashboard/components/mod.rs new file mode 100644 index 0000000..131d4ed --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/dashboard/components/mod.rs @@ -0,0 +1,7 @@ +pub mod stats_overview; +pub mod user_summary_list; +pub mod channel_summary_list; + +pub use stats_overview::StatsOverview; +pub use user_summary_list::UserSummaryList; +pub use channel_summary_list::ChannelSummaryList; diff --git a/services/frontend-leptos/frontend/src/features/dashboard/components/stats_overview.rs b/services/frontend-leptos/frontend/src/features/dashboard/components/stats_overview.rs new file mode 100644 index 0000000..657d507 --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/dashboard/components/stats_overview.rs @@ -0,0 +1,145 @@ +use leptos::prelude::*; +use shared_types::dashboard::{DashboardStats, TopChannel}; + +#[component] +pub fn StatsOverview( + stats: Option, + loading: bool, + error: Option, + on_retry: Box, +) -> impl IntoView { + let retry = StoredValue::new(on_retry); + + view! { +
+ {move || { + if loading { + view! { }.into_any() + } else if let Some(err) = error.clone() { + view! { +
+
"⚠"
+

{err}

+ +
+ }.into_any() + } else if let Some(stats) = stats.clone() { + view! { +
+ + + + + + + + + +
+
+
"Top Channels"
+
+
+ +
+
+ +
+
+
"Moderation Queue"
+
+
+
+ + + +
+
+
+
+ }.into_any() + } else { + view! { +
+

"No dashboard data available yet."

+
+ }.into_any() + } + }} +
+ } +} + +#[component] +fn MetricCard(label: &'static str, value: u64, icon: &'static str, tone: &'static str) -> impl IntoView { + view! { +
+
+
+
{label}
+
{format_number(value)}
+
+
{icon}
+
+
+ } +} + +#[component] +fn QueueMetric(label: &'static str, value: u64, tone: &'static str) -> impl IntoView { + view! { +
+
{format_number(value)}
+
{label}
+
+ } +} + +#[component] +fn TopChannels(channels: Vec) -> impl IntoView { + if channels.is_empty() { + return view! {

"No channel data yet."

}.into_any(); + } + + view! { +
+ {channels.into_iter().map(|ch| { + let name = ch.channel_name.unwrap_or_else(|| ch.channel_id.clone()); + view! { +
+ {format!("#{}", name)} + {format_number(ch.message_count)} +
+ } + }).collect::>()} +
+ }.into_any() +} + +#[component] +fn StatsSkeleton() -> impl IntoView { + view! { +
+ {(0..8).map(|_| view! { +
+
+
+
+ }).collect::>()} +
+ } +} + +fn format_number(value: u64) -> String { + let raw = value.to_string(); + let mut out = String::new(); + for (idx, ch) in raw.chars().rev().enumerate() { + if idx > 0 && idx % 3 == 0 { + out.push(','); + } + out.push(ch); + } + out.chars().rev().collect() +} diff --git a/services/frontend-leptos/frontend/src/features/dashboard/components/user_summary_list.rs b/services/frontend-leptos/frontend/src/features/dashboard/components/user_summary_list.rs new file mode 100644 index 0000000..997f8ac --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/dashboard/components/user_summary_list.rs @@ -0,0 +1,142 @@ +use leptos::prelude::*; +use shared_types::dashboard::DashboardUser; + +#[component] +pub fn UserSummaryList( + users: Vec, + loading: bool, + error: Option, + search: String, + has_more: bool, + on_search_change: Box, + on_load_more: Box, + on_retry: Box, +) -> impl IntoView { + let search_cb = StoredValue::new(on_search_change); + let load_more_cb = StoredValue::new(on_load_more); + let retry_cb = StoredValue::new(on_retry); + + view! { +
+
+
"Pengguna"
+

"Ringkasan aktivitas dan trust score pengguna."

+
+
+
+ +
+ + {move || { + if loading && users.is_empty() { + view! { }.into_any() + } else if let Some(err) = error.clone() { + view! { +
+
"⚠"
+

{err}

+ +
+ }.into_any() + } else if users.is_empty() { + view! { +
+
"👤"
+

"No users found."

+
+ }.into_any() + } else { + view! { +
+ {users.clone().into_iter().map(|user| view! { + + }).collect::>()} +
+ }.into_any() + } + }} + + {move || { + (has_more && !loading).then(|| view! { +
+ +
+ }) + }} +
+
+ } +} + +#[component] +fn UserRow(user: DashboardUser) -> impl IntoView { + let name = user.username.clone().unwrap_or_else(|| user.user_id.clone()); + let summary = user + .profile_summary + .clone() + .unwrap_or_else(|| format!("{} messages", format_number(user.total_messages))); + let trust = user.trust_score.map(|score| format!("Trust: {:.2}", score)); + let last_seen = user.last_message_at.map(format_timestamp); + + view! { +
+
+ {if let Some(url) = user.avatar_url.clone() { + view! { }.into_any() + } else { + view! { "👤" }.into_any() + }} +
+
+
{name}
+
{summary}
+
+ {format!("{} flagged", format_number(user.flagged_count))} + {trust.map(|t| view! { {t} })} + {last_seen.map(|t| view! { {format!("Last: {}", t)} })} +
+
+
+ } +} + +#[component] +fn ListSkeleton() -> impl IntoView { + view! { +
+ {(0..5).map(|_| view! { +
+
+
+
+
+
+
+ }).collect::>()} +
+ } +} + +fn format_number(value: u64) -> String { + let raw = value.to_string(); + let mut out = String::new(); + for (idx, ch) in raw.chars().rev().enumerate() { + if idx > 0 && idx % 3 == 0 { out.push(','); } + out.push(ch); + } + out.chars().rev().collect() +} + +fn format_timestamp(ts: i64) -> String { + let d = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64((ts as f64) * 1000.0)); + d.to_locale_date_string("en-US", &wasm_bindgen::JsValue::UNDEFINED).into() +} diff --git a/services/frontend-leptos/frontend/src/features/dashboard/mod.rs b/services/frontend-leptos/frontend/src/features/dashboard/mod.rs new file mode 100644 index 0000000..6aa086f --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/dashboard/mod.rs @@ -0,0 +1,223 @@ +pub mod components; + +use components::{ChannelSummaryList, StatsOverview, UserSummaryList}; +use leptos::prelude::*; +use shared_types::dashboard::{DashboardChannel, DashboardStats, DashboardUser}; +use std::sync::Arc; +use wasm_bindgen_futures::spawn_local; + +#[derive(Clone, PartialEq)] +enum DashboardTab { + Stats, + Users, + Channels, +} + +#[component] +pub fn DashboardPanel() -> impl IntoView { + let active_tab = RwSignal::new(DashboardTab::Stats); + + let stats = RwSignal::new(None::); + let stats_loading = RwSignal::new(false); + let stats_error = RwSignal::new(None::); + + let users = RwSignal::new(Vec::::new()); + let users_loading = RwSignal::new(false); + let users_error = RwSignal::new(None::); + let users_search = RwSignal::new(String::new()); + let users_cursor = RwSignal::new(None::); + + let channels = RwSignal::new(Vec::::new()); + let channels_loading = RwSignal::new(false); + let channels_error = RwSignal::new(None::); + let channels_search = RwSignal::new(String::new()); + let channels_cursor = RwSignal::new(None::); + + let fetch_stats: Arc = Arc::new(move || { + stats_loading.set(true); + stats_error.set(None); + spawn_local(async move { + match crate::api::dashboard::get_dashboard_stats().await { + Ok(data) => stats.set(Some(data)), + Err(err) => stats_error.set(Some(format!("Failed to load stats: {}", err))), + } + stats_loading.set(false); + }); + }); + + let fetch_users: Arc = Arc::new(move |reset: bool| { + if users_loading.get() { + return; + } + users_loading.set(true); + users_error.set(None); + + let cursor = if reset { None } else { users_cursor.get() }; + let search = users_search.get(); + spawn_local(async move { + let search_ref = (!search.trim().is_empty()).then_some(search.trim()); + match crate::api::dashboard::get_dashboard_users(Some(20), cursor.as_deref(), search_ref).await { + Ok(page) => { + if reset { + users.set(page.data); + } else { + let mut current = users.get(); + current.extend(page.data); + users.set(current); + } + users_cursor.set(page.next_cursor); + } + Err(err) => users_error.set(Some(format!("Failed to load users: {}", err))), + } + users_loading.set(false); + }); + }); + + let fetch_channels: Arc = Arc::new(move |reset: bool| { + if channels_loading.get() { + return; + } + channels_loading.set(true); + channels_error.set(None); + + let cursor = if reset { None } else { channels_cursor.get() }; + let search = channels_search.get(); + spawn_local(async move { + let search_ref = (!search.trim().is_empty()).then_some(search.trim()); + match crate::api::dashboard::get_dashboard_channels(Some(20), cursor.as_deref(), search_ref, None).await { + Ok(page) => { + if reset { + channels.set(page.data); + } else { + let mut current = channels.get(); + current.extend(page.data); + channels.set(current); + } + channels_cursor.set(page.next_cursor); + } + Err(err) => channels_error.set(Some(format!("Failed to load channels: {}", err))), + } + channels_loading.set(false); + }); + }); + + { + let fetch_stats = fetch_stats.clone(); + let fetch_users = fetch_users.clone(); + let fetch_channels = fetch_channels.clone(); + create_effect(move |_| { + fetch_stats(); + fetch_users(true); + fetch_channels(true); + }); + } + + view! { +
+
+
+

"Dashboard Guild"

+

+ "Pantau statistik, profil pengguna, dan aktivitas kanal komunitas IMPHNEN." +

+
+
+ +
+
+ + + +
+ +
+ +
+ +
+ +
+ +
+ +
+
+
+ } +} + +#[component] +fn DashboardTabButton( + tab: DashboardTab, + active_tab: RwSignal, + label: &'static str, + icon: &'static str, +) -> impl IntoView { + let tab_for_class = tab.clone(); + let tab_for_aria = tab.clone(); + let tab_for_click = tab; + + view! { + + } +} diff --git a/services/frontend-leptos/frontend/src/features/live/audio/mod.rs b/services/frontend-leptos/frontend/src/features/live/audio/mod.rs new file mode 100644 index 0000000..df0643c --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/live/audio/mod.rs @@ -0,0 +1,2 @@ +pub mod ring_buffer; +pub mod pcm_decoder; diff --git a/services/frontend-leptos/frontend/src/features/live/audio/pcm_decoder.rs b/services/frontend-leptos/frontend/src/features/live/audio/pcm_decoder.rs new file mode 100644 index 0000000..c977ebc --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/live/audio/pcm_decoder.rs @@ -0,0 +1,69 @@ +/// PCM Frame decoded from binary WebSocket data +/// Format: [u32 userId (4 bytes)][i16 samples (N bytes)] +pub struct PcmFrame { + pub user_id: u32, + pub samples: Vec, // Normalized to [-1.0, 1.0] +} + +/// Decode a binary WebSocket message into PCM frames +/// Returns None if data is too short or malformed +pub fn decode_pcm_frame(data: &[u8]) -> Option { + if data.len() < 4 { + return None; + } + + let user_id = u32::from_le_bytes([data[0], data[1], data[2], data[3]]); + let sample_bytes = &data[4..]; + let sample_count = sample_bytes.len() / 2; + + if sample_count == 0 { + return None; + } + + let samples = decode_i16_samples(sample_bytes); + Some(PcmFrame { user_id, samples }) +} + +/// Decode raw i16 PCM bytes to normalized f32 samples [-1.0, 1.0] +pub fn decode_i16_samples(data: &[u8]) -> Vec { + let count = data.len() / 2; + let mut out = Vec::with_capacity(count); + + for i in 0..count { + let offset = i * 2; + if offset + 1 < data.len() { + let sample = i16::from_le_bytes([data[offset], data[offset + 1]]); + out.push((sample as f32) / 32768.0); + } + } + + out +} + +/// Encode f32 samples [-1.0, 1.0] to base64 for WebSocket transmission +/// Uses JavaScript btoa for encoding +pub fn encode_samples_to_base64(samples: &[f32]) -> String { + // Convert f32 samples to i16 bytes + let mut bytes = Vec::with_capacity(samples.len() * 2); + for &sample in samples { + let clamped = sample.max(-1.0).min(1.0); + let int_sample = (clamped * 32767.0) as i16; + bytes.extend_from_slice(&int_sample.to_le_bytes()); + } + encode_bytes_base64(&bytes) +} + +/// Encode raw bytes to base64 using JavaScript's btoa +fn encode_bytes_base64(data: &[u8]) -> String { + // Build binary string for btoa + let binary: String = data.iter().map(|&b| b as char).collect(); + + // Call btoa from JavaScript via js_sys::eval + let js_code = format!("btoa('{}')", binary.replace('\'', "\\'")); + js_sys::eval(&js_code) + .ok() + .and_then(|r| r.as_string()) + .unwrap_or_default() +} + +use wasm_bindgen::prelude::*; diff --git a/services/frontend-leptos/frontend/src/features/live/audio/ring_buffer.rs b/services/frontend-leptos/frontend/src/features/live/audio/ring_buffer.rs new file mode 100644 index 0000000..a806b47 --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/live/audio/ring_buffer.rs @@ -0,0 +1,124 @@ +use std::sync::{Arc, Mutex}; + +/// AudioRingBuffer — Fixed-size circular buffer for real-time PCM streaming +/// Provides thread-safe write/read with automatic overwrite protection +pub struct AudioRingBuffer { + buffer: Vec, + capacity: usize, + write_pos: usize, + read_pos: usize, + available: usize, +} + +impl AudioRingBuffer { + /// Create a new ring buffer with given capacity (in samples) + pub fn new(capacity: usize) -> Self { + Self { + buffer: vec![0.0; capacity], + capacity, + write_pos: 0, + read_pos: 0, + available: 0, + } + } + + /// Write samples to the ring buffer. Overwrites oldest data if full. + pub fn write(&mut self, samples: &[f32]) { + let mut written = 0; + while written < samples.len() { + let chunk = (samples.len() - written).min(self.capacity - self.write_pos); + let src = &samples[written..written + chunk]; + let dest = &mut self.buffer[self.write_pos..self.write_pos + chunk]; + dest.copy_from_slice(src); + written += chunk; + self.write_pos = (self.write_pos + chunk) % self.capacity; + self.available = (self.available + chunk).min(self.capacity); + // If we overwrote unread data, advance read_pos + if self.available == self.capacity { + self.read_pos = self.write_pos; + } + } + } + + /// Read up to `max_samples` from the buffer. Returns the samples read. + pub fn read(&mut self, max_samples: usize) -> Vec { + let to_read = max_samples.min(self.available); + let mut out = Vec::with_capacity(to_read); + let mut remaining = to_read; + + while remaining > 0 { + let chunk = remaining.min(self.capacity - self.read_pos); + out.extend_from_slice(&self.buffer[self.read_pos..self.read_pos + chunk]); + remaining -= chunk; + self.read_pos = (self.read_pos + chunk) % self.capacity; + } + + self.available -= to_read; + out + } + + /// Number of samples available to read + pub fn available_samples(&self) -> usize { + self.available + } + + /// Clear all buffered data + pub fn clear(&mut self) { + self.write_pos = 0; + self.read_pos = 0; + self.available = 0; + } +} + +/// Thread-safe wrapper around AudioRingBuffer +pub struct SharedRingBuffer { + inner: Arc>, +} + +impl SharedRingBuffer { + pub fn new(capacity: usize) -> Self { + Self { + inner: Arc::new(Mutex::new(AudioRingBuffer::new(capacity))), + } + } + + pub fn write(&self, samples: &[f32]) { + if let Ok(mut guard) = self.inner.lock() { + guard.write(samples); + } + } + + pub fn read(&self, max_samples: usize) -> Vec { + if let Ok(mut guard) = self.inner.lock() { + guard.read(max_samples) + } else { + Vec::new() + } + } + + pub fn available_samples(&self) -> usize { + if let Ok(guard) = self.inner.lock() { + guard.available_samples() + } else { + 0 + } + } + + pub fn clear(&self) { + if let Ok(mut guard) = self.inner.lock() { + guard.clear(); + } + } + + pub fn clone_inner(&self) -> Arc> { + self.inner.clone() + } +} + +impl Clone for SharedRingBuffer { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + } + } +} diff --git a/services/frontend-leptos/frontend/src/features/live/components/active_speakers.rs b/services/frontend-leptos/frontend/src/features/live/components/active_speakers.rs new file mode 100644 index 0000000..5738c08 --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/live/components/active_speakers.rs @@ -0,0 +1,80 @@ +use leptos::prelude::*; +use shared_types::voice::ActiveSpeaker; + +/// ActiveSpeakers component for Leptos +/// Displays a real-time list of speaking users with avatar and status indicator +#[component] +pub fn ActiveSpeakers( + #[prop(optional)] speakers: RwSignal>, + #[prop(optional)] class: &'static str, +) -> impl IntoView { + let empty_state = move || speakers.get().is_empty(); + + view! { +
+ + +
+
+ {speaker.avatar.as_ref().map(|avatar_url| { + let url = avatar_url.clone(); + view! { + + } + })} +
+
+
+ {speaker.username.clone()} +
+
+ + + {move || if speaker.speaking { "Speaking" } else { "Silent" }} + +
+
+
+
+
+ } + } + > +
+
+
+ "🎤" +
+

+ "No active speakers" +

+
+
+ + + } +} diff --git a/services/frontend-leptos/frontend/src/features/live/components/audio_visualizer.rs b/services/frontend-leptos/frontend/src/features/live/components/audio_visualizer.rs new file mode 100644 index 0000000..21e928c --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/live/components/audio_visualizer.rs @@ -0,0 +1,69 @@ +use leptos::prelude::*; +use std::sync::{Arc, Mutex}; + +/// AudioVisualizer — Real-time 32-bar frequency spectrum display +/// Simplified implementation using CSS bars updated via signals +#[component] +pub fn AudioVisualizer( + #[prop(default = true)] _active: bool, + #[prop(optional)] pcm_data: Option>>>, +) -> impl IntoView { + let bars = create_rw_signal::>(vec![0.0; 32]); + + // Periodically update bars from PCM data + create_effect(move |_| { + if let Some(ref pcm_arc) = pcm_data { + if let Ok(pcm_vec) = pcm_arc.lock() { + let computed = compute_frequency_bands(&pcm_vec); + bars.update(|b| { + for i in 0..32 { + let target = computed.get(i).copied().unwrap_or(0.0).max(0.0).min(1.0); + b[i] = b[i] * 0.7 + target * 0.3; // Smooth decay + } + }); + } + } + }); + + view! { +
+
+ {(0..32).map(|i| { + view! { +
+ } + }).collect::>()} +
+
+ } +} + +/// Compute 32-band frequency spectrum from PCM samples +fn compute_frequency_bands(pcm_samples: &[f32]) -> Vec { + let mut bands = vec![0.0; 32]; + + if pcm_samples.is_empty() { + return bands; + } + + let samples_per_band = (pcm_samples.len() / 32).max(1); + + for (band_idx, band) in bands.iter_mut().enumerate() { + let start = band_idx * samples_per_band; + let end = ((band_idx + 1) * samples_per_band).min(pcm_samples.len()); + + if start < pcm_samples.len() { + let slice = &pcm_samples[start..end]; + let rms = (slice.iter().map(|s| s * s).sum::() / slice.len() as f32).sqrt(); + *band = rms.min(1.0); + } + } + + bands +} diff --git a/services/frontend-leptos/frontend/src/features/live/components/mic_level_meter.rs b/services/frontend-leptos/frontend/src/features/live/components/mic_level_meter.rs new file mode 100644 index 0000000..d2d45e1 --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/live/components/mic_level_meter.rs @@ -0,0 +1,84 @@ +use leptos::prelude::*; +use std::sync::{Arc, Mutex}; + +/// MicLevelMeter — Horizontal level indicator for microphone input +/// Displays 0-100% amplitude as a filling bar with smooth decay +#[component] +pub fn MicLevelMeter( + #[prop(default = true)] active: bool, + #[prop(optional)] pcm_data: Option>>>, + #[prop(optional)] label: Option<&'static str>, +) -> impl IntoView { + let level = create_rw_signal::(0.0); + let peak = create_rw_signal::(0.0); + + // Update level periodically + create_effect(move |_| { + if !active { + return; + } + + if let Some(ref pcm_arc) = pcm_data { + if let Ok(pcm_vec) = pcm_arc.lock() { + let current_level = compute_rms(&pcm_vec); + level.update(|l| { + *l = *l * 0.8 + current_level * 0.2; // Smooth decay + }); + peak.update(|p| { + *p = (*p * 0.95).max(current_level); // Peak hold with decay + }); + } + } + }); + + let level_percent = move || (level.get() * 100.0).min(100.0); + let peak_percent = move || (peak.get() * 100.0).min(100.0); + + // Determine color based on level + let level_color = move || { + let l = level.get(); + if l < 0.5 { + "bg-green-500" + } else if l < 0.75 { + "bg-yellow-500" + } else { + "bg-red-500" + } + }; + + view! { +
+ {label.map(|l| view! { + + })} +
+ {/* Main level bar */} +
+
+ {/* Peak indicator */} +
+
+ {/* Percentage display */} + + {move || format!("{}%", (level_percent() as u8))} + +
+
+ } +} + +/// Compute RMS (Root Mean Square) amplitude from PCM samples +/// Returns normalized value 0.0-1.0 +fn compute_rms(samples: &[f32]) -> f32 { + if samples.is_empty() { + return 0.0; + } + let mean_square = samples.iter().map(|s| s * s).sum::() / samples.len() as f32; + mean_square.sqrt() +} diff --git a/services/frontend-leptos/frontend/src/features/live/components/mod.rs b/services/frontend-leptos/frontend/src/features/live/components/mod.rs new file mode 100644 index 0000000..41dfc1a --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/live/components/mod.rs @@ -0,0 +1,19 @@ +pub mod voice_connection_card; +pub mod active_speakers; +pub mod audio_visualizer; +pub mod mic_level_meter; +pub mod now_playing; +pub mod music_sub_panel; +pub mod screen_sub_panel; +pub mod recordings_sub_panel; +pub mod waveform_player; + +pub use voice_connection_card::VoiceConnectionCard; +pub use active_speakers::ActiveSpeakers; +pub use audio_visualizer::AudioVisualizer; +pub use mic_level_meter::MicLevelMeter; +pub use now_playing::NowPlaying; +pub use music_sub_panel::MusicSubPanel; +pub use screen_sub_panel::ScreenSubPanel; +pub use recordings_sub_panel::RecordingsSubPanel; +pub use waveform_player::WaveformPlayer; diff --git a/services/frontend-leptos/frontend/src/features/live/components/music_sub_panel.rs b/services/frontend-leptos/frontend/src/features/live/components/music_sub_panel.rs new file mode 100644 index 0000000..deb0f23 --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/live/components/music_sub_panel.rs @@ -0,0 +1,56 @@ +use leptos::prelude::*; + +/// MusicSubPanel — Music playlist controls and URL input +#[component] +pub fn MusicSubPanel( + #[prop(optional)] on_queue: Option>, +) -> impl IntoView { + let (url_input, set_url_input) = create_signal::(String::new()); + let (is_loading, set_is_loading) = create_signal::(false); + + let handle_queue_click = move |_| { + let url = url_input.get().trim().to_string(); + if !url.is_empty() { + if let Some(ref cb) = on_queue { + set_is_loading.set(true); + cb(url.clone()); + set_url_input.set(String::new()); + set_is_loading.set(false); + } + } + }; + + view! { +
+
+
+ + + + + "Music" +
+
+
+
+ + +
+ +
+
+ } +} diff --git a/services/frontend-leptos/frontend/src/features/live/components/now_playing.rs b/services/frontend-leptos/frontend/src/features/live/components/now_playing.rs new file mode 100644 index 0000000..f18ba9b --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/live/components/now_playing.rs @@ -0,0 +1,104 @@ +use leptos::prelude::*; +use shared_types::media::MediaState; + +/// NowPlaying — Displays current media item and queue info +#[component] +pub fn NowPlaying( + #[prop(optional)] state: Option, + #[prop(optional)] on_skip: Option>, + #[prop(optional)] on_stop: Option>, +) -> impl IntoView { + let media_state = create_rw_signal::>(state); + + // Wrap callbacks in StoredValue for shareable non-Clone ownership in Leptos context + let skip_cb = StoredValue::new(on_skip); + let stop_cb = StoredValue::new(on_stop); + let has_skip = skip_cb.with_value(|v| v.is_some()); + let has_stop = stop_cb.with_value(|v| v.is_some()); + + view! { +
+
+
"Now Playing"
+
+
+ {move || { + media_state.get().map(|ms| { + let current = ms.current.as_ref().cloned(); + let queue_len = ms.queue.len(); + + view! { + <> + {current.map(|item| { + let title = item.title.clone().unwrap_or_else(|| "Unknown".to_string()); + let duration_ms = item.duration_ms.unwrap_or(0); + let duration_sec = duration_ms / 1000; + view! { +
+
+ {title} +
+
+ {format!("{}s", duration_sec)} +
+
+ {has_skip.then(|| { + view! { + + } + })} + {has_stop.then(|| { + view! { + + } + })} +
+
+ } + })} + + {(queue_len > 0).then(|| { + view! { +
+
+ {format!("Queue: {} item{}", queue_len, if queue_len == 1 { "" } else { "s" })} +
+
+ } + })} + + {(queue_len == 0).then(|| { + view! { +
+ "Queue is empty" +
+ } + })} + + } + }) + }} + + {move || { + media_state.get().is_none().then(|| { + view! { +
+ "No media connected" +
+ } + }) + }} +
+
+ } +} diff --git a/services/frontend-leptos/frontend/src/features/live/components/recordings_sub_panel.rs b/services/frontend-leptos/frontend/src/features/live/components/recordings_sub_panel.rs new file mode 100644 index 0000000..5e3d2e9 --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/live/components/recordings_sub_panel.rs @@ -0,0 +1,170 @@ +use leptos::prelude::*; +use shared_types::recording::VoiceRecording; +use crate::api::recordings::{get_recordings, delete_recording}; + +/// RecordingsSubPanel — Paginated list of voice recordings +#[component] +pub fn RecordingsSubPanel() -> impl IntoView { + let recordings = create_rw_signal::>(Vec::new()); + let loading = create_rw_signal::(false); + let has_more = create_rw_signal::(true); + let next_cursor = create_rw_signal::>(None); + + // Load recordings + let load = move |reset: bool| { + if loading.get() { return; } + loading.set(true); + + let cursor_val = if reset { None } else { next_cursor.get() }; + wasm_bindgen_futures::spawn_local({ + async move { + match get_recordings(Some(20), cursor_val.as_deref()).await { + Ok(resp) => { + if reset { + recordings.set(resp.items); + } else { + let mut current = recordings.get(); + current.extend(resp.items); + recordings.set(current); + } + has_more.set(resp.has_more); + next_cursor.set(resp.next_cursor); + } + Err(_) => { + if reset { + recordings.set(Vec::new()); + } + } + } + loading.set(false); + } + }); + }; + + // Load on mount + create_effect(move |_| { + load(true); + }); + + // Delete recording handler + let do_delete = move |id: String| { + wasm_bindgen_futures::spawn_local({ + let id = id.clone(); + async move { + let _ = delete_recording(&id).await; + recordings.update(|r| r.retain(|rec| rec.id != id)); + } + }); + }; + + view! { +
+
+
+ + + + + + + "Recordings" +
+

"Voice channel recordings from all sessions."

+
+
+ {move || { + let recs = recordings.get(); + if recs.is_empty() && !loading.get() { + view! { +
+

"No recordings yet."

+

"Join a voice channel to start recording."

+
+ }.into_any() + } else { + view! { +
+ {recs.iter().map(|rec| { + let id = rec.id.clone(); + let username = rec.username.clone(); + let channel_name = rec.channel_name.clone().unwrap_or_default(); + let created_at = format_timestamp(rec.created_at); + let has_url = rec.download_url.is_some(); + let url = rec.download_url.clone().unwrap_or_default(); + + view! { +
+
+
+ {username} +
+
+ {channel_name} + "·" + {format_size(rec.size_bytes)} + "·" + {created_at} +
+
+
+ {has_url.then(|| { + view! { + + "Download" + + } + })} + +
+
+ } + }).collect::>()} +
+ }.into_any() + } + }} + + {move || { + (has_more.get() && !loading.get()).then(|| { + view! { +
+ +
+ } + }) + }} +
+
+ } +} + +/// Format file size bytes to human readable +fn format_size(bytes: u64) -> String { + if bytes < 1024 { + format!("{} B", bytes) + } else if bytes < 1024 * 1024 { + format!("{:.1} KB", bytes as f64 / 1024.0) + } else { + format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) + } +} + +/// Format timestamp i64 to readable date +fn format_timestamp(ts: i64) -> String { + let d = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64((ts as f64) * 1000.0)); + d.to_locale_date_string("en-US", &wasm_bindgen::JsValue::UNDEFINED).into() +} diff --git a/services/frontend-leptos/frontend/src/features/live/components/screen_sub_panel.rs b/services/frontend-leptos/frontend/src/features/live/components/screen_sub_panel.rs new file mode 100644 index 0000000..7106b34 --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/live/components/screen_sub_panel.rs @@ -0,0 +1,84 @@ +use leptos::prelude::*; + +/// ScreenSubPanel — Screenshare controls +#[component] +pub fn ScreenSubPanel( + #[prop(optional)] on_start_stream: Option>, + #[prop(optional)] on_stop_stream: Option>, +) -> impl IntoView { + let (is_streaming, set_is_streaming) = create_signal::(false); + + let has_start = on_start_stream.is_some(); + let has_stop = on_stop_stream.is_some(); + + view! { +
+
+
+ + + + + + "Screenshare" +
+
+
+

+ "Stream your screen to the voice channel for everyone to see." +

+ +
+ {has_start.then(|| { + view! { + + } + })} + + {has_stop.then(|| { + view! { + + } + })} +
+ + {move || { + is_streaming.get().then(|| { + view! { +
+ "🔴 Live streaming..." +
+ } + }) + }} +
+
+ } +} + diff --git a/services/frontend-leptos/frontend/src/features/live/components/voice_connection_card.rs b/services/frontend-leptos/frontend/src/features/live/components/voice_connection_card.rs new file mode 100644 index 0000000..37e600e --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/live/components/voice_connection_card.rs @@ -0,0 +1,223 @@ +use leptos::prelude::*; +use wasm_bindgen::prelude::*; +use crate::features::live::hooks::use_voice_control::{use_voice_control, VoiceControlState}; + +/// VoiceConnectionCard component for Leptos +/// Renders guild and voice channel selectors with connect/disconnect controls +#[component] +pub fn VoiceConnectionCard( + #[prop(optional)] voice_state: Option, + #[prop(optional)] class: &'static str, +) -> impl IntoView { + let default_state = use_voice_control(); + let state = voice_state.unwrap_or(default_state); + + // Reactive signal for selected guild + let (selected_guild, set_selected_guild) = create_signal::(String::new()); + // Reactive signal for selected channel + let (selected_channel, set_selected_channel) = create_signal::(String::new()); + + // When guild is selected, load voice channels + create_effect(move |_| { + let guild_id = selected_guild.get(); + if !guild_id.is_empty() { + (state.load_voice_channels)(guild_id); + } + }); + + // Load guilds on mount + create_effect(move |_| { + (state.load_guilds)(); + }); + + let on_guild_change = move |ev: leptos::ev::Event| { + if let Some(target) = ev.target() { + if let Ok(select_el) = target.dyn_into::() { + set_selected_guild.set(select_el.value()); + } + } + }; + + let on_channel_change = move |ev: leptos::ev::Event| { + if let Some(target) = ev.target() { + if let Ok(select_el) = target.dyn_into::() { + set_selected_channel.set(select_el.value()); + } + } + }; + + let on_join_click = move |_| { + let guild_id = selected_guild.get(); + let channel_id = selected_channel.get(); + if !guild_id.is_empty() && !channel_id.is_empty() { + (state.join_voice)(guild_id, channel_id); + } + }; + + let on_disconnect_click = move |_| { + (state.leave_voice)(); + }; + + // Read signals for reactive rendering + let guilds = state.guilds; + let voice_channels = state.voice_channels; + let loading = state.loading; + let error = state.error; + let voice_status = state.voice_status; + + let is_connected = move || { + voice_status.get().map(|s| s.connected).unwrap_or(false) + }; + + let can_join = move || { + !selected_guild.get().is_empty() && !selected_channel.get().is_empty() && !loading.get() + }; + + let can_disconnect = move || { + is_connected() && !loading.get() + }; + + view! { +
+
+
+ + + +

"Voice Bridge"

+
+

+ "Join a Discord voice channel, listen, and transmit audio." +

+ + {/* Guild and Channel Selectors */} +
+
+ + +
+ +
+ + +
+
+ + {/* Error Display */} + {move || { + error.get().map(|err| { + view! { +
+ {err} +
+ } + }) + }} + + {/* Status Display */} + {move || { + voice_status.get().map(|status| { + let connected = status.connected; + let active_channel = status.active_channel_name.clone(); + view! { +
+
+ + {if connected { "Connected" } else { "Disconnected" }} + + {active_channel.map(|name| { + view! { + + {format!(" - {}", name)} + + } + })} +
+ } + }) + }} + + {/* Control Buttons */} +
+ + + + + {move || { + if loading.get() { + view! { + + "Loading..." + + }.into_any() + } else { + view! { <> }.into_any() + } + }} +
+
+
+ } +} diff --git a/services/frontend-leptos/frontend/src/features/live/components/waveform_player.rs b/services/frontend-leptos/frontend/src/features/live/components/waveform_player.rs new file mode 100644 index 0000000..2b7f5e2 --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/live/components/waveform_player.rs @@ -0,0 +1,97 @@ +use leptos::prelude::*; +use wasm_bindgen::prelude::*; + +/// WaveformPlayer — Audio player with waveform progress bar +#[component] +pub fn WaveformPlayer( + audio_url: String, + #[prop(default = "Recording".to_string())] title: String, +) -> impl IntoView { + let is_playing = create_rw_signal::(false); + let current_time = create_rw_signal::(0.0); + let duration = create_rw_signal::(0.0); + let audio_id = format!("audio_{}", &audio_url); + + // Clone audio_url for the audio element + let audio_src = audio_url.clone(); + let audio_src_for_id = audio_src.clone(); + + let toggle_play = move |_| { + let doc = web_sys::window().unwrap().document().unwrap(); + let audio_opt = doc.get_element_by_id(&format!("audio_{}", &audio_src_for_id)); + if let Some(audio_el) = audio_opt { + if let Ok(audio) = audio_el.dyn_into::() { + if is_playing.get() { + let _ = audio.pause(); + is_playing.set(false); + } else { + if audio.ended() { + audio.set_current_time(0.0); + } + if let Ok(_) = audio.play() { + is_playing.set(true); + } + } + } + } + }; + + let _ = audio_url; // Mark as used for the audio_id + + view! { +
+ + +
+
+
+ +
+ + +
+ {move || format_time(current_time.get())} + {title.clone()} +
+
+
+ } +} + +fn progress_pct(current: f64, dur: f64) -> f64 { + if dur > 0.0 { (current / dur * 100.0).min(100.0) } else { 0.0 } +} + +fn format_time(secs: f64) -> String { + if !secs.is_finite() || secs < 0.0 { return "00:00".to_string(); } + let total = secs as u32; + format!("{:02}:{:02}", total / 60, total % 60) +} diff --git a/services/frontend-leptos/frontend/src/features/live/hooks/mod.rs b/services/frontend-leptos/frontend/src/features/live/hooks/mod.rs new file mode 100644 index 0000000..28dcb67 --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/live/hooks/mod.rs @@ -0,0 +1,4 @@ +pub mod use_voice_control; +pub mod use_media_control; +pub mod use_audio_playback; +pub mod use_audio_transmit; diff --git a/services/frontend-leptos/frontend/src/features/live/hooks/use_audio_playback.rs b/services/frontend-leptos/frontend/src/features/live/hooks/use_audio_playback.rs new file mode 100644 index 0000000..ffa7b92 --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/live/hooks/use_audio_playback.rs @@ -0,0 +1,107 @@ +use leptos::prelude::*; +use std::sync::Arc; +use crate::features::live::audio::pcm_decoder::decode_pcm_frame; +use crate::features::live::audio::ring_buffer::SharedRingBuffer; + +/// AudioPlaybackState — Manages PCM audio playback from WebSocket binary frames +pub struct AudioPlaybackState { + /// Ring buffer for incoming PCM data + pub buffer: SharedRingBuffer, + /// Whether playback is active + pub active: RwSignal, + /// Volume level (0.0-1.0) + pub volume: RwSignal, +} + +/// Create and initialize audio playback state +pub fn use_audio_playback() -> AudioPlaybackState { + let buffer = SharedRingBuffer::new(44100 * 5); // 5 seconds at 44.1kHz + let active = create_rw_signal::(false); + let volume = create_rw_signal::(0.5); + + AudioPlaybackState { + buffer, + active, + volume, + } +} + +/// Process incoming binary data from WebSocket (PCM audio frame) +/// Format: [u32 userId (4 bytes)][i16 samples (N bytes)] +pub fn process_pcm_data(state: &AudioPlaybackState, data: Vec) { + if let Some(frame) = decode_pcm_frame(&data) { + state.buffer.write(&frame.samples); + } +} + +/// Start consuming the ring buffer and playing through AudioContext +pub fn start_playback(state: &AudioPlaybackState) { + if state.active.get() { + return; + } + state.active.set(true); + + let buffer = state.buffer.clone(); + let active = state.active; + + wasm_bindgen_futures::spawn_local(async move { + let ctx = match web_sys::AudioContext::new() { + Ok(ctx) => ctx, + Err(_) => { + active.set(false); + return; + } + }; + + let ctx_ref = &ctx; + let _ = ctx_ref.resume(); + + while active.get() { + let available = buffer.available_samples(); + if available >= 4410 { + // ~100ms worth at 44.1kHz + let samples = buffer.read(4410); + if !samples.is_empty() { + play_samples(&ctx, &samples); + } + } + let _ = gloo_timers::future::TimeoutFuture::new(50).await; + } + + let _ = ctx.close(); + }); +} + +/// Stop playback and clear buffer +pub fn stop_playback(state: &AudioPlaybackState) { + state.active.set(false); + state.buffer.clear(); +} + +/// Play a chunk of PCM samples through AudioContext using AudioBufferSourceNode +fn play_samples(ctx: &web_sys::AudioContext, samples: &[f32]) { + let frame_count = samples.len() as u32; + let Ok(audio_buffer) = ctx.create_buffer(1, frame_count, ctx.sample_rate()) else { + return; + }; + + // Write samples into the buffer channel + let Ok(channel_data) = audio_buffer.get_channel_data(0) else { + return; + }; + + let len = samples.len().min(channel_data.len() as usize); + if len == 0 { + return; + } + + // Copy samples directly to audio buffer channel + let _ = audio_buffer.copy_to_channel(&samples[..len], 0); + + // Create source and play + if let Ok(source) = ctx.create_buffer_source() { + source.set_buffer(Some(&audio_buffer)); + source.set_loop(false); + let _ = source.start(); + } +} diff --git a/services/frontend-leptos/frontend/src/features/live/hooks/use_audio_transmit.rs b/services/frontend-leptos/frontend/src/features/live/hooks/use_audio_transmit.rs new file mode 100644 index 0000000..b125f45 --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/live/hooks/use_audio_transmit.rs @@ -0,0 +1,79 @@ +use leptos::prelude::*; +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::spawn_local; +use web_sys::{MediaStream, MediaStreamConstraints, MediaStreamTrack}; + +/// AudioTransmitState — Manages microphone capture state +pub struct AudioTransmitState { + pub active: RwSignal, + pub stream: StoredValue>, +} + +/// Create microphone transmit state +pub fn use_audio_transmit() -> AudioTransmitState { + let active = create_rw_signal::(false); + let stream = StoredValue::new(None::); + AudioTransmitState { active, stream } +} + +/// Start microphone capture - requests getUserMedia and stores the stream +pub fn start_transmit(state: &AudioTransmitState) { + if state.active.get() { + return; + } + state.active.set(true); + + let constraints = MediaStreamConstraints::new(); + let _ = js_sys::Reflect::set( + &constraints, + &JsValue::from_str("audio"), + &JsValue::from_bool(true), + ); + + let window = match web_sys::window() { + Some(w) => w, + None => return, + }; + let media_devices = match window.navigator().media_devices() { + Ok(md) => md, + Err(_) => return, + }; + + let promise = match media_devices.get_user_media_with_constraints(&constraints) { + Ok(p) => p, + Err(_) => return, + }; + + // Clone signals before spawning async task to avoid reference escaping + let active_signal = state.active; + let stream_signal = state.stream; + + spawn_local(async move { + match wasm_bindgen_futures::JsFuture::from(promise).await { + Ok(val) => { + if let Ok(s) = val.dyn_into::() { + stream_signal.set_value(Some(s)); + } + } + Err(_) => { + active_signal.set(false); + } + } + }); +} + +/// Stop microphone transmission +pub fn stop_transmit(state: &AudioTransmitState) { + state.active.set(false); + state.stream.update_value(|s| { + if let Some(stream) = s.take() { + let tracks = stream.get_tracks(); + for i in 0..tracks.length() { + let track_val = tracks.get(i); + if let Ok(track) = track_val.dyn_into::() { + track.stop(); + } + } + } + }); +} diff --git a/services/frontend-leptos/frontend/src/features/live/hooks/use_media_control.rs b/services/frontend-leptos/frontend/src/features/live/hooks/use_media_control.rs new file mode 100644 index 0000000..bc9ca30 --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/live/hooks/use_media_control.rs @@ -0,0 +1,157 @@ +use leptos::prelude::*; +use shared_types::media::MediaState; +use crate::api::voice::{ + get_media_status, media_queue, media_skip, media_stop, media_volume, +}; +use std::sync::Arc; +use wasm_bindgen_futures::spawn_local; + +/// Callback type for enqueue +pub type EnqueueCallback = Arc; +/// Callback type for skip_track +pub type SkipTrackCallback = Arc; +/// Callback type for stop_playback +pub type StopPlaybackCallback = Arc; +/// Callback type for set_volume +pub type SetVolumeCallback = Arc; +/// Callback type for refresh +pub type RefreshCallback = Arc; + +/// State returned by use_media_control hook +#[derive(Clone)] +pub struct MediaControlState { + /// Current media playback state + pub media_state: RwSignal>, + /// Whether we're currently loading data + pub loading: RwSignal, + /// Last error message if any + pub error: RwSignal>, + /// Enqueue media (source URL, mode: "music" or "screen") + pub enqueue: EnqueueCallback, + /// Skip to next track + pub skip_track: SkipTrackCallback, + /// Stop all playback + pub stop_playback: StopPlaybackCallback, + /// Set volume level (0.0 - 1.0) + pub set_volume: SetVolumeCallback, + /// Refresh media status from server + pub refresh: RefreshCallback, +} + +/// Hook to manage media playback state and controls +pub fn use_media_control() -> MediaControlState { + // Core signals + let media_state_signal = RwSignal::new(None::); + let loading_signal = RwSignal::new(false); + let error_signal = RwSignal::new(None::); + + // Enqueue media + let enqueue_impl = Arc::new(move |source: String, mode: String| { + spawn_local({ + let source = source.clone(); + let mode = mode.clone(); + async move { + error_signal.set(None); + loading_signal.set(true); + + match media_queue(&source, &mode).await { + Ok(state) => { + media_state_signal.set(Some(state)); + loading_signal.set(false); + } + Err(e) => { + error_signal.set(Some(format!("Failed to enqueue media: {}", e))); + loading_signal.set(false); + } + } + } + }); + }); + + // Skip to next track + let skip_track_impl = Arc::new(move || { + spawn_local(async move { + error_signal.set(None); + loading_signal.set(true); + + match media_skip().await { + Ok(state) => { + media_state_signal.set(Some(state)); + loading_signal.set(false); + } + Err(e) => { + error_signal.set(Some(format!("Failed to skip track: {}", e))); + loading_signal.set(false); + } + } + }); + }); + + // Stop all playback + let stop_playback_impl = Arc::new(move || { + spawn_local(async move { + error_signal.set(None); + loading_signal.set(true); + + match media_stop().await { + Ok(state) => { + media_state_signal.set(Some(state)); + loading_signal.set(false); + } + Err(e) => { + error_signal.set(Some(format!("Failed to stop playback: {}", e))); + loading_signal.set(false); + } + } + }); + }); + + // Set volume level + let set_volume_impl = Arc::new(move |volume: f64| { + spawn_local(async move { + error_signal.set(None); + loading_signal.set(true); + + match media_volume(volume).await { + Ok(state) => { + media_state_signal.set(Some(state)); + loading_signal.set(false); + } + Err(e) => { + error_signal.set(Some(format!("Failed to set volume: {}", e))); + loading_signal.set(false); + } + } + }); + }); + + // Refresh media status + let refresh_impl = Arc::new(move || { + spawn_local(async move { + error_signal.set(None); + loading_signal.set(true); + + match get_media_status().await { + Ok(state) => { + media_state_signal.set(Some(state)); + loading_signal.set(false); + } + Err(e) => { + error_signal.set(Some(format!("Failed to refresh media status: {}", e))); + loading_signal.set(false); + } + } + }); + }); + + MediaControlState { + media_state: media_state_signal, + loading: loading_signal, + error: error_signal, + enqueue: enqueue_impl, + skip_track: skip_track_impl, + stop_playback: stop_playback_impl, + set_volume: set_volume_impl, + refresh: refresh_impl, + } +} diff --git a/services/frontend-leptos/frontend/src/features/live/hooks/use_voice_control.rs b/services/frontend-leptos/frontend/src/features/live/hooks/use_voice_control.rs new file mode 100644 index 0000000..8af2bca --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/live/hooks/use_voice_control.rs @@ -0,0 +1,177 @@ +use leptos::prelude::*; +use shared_types::guild::{Guild, Channel}; +use shared_types::voice::VoiceStatus; +use crate::api::voice::{ + get_guilds, get_voice_channels, get_text_channels, get_voice_status, + connect_voice, disconnect_voice, +}; +use std::sync::Arc; +use wasm_bindgen_futures::spawn_local; + +/// Callback type for join_voice +pub type JoinVoiceCallback = Arc; +/// Callback type for leave_voice +pub type LeaveVoiceCallback = Arc; +/// Callback type for load_guilds +pub type LoadGuildsCallback = Arc; +/// Callback type for load_voice_channels +pub type LoadVoiceChannelsCallback = Arc; +/// Callback type for load_text_channels +pub type LoadTextChannelsCallback = Arc; + +/// State returned by use_voice_control hook +#[derive(Clone)] +pub struct VoiceControlState { + /// List of available guilds + pub guilds: RwSignal>, + /// List of voice channels for current guild + pub voice_channels: RwSignal>, + /// List of text channels for current guild + pub text_channels: RwSignal>, + /// Current voice connection status + pub voice_status: RwSignal>, + /// Whether we're currently loading data + pub loading: RwSignal, + /// Last error message if any + pub error: RwSignal>, + /// Join a voice channel + pub join_voice: JoinVoiceCallback, + /// Leave the current voice channel + pub leave_voice: LeaveVoiceCallback, + /// Fetch list of guilds + pub load_guilds: LoadGuildsCallback, + /// Fetch voice channels for a guild + pub load_voice_channels: LoadVoiceChannelsCallback, + /// Fetch text channels for a guild + pub load_text_channels: LoadTextChannelsCallback, +} + +/// Hook to manage voice connection state and controls +pub fn use_voice_control() -> VoiceControlState { + // Core signals + let guilds_signal = RwSignal::new(Vec::::new()); + let voice_channels_signal = RwSignal::new(Vec::::new()); + let text_channels_signal = RwSignal::new(Vec::::new()); + let voice_status_signal = RwSignal::new(None::); + let loading_signal = RwSignal::new(false); + let error_signal = RwSignal::new(None::); + + // Join a voice channel + let join_voice_impl = Arc::new(move |guild_id: String, channel_id: String| { + spawn_local({ + let guild_id = guild_id.clone(); + let channel_id = channel_id.clone(); + async move { + error_signal.set(None); + loading_signal.set(true); + + match connect_voice(&guild_id, &channel_id).await { + Ok(status) => { + voice_status_signal.set(Some(status)); + loading_signal.set(false); + } + Err(e) => { + error_signal.set(Some(format!("Failed to join voice: {}", e))); + loading_signal.set(false); + } + } + } + }); + }); + + // Leave the current voice channel + let leave_voice_impl = Arc::new(move || { + spawn_local(async move { + error_signal.set(None); + loading_signal.set(true); + + match disconnect_voice().await { + Ok(status) => { + voice_status_signal.set(Some(status)); + loading_signal.set(false); + } + Err(e) => { + error_signal.set(Some(format!("Failed to leave voice: {}", e))); + loading_signal.set(false); + } + } + }); + }); + + // Fetch list of guilds + let load_guilds_impl = Arc::new(move || { + spawn_local(async move { + error_signal.set(None); + loading_signal.set(true); + + match get_guilds().await { + Ok(guilds) => { + guilds_signal.set(guilds); + loading_signal.set(false); + } + Err(e) => { + error_signal.set(Some(format!("Failed to load guilds: {}", e))); + loading_signal.set(false); + } + } + }); + }); + + // Fetch voice channels for a guild + let load_voice_channels_impl = Arc::new(move |guild_id: String| { + spawn_local({ + let guild_id = guild_id.clone(); + async move { + error_signal.set(None); + loading_signal.set(true); + + match get_voice_channels(&guild_id).await { + Ok(channels) => { + voice_channels_signal.set(channels); + loading_signal.set(false); + } + Err(e) => { + error_signal.set(Some(format!("Failed to load voice channels: {}", e))); + loading_signal.set(false); + } + } + } + }); + }); + + // Fetch text channels for a guild + let load_text_channels_impl = Arc::new(move |guild_id: String| { + spawn_local({ + let guild_id = guild_id.clone(); + async move { + error_signal.set(None); + loading_signal.set(true); + + match get_text_channels(&guild_id).await { + Ok(channels) => { + text_channels_signal.set(channels); + loading_signal.set(false); + } + Err(e) => { + error_signal.set(Some(format!("Failed to load text channels: {}", e))); + loading_signal.set(false); + } + } + } + }); + }); + + VoiceControlState { + guilds: guilds_signal, + voice_channels: voice_channels_signal, + text_channels: text_channels_signal, + voice_status: voice_status_signal, + loading: loading_signal, + error: error_signal, + join_voice: join_voice_impl, + leave_voice: leave_voice_impl, + load_guilds: load_guilds_impl, + load_voice_channels: load_voice_channels_impl, + load_text_channels: load_text_channels_impl, + } +} diff --git a/services/frontend-leptos/frontend/src/features/live/mod.rs b/services/frontend-leptos/frontend/src/features/live/mod.rs new file mode 100644 index 0000000..8c5c039 --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/live/mod.rs @@ -0,0 +1,65 @@ +pub mod components; +pub mod hooks; +pub mod audio; + +use leptos::prelude::*; +use crate::ws::context::WsContext; +use components::{ + VoiceConnectionCard, ActiveSpeakers, AudioVisualizer, + NowPlaying, MusicSubPanel, ScreenSubPanel, RecordingsSubPanel, +}; + +/// LivePanel — Composition shell for all voice and media components +#[component] +pub fn LivePanel() -> impl IntoView { + let ws = use_context::(); + + view! { +
+
+
+

"Voice & Media"

+

+ "Monitor voice channels, play music, share your screen, and browse recordings." +

+
+
+ + {/* Top row: Voice connection + speakers + visualizer */} +
+
+ +
+
+ +
+
+ + {/* Audio visualization */} +
+
+
"Audio Visualization"
+
+
+ +
+
+ + {/* Media controls: Now Playing + Music + Screen */} +
+
+ +
+
+ +
+
+ +
+
+ + {/* Recordings */} + +
+ } +} diff --git a/services/frontend-leptos/frontend/src/features/messages/components/image_grid.rs b/services/frontend-leptos/frontend/src/features/messages/components/image_grid.rs new file mode 100644 index 0000000..6c6fe21 --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/messages/components/image_grid.rs @@ -0,0 +1,75 @@ +use leptos::prelude::*; +use shared_types::message::MessageRecord; + +#[component] +pub fn ImageGrid( + messages: Vec, +) -> impl IntoView { + let mut seen_urls = std::collections::HashSet::new(); + let mut urls = Vec::new(); + + for msg in &messages { + if let Some(meta) = &msg.metadata { + // attachments with image MIME + if let Some(atts) = &meta.attachments { + for att in atts { + let is_img = att.content_type.as_deref().map(|ct| ct.starts_with("image/")).unwrap_or(false) + || att.name.to_lowercase().ends_with(".png") + || att.name.to_lowercase().ends_with(".jpg") + || att.name.to_lowercase().ends_with(".jpeg") + || att.name.to_lowercase().ends_with(".gif") + || att.name.to_lowercase().ends_with(".webp"); + if is_img && seen_urls.insert(att.url.clone()) { + urls.push(att.url.clone()); + } + } + } + // stickers + if let Some(stickers) = &meta.stickers { + for s in stickers { + if let Some(ref url) = s.url { + if seen_urls.insert(url.clone()) { + urls.push(url.clone()); + } + } + } + } + // embed images + if let Some(embeds) = &meta.embeds { + for e in embeds { + if let Some(ref img) = e.image { + if seen_urls.insert(img.url.clone()) { + urls.push(img.url.clone()); + } + } + if let Some(ref thumb) = e.thumbnail { + if seen_urls.insert(thumb.url.clone()) { + urls.push(thumb.url.clone()); + } + } + } + } + } + } + + if urls.is_empty() { + return view! { +
+ "No images found" +
+ }.into_any(); + } + + view! { +
+ {urls.into_iter().map(|url| { + let url_clone = url.clone(); + view! { + + attachment + + } + }).collect::>()} +
+ }.into_any() +} diff --git a/services/frontend-leptos/frontend/src/features/messages/components/message_card.rs b/services/frontend-leptos/frontend/src/features/messages/components/message_card.rs new file mode 100644 index 0000000..af88917 --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/messages/components/message_card.rs @@ -0,0 +1,416 @@ +use leptos::prelude::*; +use regex::Regex; +use shared_types::message::{AiSeverity, AiStatus, AttachmentRef, MessageRecord}; +use std::sync::{Arc, OnceLock}; +use wasm_bindgen::prelude::*; + +// ─── Helpers ────────────────────────────────────────────── + +fn custom_emoji_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"<(a)?:([a-zA-Z0-9_]+):(\d+)>").unwrap()) +} + +fn render_emojis(content: &str) -> Vec { + let re = custom_emoji_regex(); + let mut parts: Vec = Vec::new(); + let mut last = 0; + let content_owned = content.to_string(); + for cap in re.captures_iter(&content_owned) { + let m = cap.get(0).unwrap(); + if m.start() > last { + let text = content_owned[last..m.start()].to_string(); + parts.push(view! { {text} }.into_any()); + } + let animated = cap.get(1).is_some(); + let name = cap.get(2).map(|c| c.as_str()).unwrap_or("").to_string(); + let id = cap.get(3).map(|c| c.as_str()).unwrap_or("0").to_string(); + let ext = if animated { "gif" } else { "png" }; + let url = format!("https://cdn.discordapp.com/emojis/{}.{}?size=128", id, ext); + let title = format!(":{}:", name); + parts.push(view! { + name + }.into_any()); + last = m.end(); + } + if last < content_owned.len() { + let text = content_owned[last..].to_string(); + parts.push(view! { {text} }.into_any()); + } + parts +} + +fn time_ago(ts: i64) -> String { + let now = (js_sys::Date::now() / 1000.0) as i64; + let secs = if now > ts { now - ts } else { 0 }; + if secs < 60 { + format!("{}s ago", secs) + } else if secs < 3600 { + format!("{}m ago", secs / 60) + } else if secs < 86400 { + format!("{}h ago", secs / 3600) + } else { + let d = js_sys::Date::new(&JsValue::from_f64((ts as f64) * 1000.0)); + format!("{}", d.to_locale_date_string("en-US", &JsValue::UNDEFINED)) + } +} + +fn fmt_time(ts: i64) -> String { + let d = js_sys::Date::new(&JsValue::from_f64((ts as f64) * 1000.0)); + format!("{:02}:{:02}", d.get_hours(), d.get_minutes()) +} + +fn severity_class(s: &AiSeverity) -> &'static str { + match s { + AiSeverity::Critical | AiSeverity::High => "badge-destructive", + AiSeverity::Medium => "badge-warning", + AiSeverity::Low => "badge-info", + AiSeverity::None => "badge-outline", + } +} + +fn is_fallback(t: &str) -> bool { + t.starts_with("[Attachment:") + || t.starts_with("[Sticker:") + || t.starts_with("[Embed]") +} + +fn get_cats(raw: &Option>) -> Vec { + raw.as_ref() + .map(|v| v.iter().filter(|c| *c != "analysis_incomplete").cloned().collect()) + .unwrap_or_default() +} + +// ─── StatusBadgeInline ──────────────────────────────────── +#[component] +fn StatusBadgeInline(status: AiStatus) -> impl IntoView { + let (cl, icon_svg): (&'static str, AnyView) = match &status { + AiStatus::Clean => ("status-badge-clean", view! { }.into_any()), + AiStatus::Flagged => ("status-badge-flagged", view! { }.into_any()), + AiStatus::Error => ("status-badge-error", view! { }.into_any()), + AiStatus::Pending => ("status-badge-pending", view! { }.into_any()), + AiStatus::Processing => ("status-badge-processing", view! { }.into_any()), + AiStatus::Warn => ("status-badge-warn", view! { }.into_any()), + }; + view! { + + {icon_svg} + {format!("{:?}", status)} + + } +} + +// ─── MessageRow ─────────────────────────────────────────── +#[component] +pub fn MessageRow( + message: MessageRecord, + on_reanalyze: Arc, +) -> impl IntoView { + let cats = get_cats(&message.ai_categories); + let conf = message.ai_confidence.or(message.ai_moderation_score); + let display = message.edited_content.as_deref().unwrap_or(&message.content); + let show = !display.is_empty() && !is_fallback(display); + let ai_st = message.ai_status.clone().unwrap_or(AiStatus::Pending); + + let analysis_summary = { + let mut p = cats.iter().take(3).cloned().collect::>().join(", "); + if cats.len() > 3 { + p = format!("{} +{} more", p, cats.len() - 3); + } + if !p.is_empty() { p.push_str(" · "); } + p.push_str(&format!("{}% conf", conf.map(|c| (c * 100.0) as u8).unwrap_or(0))); + p + }; + + // Attachments + let all_atts = message.metadata.as_ref() + .and_then(|m| m.attachments.as_ref()).cloned().unwrap_or_default(); + let imgs: Vec = all_atts.iter().filter(|a| { + a.content_type.as_deref().map(|ct| ct.starts_with("image/")).unwrap_or(false) + || a.name.to_lowercase().ends_with(".png") + || a.name.to_lowercase().ends_with(".jpg") + || a.name.to_lowercase().ends_with(".jpeg") + || a.name.to_lowercase().ends_with(".gif") + || a.name.to_lowercase().ends_with(".webp") + }).cloned().collect(); + let vids: Vec = all_atts.iter().filter(|a| { + a.content_type.as_deref().map(|ct| ct.starts_with("video/")).unwrap_or(false) + || a.name.to_lowercase().ends_with(".mp4") + || a.name.to_lowercase().ends_with(".webm") + || a.name.to_lowercase().ends_with(".mov") + }).cloned().collect(); + + let stickers = message.metadata.as_ref() + .and_then(|m| m.stickers.as_ref()).cloned().unwrap_or_default(); + + let reanalyze_id = message.id.clone(); + let on_click_re = move |_| on_reanalyze(reanalyze_id.clone()); + + view! { +
+ {/* Header */} +
+ + {fmt_time(message.created_at)} + + {message.edited_at.is_some().then(|| view! { + + "✎ edited" + + })} + {message.deleted_at.is_some().then(|| view! { + + "🗑 deleted" + + })} +
+ + {message.ai_severity.as_ref().filter(|s| **s != AiSeverity::None).map(|sev| view! { + {format!("{:?}", sev)} + })} +
+
+ + {/* Reply - field removed from MessageRecord */} + + {/* Forward - field removed from MessageRecord */} + + {/* Crosspost - field removed from MessageRecord */} + + {/* Content */} + {show.then(|| { + let rendered = render_emojis(display); + let cls_str = if message.deleted_at.is_some() { "text-secondary/60" } else { "" }; + let class_str = format!("whitespace-pre-wrap break-words text-sm leading-6 {}", cls_str); + view! { +

+ {rendered.into_iter().collect::>()} +

+ } + })} + + {/* Stickers */} + {(!stickers.is_empty()).then(|| view! { +
+ {stickers.iter().map(|s| { + let url_owned = s.url.clone().unwrap_or_default(); + let name_owned = s.name.clone().unwrap_or_default(); + let has_url = !url_owned.is_empty(); + view! { +
+ {if has_url { + view! { + name_owned + }.into_any() + } else { + view! { +
+ "😊" +
+ }.into_any() + }} +
+ } + }).collect::>()} +
+ })} + + {/* Images */} + {if !imgs.is_empty() { + let imgs_local = imgs.clone(); + let images_view = imgs_local.iter().take(4).map(|a| { + let url1 = a.url.clone(); + let url2 = a.url.clone(); + let name1 = a.name.clone(); + view! { + + name1 + + } + }).collect::>(); + let overflow = if imgs.len() > 4 { + let extra = imgs.len() - 4; + view! { +
+ {"+"} {extra} "🖼" +
+ }.into_any() + } else { + view! {}.into_any() + }; + view! { +
+ {images_view} + {overflow} +
+ }.into_any() + } else { + view! {}.into_any() + }} + + {/* Videos */} + {if !vids.is_empty() { + let vids_local = vids.clone(); + let videos_view = vids_local.iter().take(4).map(|a| { + let url = a.url.clone(); + view! { + + } + }).collect::>(); + let overflow = if vids.len() > 4 { + let extra = vids.len() - 4; + view! { +
+ {"+"} {extra} "▶" +
+ }.into_any() + } else { + view! {}.into_any() + }; + view! { +
+ {videos_view} + {overflow} +
+ }.into_any() + } else { + view! {}.into_any() + }} + + {/* Categories */} + {if !cats.is_empty() { + let cats_local = cats.clone(); + view! { +
+ {cats_local.iter().map(|c| view! { + {c.clone()} + }).collect::>()} +
+ }.into_any() + } else { + view! {}.into_any() + }} + + {/* AI Analysis */} + {message.ai_analysis.as_ref().map(|analysis| { + let border_str = if ai_st == AiStatus::Flagged { "border-l-3 bg-warning/5" } else { "border-l-3 bg-success/5" }; + let icon = if ai_st == AiStatus::Flagged { "🚨" } else { "ℹ️" }; + let analysis_summary_str = analysis_summary.clone(); + let analysis_str = analysis.clone(); + view! { +
+
+ {icon} +
+ {analysis_summary_str} +
{analysis_str}
+
+
+
+ } + })} + + {/* Error */} + {message.ai_error.as_ref().map(|e| { + let error_str = e.clone(); + view! { +
+ "AI error: "{error_str} +
+ } + })} + + {/* Re-analyze */} +
+ + {(ai_st == AiStatus::Error).then(|| view! { + "Click to retry" + })} +
+
+ } +} + +// ─── MessageCard ────────────────────────────────────────── +#[component] +pub fn MessageCard( + messages: Vec, + on_reanalyze: Arc, +) -> impl IntoView { + let first = &messages[0]; + let has_multi = messages.len() > 1; + let deleted = first.deleted_at.is_some(); + let avatar = first.avatar_url.clone() + .unwrap_or_else(|| "https://cdn.discordapp.com/embed/avatars/0.png".into()); + let loc_label = first.metadata.as_ref().and_then(|m| m.channel.as_ref()).map(|c| { + if let Some(ref tn) = c.thread_name { + format!("# {} › {}", c.channel_name.as_deref().unwrap_or("?"), tn) + } else { + format!("# {}", c.channel_name.as_deref().unwrap_or("?")) + } + }); + let card_cls = if deleted { "border-destructive/20 opacity-60" } else { "" }; + + view! { +
+
+ +
+
+ {first.username.clone()} + {loc_label.as_ref().map(|l| { + let label_str = l.clone(); + view! { + + "#" " " {label_str} + + } + })} + + {time_ago(first.created_at)} + {has_multi.then(|| format!(" · {} msgs", messages.len()))} + +
+
+ {messages.into_iter().enumerate().map(|(i, msg)| { + let sep = has_multi && i > 0; + view! { +
+ +
+ } + }).collect::>()} +
+
+
+
+ } +} + +// ─── Skeleton ───────────────────────────────────────────── +#[component] +pub fn MessageCardSkeleton() -> impl IntoView { + view! { +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ } +} diff --git a/services/frontend-leptos/frontend/src/features/messages/components/message_feed.rs b/services/frontend-leptos/frontend/src/features/messages/components/message_feed.rs new file mode 100644 index 0000000..6f1758b --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/messages/components/message_feed.rs @@ -0,0 +1,128 @@ +use leptos::prelude::*; +use shared_types::message::MessageRecord; +use std::sync::Arc; +use wasm_bindgen::prelude::*; +use web_sys::IntersectionObserver; +use leptos::html; + +const GROUP_WINDOW_MS: i64 = 5 * 60 * 1000; + +fn group_messages(messages: Vec) -> Vec> { + let mut groups: Vec> = Vec::new(); + for msg in messages { + if let Some(last_group) = groups.last_mut() { + let same_user = last_group.first() + .map(|m| m.user_id == msg.user_id) + .unwrap_or(false); + let same_window = last_group.last() + .map(|m| (m.created_at - msg.created_at).abs() < GROUP_WINDOW_MS) + .unwrap_or(false); + if same_user && same_window { + last_group.push(msg); + continue; + } + } + groups.push(vec![msg]); + } + groups +} + +#[component] +pub fn MessageFeed( + messages: Vec, + #[prop(optional)] empty_text: &'static str, + #[prop(optional)] loading: bool, + #[prop(optional)] has_more: bool, + #[prop(optional)] loading_more: bool, + #[prop(optional)] on_load_more: Option>, + on_reanalyze: Arc, +) -> impl IntoView { + let sentinel_ref = create_node_ref::(); + let (intersecting, set_intersecting) = create_signal(false); + + create_effect(move |_| { + let _ = intersecting.get(); // track signal + if let Some(node) = sentinel_ref.get() { + let on_load_more = on_load_more.clone(); + let cb = Closure::)>::new(move |entries: Vec| { + for entry in entries { + if let Some(entry) = entry.dyn_ref::() { + if entry.is_intersecting() { + if let Some(ref cb) = on_load_more { + cb(); + } + } + } + } + }); + let observer = IntersectionObserver::new(cb.as_ref().unchecked_ref()) + .expect("IntersectionObserver failed"); + observer.observe(&node); + on_cleanup(move || { + observer.disconnect(); + }); + // Keep closure alive + cb.forget(); + } + }); + + // Loading state + if loading { + return view! { +
+ {std::iter::repeat_with(|| { + use super::message_card::MessageCardSkeleton; + view! { } + }).take(3).collect::>()} +
+ }.into_any(); + } + + if messages.is_empty() { + return view! { +
+
+ {if empty_text.is_empty() { "No messages" } else { empty_text }} +
+
+ }.into_any(); + } + + let groups = group_messages(messages); + let has_more_val = has_more; + let loading_more_val = loading_more; + + view! { +
+ {groups.into_iter().map(|group| { + let cb = on_reanalyze.clone(); + view! { + + } + }).collect::>()} + + {/* Infinite scroll sentinel */} + {has_more_val.then(|| { + view! { +
+ {loading_more_val.then(|| { + use super::message_card::MessageCardSkeleton; + view! { } + })} +
+ } + })} +
+ }.into_any() +} + +#[component] +fn MessageCardGroup( + messages: Vec, + on_reanalyze: Arc, +) -> impl IntoView { + use super::message_card::MessageCard; + view! { + + } +} diff --git a/services/frontend-leptos/frontend/src/features/messages/components/mod.rs b/services/frontend-leptos/frontend/src/features/messages/components/mod.rs new file mode 100644 index 0000000..644a7cd --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/messages/components/mod.rs @@ -0,0 +1,3 @@ +pub mod message_feed; +pub mod message_card; +pub mod image_grid; diff --git a/services/frontend-leptos/frontend/src/features/messages/hooks/mod.rs b/services/frontend-leptos/frontend/src/features/messages/hooks/mod.rs new file mode 100644 index 0000000..4533575 --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/messages/hooks/mod.rs @@ -0,0 +1 @@ +pub mod use_messages; diff --git a/services/frontend-leptos/frontend/src/features/messages/hooks/use_messages.rs b/services/frontend-leptos/frontend/src/features/messages/hooks/use_messages.rs new file mode 100644 index 0000000..ce907b7 --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/messages/hooks/use_messages.rs @@ -0,0 +1,200 @@ +use leptos::prelude::*; +use shared_types::message::{MessageRecord, PageResult}; +use crate::api::messages::{get_messages, reanalyze_message, reanalyze_batch}; +use std::collections::HashMap; +use std::sync::Arc; +use wasm_bindgen_futures::spawn_local; + +/// Merges current messages with incoming messages, deduplicating by ID and sorting +pub fn merge_messages(current: &[MessageRecord], incoming: &[MessageRecord]) -> Vec { + let mut by_id: HashMap = current.iter().map(|m| (m.id.clone(), m.clone())).collect(); + for msg in incoming { + by_id.insert(msg.id.clone(), msg.clone()); + } + let mut merged: Vec = by_id.into_values().collect(); + merged.sort_by(|a, b| b.created_at.cmp(&a.created_at).then_with(|| b.id.cmp(&a.id))); + merged +} + +/// Callback type for fetch_messages +pub type FetchMessagesCallback = Arc; +/// Callback type for load_more +pub type LoadMoreCallback = Arc; +/// Callback type for reanalyze +pub type ReanalyzeCallback = Arc; +/// Callback type for reanalyze_all_errors +pub type ReanalyzeAllErrorsCallback = Arc; + +/// State returned by use_messages hook +#[derive(Clone)] +pub struct MessagesState { + /// Current list of messages + pub messages: RwSignal>, + /// Whether the initial fetch is in progress + pub loading: ReadSignal, + /// Whether we're loading more messages + pub loading_more: RwSignal, + /// Pagination cursor for next page + pub cursor: RwSignal>, + /// Derived: whether there are more messages to load + pub has_more: Memo, + /// Last error message if any + pub error: RwSignal>, + /// Current guild ID + pub current_guild: RwSignal>, + /// Fetch initial messages for a guild + pub fetch_messages: FetchMessagesCallback, + /// Load next page of messages + pub load_more: LoadMoreCallback, + /// Reanalyze a single message + pub reanalyze: ReanalyzeCallback, + /// Reanalyze all error messages in current batch + pub reanalyze_all_errors: ReanalyzeAllErrorsCallback, +} + +/// Hook to manage message data fetching and state +pub fn use_messages() -> MessagesState { + // Core signals + let messages_signal = RwSignal::new(Vec::::new()); + let (loading, set_loading) = create_signal(false); + let loading_more_signal = RwSignal::new(false); + let cursor_signal = RwSignal::new(None::); + let error_signal = RwSignal::new(None::); + let current_guild_signal = RwSignal::new(None::); + + // Derived signal: has_more is true if cursor is Some + let has_more_signal = create_memo(move |_| cursor_signal.get().is_some()); + + // Fetch initial messages for a guild + let fetch_messages_impl = Arc::new(move |guild_id: String| { + spawn_local({ + let guild_id = guild_id.clone(); + async move { + error_signal.set(None); + set_loading.set(true); + + match get_messages(&guild_id, Some(30), None, None).await { + Ok(PageResult { data, next_cursor }) => { + messages_signal.set(data); + cursor_signal.set(next_cursor); + current_guild_signal.set(Some(guild_id)); + set_loading.set(false); + } + Err(e) => { + error_signal.set(Some(format!("Failed to fetch messages: {}", e))); + set_loading.set(false); + } + } + } + }); + }); + + // Load more messages (append next page) + let load_more_impl = Arc::new(move || { + spawn_local(async move { + let guild_id = match current_guild_signal.get() { + Some(id) => id, + None => { + error_signal.set(Some("No guild selected".to_string())); + return; + } + }; + + let cursor = match cursor_signal.get() { + Some(c) => c, + None => { + error_signal.set(Some("No more messages to load".to_string())); + return; + } + }; + + loading_more_signal.set(true); + error_signal.set(None); + + match get_messages(&guild_id, Some(30), None, Some(&cursor)).await { + Ok(PageResult { data, next_cursor }) => { + let current = messages_signal.get(); + messages_signal.set(merge_messages(¤t, &data)); + cursor_signal.set(next_cursor); + loading_more_signal.set(false); + } + Err(e) => { + error_signal.set(Some(format!("Failed to load more: {}", e))); + loading_more_signal.set(false); + } + } + }); + }); + + // Reanalyze single message with optimistic update + let reanalyze_impl = Arc::new(move |message_id: String| { + spawn_local({ + let message_id = message_id.clone(); + async move { + // Optimistic: flip status to Processing + let mut msgs = messages_signal.get(); + if let Some(pos) = msgs.iter().position(|m| m.id == message_id) { + if let Some(ref mut msg) = msgs.get_mut(pos) { + msg.ai_status = Some(shared_types::message::AiStatus::Processing); + } + } + messages_signal.set(msgs); + + // Call API + match reanalyze_message(&message_id).await { + Ok(_) => { + // Success: keep the Processing status (will be updated via WS) + } + Err(e) => { + // Revert to Error status on failure + let mut msgs = messages_signal.get(); + if let Some(pos) = msgs.iter().position(|m| m.id == message_id) { + if let Some(ref mut msg) = msgs.get_mut(pos) { + msg.ai_status = Some(shared_types::message::AiStatus::Error); + msg.ai_error = Some(e.to_string()); + } + } + messages_signal.set(msgs); + error_signal.set(Some(format!("Reanalyze failed: {}", e))); + } + } + } + }); + }); + + // Reanalyze all error messages + let reanalyze_all_errors_impl = Arc::new(move || { + spawn_local(async move { + match reanalyze_batch().await { + Ok(_count) => { + error_signal.set(None); + // Optimistically mark all error messages as Processing + let mut msgs = messages_signal.get(); + for msg in msgs.iter_mut() { + if msg.ai_status == Some(shared_types::message::AiStatus::Error) { + msg.ai_status = Some(shared_types::message::AiStatus::Processing); + } + } + messages_signal.set(msgs); + } + Err(e) => { + error_signal.set(Some(format!("Batch reanalyze failed: {}", e))); + } + } + }); + }); + + MessagesState { + messages: messages_signal, + loading, + loading_more: loading_more_signal, + cursor: cursor_signal, + has_more: has_more_signal, + error: error_signal, + current_guild: current_guild_signal, + fetch_messages: fetch_messages_impl, + load_more: load_more_impl, + reanalyze: reanalyze_impl, + reanalyze_all_errors: reanalyze_all_errors_impl, + } +} diff --git a/services/frontend-leptos/frontend/src/features/messages/mod.rs b/services/frontend-leptos/frontend/src/features/messages/mod.rs new file mode 100644 index 0000000..6569235 --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/messages/mod.rs @@ -0,0 +1,310 @@ +use leptos::prelude::*; +use shared_types::message::{AiStatus, MessageRecord}; +use std::sync::Arc; +use wasm_bindgen_futures::spawn_local; + +pub mod components; +pub mod hooks; + +use components::message_feed::MessageFeed; +use components::image_grid::ImageGrid; +use hooks::use_messages::{merge_messages, use_messages}; + +type AiFilter = &'static str; +const FILTERS: &[AiFilter] = &["all", "analyzed", "clean", "flagged", "error", "pending"]; + +#[derive(Clone, PartialEq)] +enum ViewTab { All, Images } + +#[component] +pub fn MessagesPanel() -> impl IntoView { + let state = use_messages(); + let (search_query, set_search_query) = create_signal(String::new()); + let (search_results, set_search_results) = create_signal::>(Vec::new()); + let (show_search, set_show_search) = create_signal(false); + let (is_searching, set_is_searching) = create_signal(false); + let ai_filter = RwSignal::new("analyzed".to_string()); + let view_tab = RwSignal::new(ViewTab::All); + let (retrying_all, set_retrying_all) = create_signal(false); + + // Stats derived from filtered messages + let stats = create_memo(move |_| { + let base = if show_search.get() { search_results.get() } else { state.messages.get() }; + let total = base.len(); + let clean = base.iter().filter(|m| m.ai_status == Some(AiStatus::Clean)).count(); + let flagged = base.iter().filter(|m| m.ai_status == Some(AiStatus::Flagged)).count(); + let error = base.iter().filter(|m| m.ai_status == Some(AiStatus::Error)).count(); + let pending = base.iter().filter(|m| m.ai_status.is_none() || m.ai_status == Some(AiStatus::Pending)).count(); + let deleted = base.iter().filter(|m| m.deleted_at.is_some()).count(); + let edited = base.iter().filter(|m| m.edited_at.is_some()).count(); + (total, clean, flagged, error, pending, deleted, edited) + }); + + // Filter messages based on active filter + let filtered_messages = create_memo(move |_| { + let base = if show_search.get() { search_results.get() } else { state.messages.get() }; + let filter = ai_filter.get(); + if filter == "all" { return base; } + base.into_iter().filter(|m| { + let status = m.ai_status.clone().unwrap_or(AiStatus::Pending); + if filter == "analyzed" { return status != AiStatus::Pending; } + if filter == "pending" { return status == AiStatus::Pending; } + format!("{:?}", status).to_lowercase() == filter + }).collect() + }); + + // Search handler - takes any event type and triggers the search + let do_search = { + let q = search_query; + move || { + let query = q.get(); + if query.trim().is_empty() { + set_show_search.set(false); + set_search_results.set(Vec::new()); + return; + } + set_is_searching.set(true); + let q_clone = query.trim().to_string(); + spawn_local(async move { + match crate::api::messages::search_messages(&q_clone, Some(50)).await { + Ok(results) => { + set_search_results.set(results); + set_show_search.set(true); + } + Err(_) => { + set_search_results.set(Vec::new()); + } + } + set_is_searching.set(false); + }); + } + }; + // Separate closures for different event types so on:click/on:keydown type-check + let handle_search_click = move |_: web_sys::MouseEvent| do_search(); + let handle_search_keydown = move |_: web_sys::KeyboardEvent| do_search(); + + // Clear search + let clear_search = move |_| { + set_show_search.set(false); + set_search_results.set(Vec::new()); + set_search_query.set(String::new()); + }; + + // Reanalyze all errors + let handle_retry_all = move |_| { + set_retrying_all.set(true); + let cb = state.reanalyze_all_errors.clone(); + spawn_local(async move { + cb(); + set_retrying_all.set(false); + }); + }; + + // Filter chip click + let set_filter = { + let af = ai_filter; + move |f: &'static str| af.set(f.to_string()) + }; + + // WS event handlers (wire once on mount) + let ws = use_context::(); + if let Some(ref ws) = ws { + // Subscribe to real-time message events + { + let msgs = state.messages; + *ws.on_message_created.borrow_mut() = Some(Box::new(move |msg| { + let current = msgs.get(); + msgs.set(merge_messages(¤t, &[msg])); + })); + } + { + let msgs = state.messages; + *ws.on_message_updated.borrow_mut() = Some(Box::new(move |msg| { + let current = msgs.get(); + msgs.set(merge_messages(¤t, &[msg])); + })); + } + { + let msgs = state.messages; + *ws.on_message_deleted.borrow_mut() = Some(Box::new(move |id| { + let current = msgs.get(); + msgs.set(current.into_iter().filter(|m| m.id != id).collect()); + })); + } + { + let msgs = state.messages; + *ws.on_message_analyzed.borrow_mut() = Some(Box::new(move |msg| { + let current = msgs.get(); + msgs.set(merge_messages(¤t, &[msg])); + })); + } + } + + // Fetch messages on mount if guild is configured + create_effect(move |_| { + if let Some(config) = use_context::() { + if let Some(ref guild_id) = config.monitor_guild_id { + (state.fetch_messages)(guild_id.clone()); + } + } + }); + + // ─── View ──────────────────────────────────────────────── + let get_stats = move || stats.get(); + let (total, clean, flagged, error, pending, deleted, edited) = ( + move || get_stats().0, + move || get_stats().1, + move || get_stats().2, + move || get_stats().3, + move || get_stats().4, + move || get_stats().5, + move || get_stats().6, + ); + + view! { +
+ {/* Header card */} +
+
+
"Messages"
+

+ "Messages are automatically captured from all text channels. Real-time updates arrive via WebSocket." +

+
+
+ + {/* Stats badges */} + {(total() > 0).then(|| view! { +
+ {total()} " total" {state.has_more.get().then(|| "+")} + {clean()} " clean" + {flagged()} " flagged" + {error()} " error" + {pending()} " pending" + {(deleted() > 0).then(|| view! { + {deleted()} " deleted" + })} + {(edited() > 0).then(|| view! { + {edited()} " edited" + })} +
+ })} + + {/* Search + filters row */} +
+
+ {/* Search icon as SVG */} + + +
+ + {show_search.get().then(|| view! { + + })} + {(error() > 0 && !show_search.get()).then(|| view! { + + })} +
+ {/* Filter icon as SVG since lucide-leptos Filter unavailable */} + + {FILTERS.iter().map(|f| { + let active = ai_filter.get() == *f; + let cls = if active { + "filter-chip active" + } else { + "filter-chip" + }; + let f_ptr: &'static str = f; + view! { + + } + }).collect::>()} +
+
+ + {/* Search results count */} + {show_search.get().then(|| { + let n = search_results.get().len(); + view! { +
+ "Found " {n} " result" {if n != 1 { "s" } else { "" }} +
+ } + })} + + {/* View tabs + content */} +
+
+ + +
+ +
+ { + let load_more_cb = state.load_more.clone(); + let empty_text: &'static str = if show_search.get() { "No messages found matching your search." } else { "No captures yet." }; + let has_more = if show_search.get() { false } else { state.has_more.get() }; + let on_load_more_clone: Arc = Arc::new(move || load_more_cb()); + view! { + + } + } +
+
+ +
+
+
+ } +} diff --git a/services/frontend-leptos/frontend/src/features/mod.rs b/services/frontend-leptos/frontend/src/features/mod.rs new file mode 100644 index 0000000..ff99f81 --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/mod.rs @@ -0,0 +1,4 @@ +pub mod dashboard; +pub mod live; +pub mod messages; +pub mod polish; diff --git a/services/frontend-leptos/frontend/src/features/polish/components/mascot_chatbot.rs b/services/frontend-leptos/frontend/src/features/polish/components/mascot_chatbot.rs new file mode 100644 index 0000000..bafee16 --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/polish/components/mascot_chatbot.rs @@ -0,0 +1,152 @@ +use leptos::prelude::*; +use wasm_bindgen_futures::spawn_local; + +#[derive(Clone, PartialEq)] +enum ChatRole { + User, + Mascot, +} + +#[derive(Clone)] +struct ChatMessage { + id: String, + role: ChatRole, + content: String, +} + +#[component] +pub fn MascotChatbot() -> impl IntoView { + let open = RwSignal::new(false); + let minimized = RwSignal::new(false); + let loading = RwSignal::new(false); + let input = RwSignal::new(String::new()); + let messages = RwSignal::new(vec![ChatMessage { + id: "init-1".to_string(), + role: ChatRole::Mascot, + content: "Halo! 👋 Aku mascot IMPHNEN. Tanya aku soal analytics, pesan, atau moderation queue.".to_string(), + }]); + + let send_message = move || { + let text = input.get().trim().to_string(); + if text.is_empty() || loading.get() { + return; + } + + let now = js_sys::Date::now() as u64; + messages.update(|list| list.push(ChatMessage { + id: format!("user-{}", now), + role: ChatRole::User, + content: text.clone(), + })); + input.set(String::new()); + loading.set(true); + + spawn_local(async move { + let response = match crate::api::mascot::send_mascot_message(&text).await { + Ok(resp) => resp.response, + Err(_) => fallback_response(&text), + }; + + messages.update(|list| list.push(ChatMessage { + id: format!("mascot-{}", js_sys::Date::now() as u64), + role: ChatRole::Mascot, + content: response, + })); + loading.set(false); + }); + }; + + view! { +
+ {move || if open.get() { + view! { +
+
+
+
"💬"
+
+
"Mascot IMPHNEN"
+
{move || if loading.get() { "Mengetik..." } else { "Online" }}
+
+
+
+ + +
+
+ + {move || (!minimized.get()).then(|| view! { + <> +
+ {messages.get().into_iter().map(|msg| { + let is_user = msg.role == ChatRole::User; + view! { +
+ {(!is_user).then(|| view! {
"🤖"
})} +
+ {msg.content} +
+
+ } + }).collect::>()} + + {loading.get().then(|| view! { +
+
"🤖"
+
+ +
+
+ })} +
+ +
+ + +
+ + })} +
+ }.into_any() + } else { + view! { + + }.into_any() + }} +
+ } +} + +fn fallback_response(input: &str) -> String { + let lower = input.to_lowercase(); + if lower.contains("halo") || lower.contains("hai") { + "Halo juga! 👋 Aku siap bantu baca kondisi server.".to_string() + } else if lower.contains("pesan") || lower.contains("message") { + "Cek tab Messages untuk live capture dan hasil AI moderation terbaru.".to_string() + } else if lower.contains("voice") || lower.contains("audio") { + "Tab Voice & Media punya voice bridge, speakers, media controls, dan recordings.".to_string() + } else if lower.contains("dashboard") || lower.contains("stat") { + "Dashboard Guild merangkum total pesan, user aktif, channel teratas, dan moderation queue.".to_string() + } else { + format!("Menarik: \"{}\". Kalau backend mascot offline, aku tetap bisa bantu arahkan ke Messages, Voice, atau Dashboard. 😊", input) + } +} diff --git a/services/frontend-leptos/frontend/src/features/polish/components/mod.rs b/services/frontend-leptos/frontend/src/features/polish/components/mod.rs new file mode 100644 index 0000000..2bd9b1f --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/polish/components/mod.rs @@ -0,0 +1,7 @@ +pub mod mascot_chatbot; +pub mod particle_background; +pub mod theme_toggle; + +pub use mascot_chatbot::MascotChatbot; +pub use particle_background::ParticleBackground; +pub use theme_toggle::ThemeToggle; diff --git a/services/frontend-leptos/frontend/src/features/polish/components/particle_background.rs b/services/frontend-leptos/frontend/src/features/polish/components/particle_background.rs new file mode 100644 index 0000000..6d9de95 --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/polish/components/particle_background.rs @@ -0,0 +1,12 @@ +use leptos::prelude::*; + +#[component] +pub fn ParticleBackground() -> impl IntoView { + view! { + + } +} diff --git a/services/frontend-leptos/frontend/src/features/polish/components/theme_toggle.rs b/services/frontend-leptos/frontend/src/features/polish/components/theme_toggle.rs new file mode 100644 index 0000000..0f7a995 --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/polish/components/theme_toggle.rs @@ -0,0 +1,35 @@ +use leptos::prelude::*; +use crate::features::polish::{persist_theme, ThemeContext}; + +#[component] +pub fn ThemeToggle() -> impl IntoView { + let theme_ctx = use_context::(); + let theme_for_label = theme_ctx.clone(); + let theme_for_toggle = theme_ctx.clone(); + + let is_dark = move || { + theme_for_label + .as_ref() + .map(|ctx| ctx.theme.get() == "dark") + .unwrap_or(false) + }; + + let toggle = move |_| { + if let Some(ctx) = theme_for_toggle.as_ref() { + let next = if ctx.theme.get() == "dark" { "light" } else { "dark" }; + ctx.theme.set(next.to_string()); + persist_theme(next); + } + }; + + view! { + + } +} diff --git a/services/frontend-leptos/frontend/src/features/polish/mod.rs b/services/frontend-leptos/frontend/src/features/polish/mod.rs new file mode 100644 index 0000000..8c1ded8 --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/polish/mod.rs @@ -0,0 +1,22 @@ +pub mod components; + +use leptos::prelude::*; + +#[derive(Clone)] +pub struct ThemeContext { + pub theme: RwSignal, +} + +pub fn initial_theme() -> String { + web_sys::window() + .and_then(|window| window.local_storage().ok().flatten()) + .and_then(|storage| storage.get_item("imphnen-theme").ok().flatten()) + .filter(|value| value == "dark" || value == "light") + .unwrap_or_else(|| "light".to_string()) +} + +pub fn persist_theme(theme: &str) { + if let Some(storage) = web_sys::window().and_then(|window| window.local_storage().ok().flatten()) { + let _ = storage.set_item("imphnen-theme", theme); + } +} diff --git a/services/frontend-leptos/frontend/src/layout/dashboard_layout.rs b/services/frontend-leptos/frontend/src/layout/dashboard_layout.rs new file mode 100644 index 0000000..64a7005 --- /dev/null +++ b/services/frontend-leptos/frontend/src/layout/dashboard_layout.rs @@ -0,0 +1,28 @@ +// services/frontend-leptos/frontend/src/layout/dashboard_layout.rs +use leptos::children::Children; +use leptos::prelude::*; +use super::header::Header; +use super::mobile_tab_bar::MobileTabBar; +use super::sidebar::Sidebar; +use super::tab_strip::TabStrip; + +#[component] +pub fn DashboardLayout( + children: Children, +) -> impl IntoView { + view! { +
+
+
+ +
+ +
+ {children()} +
+
+
+ +
+ } +} diff --git a/services/frontend-leptos/frontend/src/layout/header.rs b/services/frontend-leptos/frontend/src/layout/header.rs new file mode 100644 index 0000000..4b8a95e --- /dev/null +++ b/services/frontend-leptos/frontend/src/layout/header.rs @@ -0,0 +1,56 @@ +// services/frontend-leptos/frontend/src/layout/header.rs +use leptos::prelude::*; +use crate::ws::context::WsContext; +use crate::ws::socket::WsStatus; + +#[component] +pub fn Header() -> impl IntoView { + let ws = use_context::().expect("WsContext not provided"); + let ws_status = ws.status; + + let indicator_text_memo = create_memo(move |_| match ws_status.get() { + WsStatus::Connected => "Online", + WsStatus::Connecting => "Menghubungkan...", + WsStatus::Disconnected => "Offline", + WsStatus::Error(_) => "Error", + }); + let indicator_color_memo = create_memo(move |_| match ws_status.get() { + WsStatus::Connected => "var(--color-success)", + WsStatus::Connecting => "var(--color-warning)", + WsStatus::Disconnected => "var(--text-tertiary)", + WsStatus::Error(_) => "var(--color-error)", + }); + + view! { +
+
+ + "IMPHNEN" + + + "Guild Watcher" + +
+ +
+
+ + {move || indicator_text_memo.get()} +
+
+
+ } +} diff --git a/services/frontend-leptos/frontend/src/layout/mobile_tab_bar.rs b/services/frontend-leptos/frontend/src/layout/mobile_tab_bar.rs new file mode 100644 index 0000000..9de8dcd --- /dev/null +++ b/services/frontend-leptos/frontend/src/layout/mobile_tab_bar.rs @@ -0,0 +1,63 @@ +// services/frontend-leptos/frontend/src/layout/mobile_tab_bar.rs +use leptos::prelude::*; +use shared_types::ui_state::Tab; +use crate::app::UiContext; + +#[component] +pub fn MobileTabBar() -> impl IntoView { + let ui = use_context::().expect("UiContext not provided"); + + view! { +
+ + + +
+ } +} + +#[component] +fn MobileTabItem( + icon: &'static str, + label: &'static str, + tab: Tab, + ui: UiContext, +) -> impl IntoView { + let tab_active = tab.clone(); + let tab_click = tab; + view! { + + } +} diff --git a/services/frontend-leptos/frontend/src/layout/mod.rs b/services/frontend-leptos/frontend/src/layout/mod.rs new file mode 100644 index 0000000..63c4a67 --- /dev/null +++ b/services/frontend-leptos/frontend/src/layout/mod.rs @@ -0,0 +1,6 @@ +// services/frontend-leptos/frontend/src/layout/mod.rs +pub mod dashboard_layout; +pub mod header; +pub mod mobile_tab_bar; +pub mod sidebar; +pub mod tab_strip; diff --git a/services/frontend-leptos/frontend/src/layout/sidebar.rs b/services/frontend-leptos/frontend/src/layout/sidebar.rs new file mode 100644 index 0000000..764e569 --- /dev/null +++ b/services/frontend-leptos/frontend/src/layout/sidebar.rs @@ -0,0 +1,71 @@ +// services/frontend-leptos/frontend/src/layout/sidebar.rs +use leptos::prelude::*; +use shared_types::ui_state::Tab; +use crate::app::UiContext; + +#[component] +pub fn Sidebar() -> impl IntoView { + let ui = use_context::().expect("UiContext not provided"); + let (collapsed, _set_collapsed) = create_signal(false); + + view! { + + } +} + +#[component] +fn NavItem( + icon: &'static str, + label: &'static str, + tab: Tab, + ui: UiContext, +) -> impl IntoView { + let tab_bg = tab.clone(); + let tab_clr = tab.clone(); + let tab_click = tab; + let handle_click = move |_| ui.active_tab.set(tab_click.clone()); + + view! { + + } +} diff --git a/services/frontend-leptos/frontend/src/layout/tab_strip.rs b/services/frontend-leptos/frontend/src/layout/tab_strip.rs new file mode 100644 index 0000000..7052f3f --- /dev/null +++ b/services/frontend-leptos/frontend/src/layout/tab_strip.rs @@ -0,0 +1,54 @@ +// services/frontend-leptos/frontend/src/layout/tab_strip.rs +use leptos::prelude::*; +use shared_types::ui_state::Tab; +use crate::app::UiContext; + +#[component] +pub fn TabStrip() -> impl IntoView { + let ui = use_context::().expect("UiContext not provided"); + + view! { +
+ + + +
+ } +} + +#[component] +fn TabItem( + label: &'static str, + tab: Tab, + ui: UiContext, +) -> impl IntoView { + let tab_color = tab.clone(); + let tab_border = tab.clone(); + let tab_click = tab; + view! { + + } +} diff --git a/services/frontend-leptos/frontend/src/lib.rs b/services/frontend-leptos/frontend/src/lib.rs new file mode 100644 index 0000000..f00e055 --- /dev/null +++ b/services/frontend-leptos/frontend/src/lib.rs @@ -0,0 +1,20 @@ +pub mod api; +pub mod app; +pub mod auth; +pub mod features; +pub mod layout; +pub mod ui; +pub mod ws; + +use wasm_bindgen::prelude::*; + +#[wasm_bindgen(start)] +pub fn start() { + // Set up panic hook for better error messages in the browser console + console_error_panic_hook::set_once(); + // Initialize logger + wasm_logger::init(wasm_logger::Config::default()); + + // Mount the Leptos app to the body + leptos::mount::mount_to_body(app::App); +} diff --git a/services/frontend-leptos/frontend/src/ui/badge.rs b/services/frontend-leptos/frontend/src/ui/badge.rs new file mode 100644 index 0000000..ed54523 --- /dev/null +++ b/services/frontend-leptos/frontend/src/ui/badge.rs @@ -0,0 +1,42 @@ +use leptos::prelude::*; + +#[derive(Clone)] +pub enum BadgeVariant { + Default, + Primary, + Success, + Warning, + Destructive, + Outline, + Info, +} + +impl Default for BadgeVariant { + fn default() -> Self { + Self::Default + } +} + +#[component] +pub fn Badge( + #[prop(optional)] variant: BadgeVariant, + children: Children, +) -> impl IntoView { + let variant_class = match variant { + BadgeVariant::Default => "", + BadgeVariant::Primary => "badge-primary", + BadgeVariant::Success => "badge-success", + BadgeVariant::Warning => "badge-warning", + BadgeVariant::Destructive => "badge-destructive", + BadgeVariant::Outline => "badge-outline", + BadgeVariant::Info => "badge-info", + }; + + let combined = format!("badge {}", variant_class); + + view! { + + {children()} + + } +} diff --git a/services/frontend-leptos/frontend/src/ui/button.rs b/services/frontend-leptos/frontend/src/ui/button.rs new file mode 100644 index 0000000..4063181 --- /dev/null +++ b/services/frontend-leptos/frontend/src/ui/button.rs @@ -0,0 +1,67 @@ +use leptos::prelude::*; + +#[derive(Clone, Default)] +pub enum ButtonVariant { + #[default] + Primary, + Secondary, + Tertiary, + Destructive, + Outline, + Ghost, + Link, +} + +#[derive(Clone)] +pub enum ButtonSize { + Default, + Sm, + Lg, + Icon, + IconSm, +} + +impl Default for ButtonSize { + fn default() -> Self { + Self::Default + } +} + +#[component] +pub fn Button( + #[prop(optional)] variant: ButtonVariant, + #[prop(optional)] size: ButtonSize, + #[prop(optional)] disabled: bool, + #[prop(optional)] class: &'static str, + #[prop(optional)] on_click: Option>, + children: Children, +) -> impl IntoView { + let variant_class = match variant { + ButtonVariant::Primary => "btn-primary", + ButtonVariant::Secondary => "btn-secondary", + ButtonVariant::Tertiary => "btn-tertiary", + ButtonVariant::Destructive => "btn-destructive", + ButtonVariant::Outline => "btn-outline", + ButtonVariant::Ghost => "btn-ghost", + ButtonVariant::Link => "btn-link", + }; + let size_class = match size { + ButtonSize::Default => "", + ButtonSize::Sm => "btn-sm", + ButtonSize::Lg => "btn-lg", + ButtonSize::Icon => "btn-icon", + ButtonSize::IconSm => "btn-icon-sm", + }; + + let combined = format!("btn {} {} {}", variant_class, size_class, class); + + view! { + + } +} diff --git a/services/frontend-leptos/frontend/src/ui/card.rs b/services/frontend-leptos/frontend/src/ui/card.rs new file mode 100644 index 0000000..e0341e0 --- /dev/null +++ b/services/frontend-leptos/frontend/src/ui/card.rs @@ -0,0 +1,46 @@ +use leptos::prelude::*; + +#[component] +pub fn Card( + #[prop(optional)] elevated: bool, + #[prop(optional)] bordered: bool, + #[prop(optional)] class: &'static str, + children: Children, +) -> impl IntoView { + let combined = format!("card {}", class); + + view! { +
+ {children()} +
+ } +} + +#[component] +pub fn CardHeader(children: Children) -> impl IntoView { + view! {
{children()}
} +} + +#[component] +pub fn CardTitle(children: Children) -> impl IntoView { + view! {

{children()}

} +} + +#[component] +pub fn CardDescription(children: Children) -> impl IntoView { + view! {

{children()}

} +} + +#[component] +pub fn CardContent(children: Children) -> impl IntoView { + view! {
{children()}
} +} + +#[component] +pub fn CardFooter(children: Children) -> impl IntoView { + view! { } +} diff --git a/services/frontend-leptos/frontend/src/ui/empty_state.rs b/services/frontend-leptos/frontend/src/ui/empty_state.rs new file mode 100644 index 0000000..662f5d5 --- /dev/null +++ b/services/frontend-leptos/frontend/src/ui/empty_state.rs @@ -0,0 +1,19 @@ +// services/frontend-leptos/frontend/src/ui/empty_state.rs +use leptos::prelude::*; + +#[component] +pub fn EmptyState( + #[prop(optional)] icon: Option, + title: &'static str, + #[prop(optional)] description: Option<&'static str>, + #[prop(optional)] children: Option, +) -> impl IntoView { + view! { +
+ {icon.map(|i| view! {
{i}
})} +
{title}
+ {description.map(|d| view! {

{d}

})} + {children.map(|c| c())} +
+ } +} diff --git a/services/frontend-leptos/frontend/src/ui/input.rs b/services/frontend-leptos/frontend/src/ui/input.rs new file mode 100644 index 0000000..809aa61 --- /dev/null +++ b/services/frontend-leptos/frontend/src/ui/input.rs @@ -0,0 +1,47 @@ +// services/frontend-leptos/frontend/src/ui/input.rs +use leptos::prelude::*; + +#[component] +pub fn Input( + #[prop(optional)] input_type: &'static str, + #[prop(optional)] placeholder: &'static str, + #[prop(optional)] value: RwSignal, + #[prop(optional)] soft: bool, + #[prop(optional)] error: bool, + #[prop(optional)] class: &'static str, + #[prop(optional)] on_input: Option>, +) -> impl IntoView { + view! { + + } +} + +#[component] +pub fn TextArea( + #[prop(optional)] placeholder: &'static str, + #[prop(optional)] value: RwSignal, + #[prop(optional)] rows: u32, + #[prop(optional)] class: &'static str, +) -> impl IntoView { + view! { + + } +} diff --git a/services/frontend-leptos/frontend/src/ui/mod.rs b/services/frontend-leptos/frontend/src/ui/mod.rs new file mode 100644 index 0000000..7ee2c6f --- /dev/null +++ b/services/frontend-leptos/frontend/src/ui/mod.rs @@ -0,0 +1,13 @@ +// services/frontend-leptos/frontend/src/ui/mod.rs +pub mod badge; +pub mod button; +pub mod card; +pub mod input; +pub mod scroll_area; +pub mod select; +pub mod tabs; +pub mod toast; +pub mod skeleton; +pub mod status_badge; +pub mod empty_state; +pub mod modal; diff --git a/services/frontend-leptos/frontend/src/ui/modal.rs b/services/frontend-leptos/frontend/src/ui/modal.rs new file mode 100644 index 0000000..866f9a9 --- /dev/null +++ b/services/frontend-leptos/frontend/src/ui/modal.rs @@ -0,0 +1,46 @@ +// services/frontend-leptos/frontend/src/ui/modal.rs +use std::sync::Arc; +use leptos::prelude::*; + +#[component] +pub fn Modal( + is_open: RwSignal, + #[prop(optional)] title: Option<&'static str>, + #[prop(optional)] on_close: Option>, + children: Children, +) -> impl IntoView { + let oc1 = on_close.clone(); + let oc2 = on_close; + + view! { + + } +} diff --git a/services/frontend-leptos/frontend/src/ui/scroll_area.rs b/services/frontend-leptos/frontend/src/ui/scroll_area.rs new file mode 100644 index 0000000..22f8f30 --- /dev/null +++ b/services/frontend-leptos/frontend/src/ui/scroll_area.rs @@ -0,0 +1,15 @@ +// services/frontend-leptos/frontend/src/ui/scroll_area.rs +use leptos::prelude::*; + +#[component] +pub fn ScrollArea( + #[prop(optional)] class: &'static str, + #[prop(optional)] style: &'static str, + children: Children, +) -> impl IntoView { + view! { +
+ {children()} +
+ } +} diff --git a/services/frontend-leptos/frontend/src/ui/select.rs b/services/frontend-leptos/frontend/src/ui/select.rs new file mode 100644 index 0000000..24119b8 --- /dev/null +++ b/services/frontend-leptos/frontend/src/ui/select.rs @@ -0,0 +1,30 @@ +// services/frontend-leptos/frontend/src/ui/select.rs +use leptos::prelude::*; + +/// Simple select — values and labels are the same +/// For options with different value/label, use `SelectOptions` +#[component] +pub fn Select( + #[prop(optional)] value: RwSignal, + options: Vec<(&'static str, &'static str)>, // (value, label) + #[prop(optional)] placeholder: &'static str, + #[prop(optional)] class: &'static str, + #[prop(optional)] on_change: Option>, +) -> impl IntoView { + view! { + + } +} diff --git a/services/frontend-leptos/frontend/src/ui/skeleton.rs b/services/frontend-leptos/frontend/src/ui/skeleton.rs new file mode 100644 index 0000000..5cb9f49 --- /dev/null +++ b/services/frontend-leptos/frontend/src/ui/skeleton.rs @@ -0,0 +1,30 @@ +// services/frontend-leptos/frontend/src/ui/skeleton.rs +use leptos::prelude::*; + +#[derive(Clone, Default)] +pub enum SkeletonShape { + #[default] + Rounded, + Circular, + Rectangular, +} + +#[component] +pub fn Skeleton( + #[prop(optional)] width: &'static str, + #[prop(optional)] height: &'static str, + #[prop(optional)] shape: SkeletonShape, +) -> impl IntoView { + let shape_class = match shape { + SkeletonShape::Rounded => "", + SkeletonShape::Circular => "skeleton-circular", + SkeletonShape::Rectangular => "skeleton-rectangular", + }; + let combined = format!("skeleton {}", shape_class); + view! { +
+ } +} diff --git a/services/frontend-leptos/frontend/src/ui/status_badge.rs b/services/frontend-leptos/frontend/src/ui/status_badge.rs new file mode 100644 index 0000000..5dfb59d --- /dev/null +++ b/services/frontend-leptos/frontend/src/ui/status_badge.rs @@ -0,0 +1,21 @@ +// services/frontend-leptos/frontend/src/ui/status_badge.rs +use leptos::prelude::*; +use shared_types::message::AiStatus; + +#[component] +pub fn StatusBadge(status: AiStatus) -> impl IntoView { + let (class, label) = match status { + AiStatus::Flagged => ("status-badge-flagged", "Flagged"), + AiStatus::Clean => ("status-badge-clean", "Clean"), + AiStatus::Warn => ("status-badge-warn", "Warned"), + AiStatus::Pending => ("status-badge-pending", "Pending"), + AiStatus::Processing => ("status-badge-processing", "Processing"), + AiStatus::Error => ("status-badge-error", "Error"), + }; + let combined = format!("status-badge {}", class); + view! { + + {label} + + } +} diff --git a/services/frontend-leptos/frontend/src/ui/tabs.rs b/services/frontend-leptos/frontend/src/ui/tabs.rs new file mode 100644 index 0000000..f1916a1 --- /dev/null +++ b/services/frontend-leptos/frontend/src/ui/tabs.rs @@ -0,0 +1,66 @@ +// services/frontend-leptos/frontend/src/ui/tabs.rs +use leptos::prelude::*; + +#[component] +pub fn Tabs( + active: RwSignal, + #[prop(optional)] class: &'static str, + children: Children, +) -> impl IntoView { + view! { +
+ {children()} +
+ } +} + +#[component] +pub fn TabList( + #[prop(optional)] class: &'static str, + children: Children, +) -> impl IntoView { + view! { +
+ {children()} +
+ } +} + +#[component] +pub fn TabTrigger( + value: String, + active: RwSignal, + children: Children, +) -> impl IntoView { + let v1 = value.clone(); + let v2 = value.clone(); + view! { + + } +} + +#[component] +pub fn TabContent( + value: String, + active: RwSignal, + children: Children, +) -> impl IntoView { + let is_selected = move || active.get() == value; + view! { +
+ {children()} +
+ } +} diff --git a/services/frontend-leptos/frontend/src/ui/toast.rs b/services/frontend-leptos/frontend/src/ui/toast.rs new file mode 100644 index 0000000..ac47d49 --- /dev/null +++ b/services/frontend-leptos/frontend/src/ui/toast.rs @@ -0,0 +1,85 @@ +// services/frontend-leptos/frontend/src/ui/toast.rs +use leptos::prelude::*; +use std::sync::{Arc, Mutex}; + +#[derive(Clone)] +pub enum ToastType { + Info, + Success, + Error, + Warning, +} + +#[derive(Clone)] +pub struct ToastMessage { + pub id: u64, + pub message: String, + pub toast_type: ToastType, +} + +#[derive(Clone)] +pub struct ToastContext { + pub toasts: RwSignal>, + next_id: Arc>, +} + +impl ToastContext { + pub fn new() -> Self { + Self { + toasts: create_rw_signal(vec![]), + next_id: Arc::new(Mutex::new(0)), + } + } + + pub fn show(&self, message: &str, toast_type: ToastType) { + let id = { + let mut n = self.next_id.lock().unwrap(); + *n += 1; + *n + }; + let msg = ToastMessage { + id, + message: message.to_string(), + toast_type, + }; + self.toasts.update(|t| t.push(msg)); + + // Auto-dismiss after 4 seconds + let toasts = self.toasts; + leptos::prelude::set_timeout( + move || { + toasts.update(|t| t.retain(|m| m.id != id)); + }, + std::time::Duration::from_secs(4), + ); + } +} + +#[component] +pub fn ToastProvider(children: Children) -> impl IntoView { + let ctx = ToastContext::new(); + provide_context(ctx.clone()); + + view! { + {children()} +
+ {move || ctx.toasts.get().into_iter().map(|msg| { + let type_class = match msg.toast_type { + ToastType::Info => "toast-info", + ToastType::Success => "toast-success", + ToastType::Error => "toast-error", + ToastType::Warning => "toast-warning", + }; + let toasts = ctx.toasts; + view! { +
+ {msg.message} + +
+ } + }).collect::>()} +
+ } +} diff --git a/services/frontend-leptos/frontend/src/ws/context.rs b/services/frontend-leptos/frontend/src/ws/context.rs new file mode 100644 index 0000000..35ad58c --- /dev/null +++ b/services/frontend-leptos/frontend/src/ws/context.rs @@ -0,0 +1,151 @@ +// services/frontend-leptos/frontend/src/ws/context.rs +use leptos::prelude::*; +use crate::ws::socket::{WsHandle, WsStatus, WsEvent}; +use shared_types::message::MessageRecord; +use shared_types::voice::ActiveSpeaker; +use shared_types::media::MediaState; +use shared_types::recording::VoiceRecording; + +#[derive(Clone)] +pub struct WsContext { + pub handle: std::rc::Rc, + pub status: ReadSignal, + // Per-event callbacks (set externally by feature components) + // Wrapped in Rc so cloning shares the same callback slots + pub on_message_created: std::rc::Rc>>>, + pub on_message_updated: std::rc::Rc>>>, + pub on_message_deleted: std::rc::Rc>>>, + pub on_message_analyzed: std::rc::Rc>>>, + pub on_voice_active_user: std::rc::Rc>>>, + pub on_voice_recording_uploaded: std::rc::Rc>>>, + pub on_media_state: std::rc::Rc>>>, + pub on_binary: std::rc::Rc)>>>>, +} + +impl WsContext { + pub fn new(url: &str) -> Self { + let ws_handle = std::rc::Rc::new(WsHandle::new(url)); + let status = ws_handle.status; + + let ctx = Self { + status, + handle: ws_handle, + on_message_created: std::rc::Rc::new(std::cell::RefCell::new(None)), + on_message_updated: std::rc::Rc::new(std::cell::RefCell::new(None)), + on_message_deleted: std::rc::Rc::new(std::cell::RefCell::new(None)), + on_message_analyzed: std::rc::Rc::new(std::cell::RefCell::new(None)), + on_voice_active_user: std::rc::Rc::new(std::cell::RefCell::new(None)), + on_voice_recording_uploaded: std::rc::Rc::new(std::cell::RefCell::new(None)), + on_media_state: std::rc::Rc::new(std::cell::RefCell::new(None)), + on_binary: std::rc::Rc::new(std::cell::RefCell::new(None)), + }; + + // Wire up the main event dispatcher + let ctx_clone = ctx.clone(); + ctx.handle.on_event(move |event| { + ctx_clone.dispatch_event(event); + }); + + ctx + } + + fn dispatch_event(&self, event: WsEvent) { + match event { + WsEvent::Text(text) => { + // Parse JSON envelope: { type: string, data?: any } + if let Ok(parsed) = serde_json::from_str::(&text) { + let event_type = parsed["type"].as_str().unwrap_or("").to_string(); + let data = parsed.get("data"); + + match event_type.as_str() { + "message_created" => { + if let Some(d) = data.and_then(|v| serde_json::from_value::(v.clone()).ok()) { + if let Some(cb) = self.on_message_created.borrow().as_ref() { + cb(d); + } + } + } + "message_updated" => { + if let Some(d) = data.and_then(|v| serde_json::from_value::(v.clone()).ok()) { + if let Some(cb) = self.on_message_updated.borrow().as_ref() { + cb(d); + } + } + } + "message_deleted" => { + if let Some(d) = data.and_then(|v| v.as_str().map(String::from)) { + if let Some(cb) = self.on_message_deleted.borrow().as_ref() { + cb(d); + } + } + } + "message_analyzed" => { + if let Some(d) = data.and_then(|v| serde_json::from_value::(v.clone()).ok()) { + if let Some(cb) = self.on_message_analyzed.borrow().as_ref() { + cb(d); + } + } + } + "voice_active_user" => { + if let Some(d) = data.and_then(|v| serde_json::from_value::(v.clone()).ok()) { + if let Some(cb) = self.on_voice_active_user.borrow().as_ref() { + cb(d); + } + } + } + "voice_recording_uploaded" => { + if let Some(d) = data.and_then(|v| serde_json::from_value::(v.clone()).ok()) { + if let Some(cb) = self.on_voice_recording_uploaded.borrow().as_ref() { + cb(d); + } + } + } + "media_state" => { + // Backend sends initial state with "state" key, live updates with "data" + let raw = data + .or_else(|| parsed.get("state")) + .cloned(); + if let Some(d) = raw.and_then(|v| serde_json::from_value::(v).ok()) { + if let Some(cb) = self.on_media_state.borrow().as_ref() { + cb(d); + } + } + } + _ => { + // Unknown event type — log and ignore + web_sys::console::log_1(&format!("[WS] unhandled event: {}", event_type).into()); + } + } + } + } + WsEvent::Binary(data) => { + if let Some(cb) = self.on_binary.borrow().as_ref() { + cb(data); + } + } + } + } + + pub fn connect(&self) { + self.handle.connect(); + } + + pub fn disconnect(&self) { + self.handle.disconnect(); + } + + pub fn send_text(&self, text: &str) { + let _ = self.handle.send_text(text); + } + + pub fn send_binary(&self, data: &[u8]) { + let _ = self.handle.send_binary(data); + } +} + +// SAFETY: WsContext uses Rc/RefCell for cheap cloning in a single-threaded WASM +// environment. The leptos reactive system requires provided contexts to be Send+Sync. +#[allow(unsafe_code)] +unsafe impl Send for WsContext {} +#[allow(unsafe_code)] +unsafe impl Sync for WsContext {} diff --git a/services/frontend-leptos/frontend/src/ws/mod.rs b/services/frontend-leptos/frontend/src/ws/mod.rs new file mode 100644 index 0000000..71e685b --- /dev/null +++ b/services/frontend-leptos/frontend/src/ws/mod.rs @@ -0,0 +1,3 @@ +// services/frontend-leptos/frontend/src/ws/mod.rs +pub mod socket; +pub mod context; diff --git a/services/frontend-leptos/frontend/src/ws/socket.rs b/services/frontend-leptos/frontend/src/ws/socket.rs new file mode 100644 index 0000000..6ad8e32 --- /dev/null +++ b/services/frontend-leptos/frontend/src/ws/socket.rs @@ -0,0 +1,195 @@ +// services/frontend-leptos/frontend/src/ws/socket.rs +use leptos::prelude::*; +use wasm_bindgen::prelude::*; +use wasm_bindgen::JsCast; +use web_sys::{WebSocket, MessageEvent, CloseEvent, ErrorEvent}; + +#[derive(Debug, Clone, PartialEq)] +pub enum WsStatus { + Disconnected, + Connecting, + Connected, + Error(String), +} + +#[derive(Debug, Clone)] +pub enum WsEvent { + Text(String), + Binary(Vec), +} + +pub struct WsHandle { + pub status: ReadSignal, + set_status: WriteSignal, + ws: std::cell::RefCell>, + on_event: std::rc::Rc>>>, + url: String, + reconnect_attempt: std::cell::Cell, +} + +impl WsHandle { + pub fn new(url: &str) -> Self { + let (status, set_status) = create_signal(WsStatus::Disconnected); + Self { + status, + set_status, + ws: std::cell::RefCell::new(None), + on_event: std::rc::Rc::new(std::cell::RefCell::new(None)), + url: url.to_string(), + reconnect_attempt: std::cell::Cell::new(0), + } + } + + pub fn on_event(&self, callback: F) + where + F: Fn(WsEvent) + 'static, + { + *self.on_event.borrow_mut() = Some(Box::new(callback)); + } + + pub fn connect(&self) { + if self.status.get() == WsStatus::Connected || self.status.get() == WsStatus::Connecting { + return; + } + self.set_status.set(WsStatus::Connecting); + + let url = self.url.clone(); + let status_clone = self.set_status.clone(); + let event_clone: std::rc::Rc>>> = self.on_event.clone(); + let ws_holder = &self.ws as *const std::cell::RefCell>; + let reconnect_attempt = &self.reconnect_attempt as *const std::cell::Cell; + + Self::perform_connect(&url, status_clone, event_clone, ws_holder, reconnect_attempt); + } + + /// Shared connection setup used for both initial connect and reconnection. + /// Takes raw pointers because it must be callable from `wasm_bindgen` closures + /// that cannot borrow `self`. + #[allow(unsafe_code)] + fn perform_connect( + url: &str, + set_status: WriteSignal, + on_event: std::rc::Rc>>>, + ws_holder: *const std::cell::RefCell>, + reconnect_attempt: *const std::cell::Cell, + ) { + let url_owned = url.to_string(); + let status1 = set_status.clone(); + let status2 = set_status.clone(); + let status3 = set_status.clone(); + let event_clone = on_event.clone(); + + match WebSocket::new(&url_owned) { + Ok(ws) => { + // Store reference + unsafe { *(*ws_holder).borrow_mut() = Some(ws.clone()) }; + + // onopen + let onopen_cb = Closure::::new(move |_| { + status1.set(WsStatus::Connected); + unsafe { (*reconnect_attempt).set(0) }; + }); + ws.set_onopen(Some(onopen_cb.as_ref().unchecked_ref())); + onopen_cb.forget(); + + // onclose — schedule reconnect with exponential backoff + let event_for_close = event_clone.clone(); + let onclose_cb = Closure::::new(move |_| { + status2.set(WsStatus::Disconnected); + unsafe { *(*ws_holder).borrow_mut() = None }; + + let attempt = unsafe { (*reconnect_attempt).get() }; + if attempt >= 20 { + status2.set(WsStatus::Error("Max reconnect attempts reached".to_string())); + return; + } + // Full-jitter exponential backoff: min(1000 * 2^attempt, 30000) * (0.5 + random * 0.5) + let base = core::cmp::min(1000u32 * (1u32 << attempt), 30000u32); + let jitter = 0.5 + js_sys::Math::random() * 0.5; + let delay_ms = (base as f64 * jitter) as u32; + unsafe { (*reconnect_attempt).set(attempt + 1) }; + + let url_reconnect = url_owned.clone(); + let status_rc = status2.clone(); + let event_rc = event_for_close.clone(); + let reconnect_fn = Closure::::new(move || { + Self::perform_connect(&url_reconnect, status_rc.clone(), event_rc.clone(), ws_holder, reconnect_attempt); + }); + web_sys::window() + .and_then(|w| { + w.set_timeout_with_callback_and_timeout_and_arguments_0( + reconnect_fn.as_ref().unchecked_ref(), + delay_ms as i32, + ).ok() + }); + reconnect_fn.forget(); + }); + ws.set_onclose(Some(onclose_cb.as_ref().unchecked_ref())); + onclose_cb.forget(); + + // onerror + let onerror_cb = Closure::::new(move |e: ErrorEvent| { + status3.set(WsStatus::Error(e.message())); + }); + ws.set_onerror(Some(onerror_cb.as_ref().unchecked_ref())); + onerror_cb.forget(); + + // onmessage + let onmsg_cb = Closure::::new(move |e: MessageEvent| { + if let Some(cb) = &*event_clone.borrow() { + if let Some(text) = e.data().as_string() { + cb(WsEvent::Text(text)); + } else if let Some(abuf) = e.data().dyn_ref::() { + let len = abuf.byte_length() as usize; + let u8view = js_sys::Uint8Array::new(abuf); + let mut bytes = vec![0u8; len]; + u8view.copy_to(&mut bytes); + cb(WsEvent::Binary(bytes)); + } else { + // Blob — would need async FileReader, skip for now + } + } + }); + ws.set_onmessage(Some(onmsg_cb.as_ref().unchecked_ref())); + onmsg_cb.forget(); + } + Err(e) => { + set_status.set(WsStatus::Error( + js_sys::Error::from(e).to_string().as_string().unwrap_or_default(), + )); + } + } + } + + pub fn disconnect(&self) { + if let Some(ws) = self.ws.borrow_mut().take() { + ws.close().ok(); + } + self.set_status.set(WsStatus::Disconnected); + } + + pub fn send_text(&self, text: &str) -> Result<(), JsValue> { + if let Some(ws) = self.ws.borrow().as_ref() { + ws.send_with_str(text) + } else { + Err(JsValue::from_str("WebSocket not connected")) + } + } + + pub fn send_binary(&self, data: &[u8]) -> Result<(), JsValue> { + if let Some(ws) = self.ws.borrow().as_ref() { + let array = js_sys::Uint8Array::from(data); + let buffer = array.buffer(); + ws.send_with_array_buffer(&buffer) + } else { + Err(JsValue::from_str("WebSocket not connected")) + } + } +} + +// SAFETY: WsHandle uses Rc/RefCell for cheap cloning in a single-threaded WASM +// environment. The leptos reactive system requires provided contexts to be Send+Sync. +#[allow(unsafe_code)] +unsafe impl Send for WsHandle {} +#[allow(unsafe_code)] +unsafe impl Sync for WsHandle {} diff --git a/services/frontend-leptos/rust-toolchain.toml b/services/frontend-leptos/rust-toolchain.toml new file mode 100644 index 0000000..3aa3203 --- /dev/null +++ b/services/frontend-leptos/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "nightly-2026-06-01" +components = ["rust-src", "rustc-dev"] +targets = ["wasm32-unknown-unknown"] diff --git a/services/frontend-leptos/shared-types/Cargo.toml b/services/frontend-leptos/shared-types/Cargo.toml new file mode 100644 index 0000000..3f27442 --- /dev/null +++ b/services/frontend-leptos/shared-types/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "shared-types" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1", features = ["derive"] } diff --git a/services/frontend-leptos/shared-types/src/dashboard.rs b/services/frontend-leptos/shared-types/src/dashboard.rs new file mode 100644 index 0000000..e2be098 --- /dev/null +++ b/services/frontend-leptos/shared-types/src/dashboard.rs @@ -0,0 +1,87 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DashboardStats { + pub total_messages: u64, + pub total_users: u64, + pub total_flagged: u64, + pub total_clean: u64, + pub total_warned: u64, + pub total_error: u64, + pub total_voice_recordings: u64, + pub total_profiles: u64, + pub today_messages: u64, + pub today_flagged: u64, + pub active_users_24h: u64, + pub top_channels: Vec, + pub moderation_overview: ModerationOverview, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TopChannel { + pub channel_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub channel_name: Option, + pub message_count: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModerationOverview { + pub pending: u64, + pub processing: u64, + pub error: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DashboardUser { + pub user_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub avatar_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub profile_summary: Option, + pub total_messages: u64, + pub flagged_count: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_message_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub trust_score: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DashboardUserDetail { + #[serde(flatten)] + pub user: DashboardUser, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_analyzed_at: Option, + pub clean_message_streak: u64, + pub total_infractions: u64, + pub clean_count: u64, + pub recent_messages: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DashboardChannel { + pub channel_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub channel_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub guild_id: Option, + pub total_messages: u64, + pub flagged_count: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_message_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub culture_summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_analyzed_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DashboardChannelDetail { + #[serde(flatten)] + pub channel: DashboardChannel, + pub clean_count: u64, + pub recent_messages: Vec, +} diff --git a/services/frontend-leptos/shared-types/src/guild.rs b/services/frontend-leptos/shared-types/src/guild.rs new file mode 100644 index 0000000..85367b9 --- /dev/null +++ b/services/frontend-leptos/shared-types/src/guild.rs @@ -0,0 +1,36 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Guild { + pub id: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Channel { + pub id: String, + pub name: String, + #[serde(rename = "type")] + #[serde(skip_serializing_if = "Option::is_none")] + pub channel_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GuildVoiceEntry { + pub guild_id: String, + pub channel_id: String, + pub channel_name: String, + pub connected_at: i64, +} + +// ── Config ──────────────────────────────────────────────── +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub monitor_guild_id: Option, +} diff --git a/services/frontend-leptos/shared-types/src/lib.rs b/services/frontend-leptos/shared-types/src/lib.rs new file mode 100644 index 0000000..1ca4889 --- /dev/null +++ b/services/frontend-leptos/shared-types/src/lib.rs @@ -0,0 +1,7 @@ +pub mod message; +pub mod guild; +pub mod voice; +pub mod media; +pub mod dashboard; +pub mod recording; +pub mod ui_state; diff --git a/services/frontend-leptos/shared-types/src/media.rs b/services/frontend-leptos/shared-types/src/media.rs new file mode 100644 index 0000000..c175dd1 --- /dev/null +++ b/services/frontend-leptos/shared-types/src/media.rs @@ -0,0 +1,30 @@ +use serde::{Deserialize, Serialize}; + +pub type MediaMode = String; // "music" | "screen" + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MediaItem { + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + pub source: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mode: Option, + #[serde(rename = "durationMs")] + #[serde(skip_serializing_if = "Option::is_none")] + pub duration_ms: Option, + #[serde(rename = "thumbnailUrl")] + #[serde(skip_serializing_if = "Option::is_none")] + pub thumbnail_url: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MediaState { + pub playing: bool, + #[serde(rename = "musicVolume")] + pub music_volume: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub current: Option, + pub queue: Vec, +} diff --git a/services/frontend-leptos/shared-types/src/message.rs b/services/frontend-leptos/shared-types/src/message.rs new file mode 100644 index 0000000..1d9e696 --- /dev/null +++ b/services/frontend-leptos/shared-types/src/message.rs @@ -0,0 +1,170 @@ +use serde::{Deserialize, Serialize}; + +// ── AI Status ───────────────────────────────────────────── +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum AiStatus { + Pending, + Processing, + Clean, + Warn, + Flagged, + Error, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum AiSeverity { + None, + Low, + Medium, + High, + Critical, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum AiRecommendedAction { + None, + Monitor, + Warn, + Review, + Delete, + Escalate, +} + +// ── Message Metadata ────────────────────────────────────── +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct MessageMetadata { + #[serde(skip_serializing_if = "Option::is_none")] + pub stickers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub attachments: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub embeds: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub channel: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct StickerInfo { + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AttachmentRef { + pub name: String, + pub url: String, + #[serde(rename = "contentType")] + #[serde(skip_serializing_if = "Option::is_none")] + pub content_type: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct EmbedInfo { + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub image: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub thumbnail: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct EmbedMedia { + pub url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub width: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub height: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ChannelRef { + pub channel_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub channel_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub thread_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub thread_name: Option, +} + +// ── Message Record ──────────────────────────────────────── +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct MessageRecord { + pub id: String, + pub guild_id: String, + pub channel_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub thread_id: Option, + pub user_id: String, + pub username: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub avatar_url: Option, + pub content: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub edited_content: Option, + #[serde(rename = "type")] + pub msg_type: String, // "text" | "edited" | "deleted" + pub created_at: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub edited_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ai_status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ai_severity: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ai_confidence: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ai_moderation_flags: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub ai_moderation_score: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ai_analysis: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ai_categories: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub ai_recommended_action: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ai_error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ai_analyzed_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, +} + +// ── Pagination ──────────────────────────────────────────── +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PageResult { + pub data: Vec, + #[serde(rename = "nextCursor")] + pub next_cursor: Option, +} + +// ── Attachment ──────────────────────────────────────────── +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AttachmentRecord { + pub id: String, + pub message_id: String, + pub guild_id: String, + pub channel_id: String, + pub filename: String, + pub size: u64, + #[serde(rename = "type")] + pub mime_type: String, + pub discord_url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub uploaded_url: Option, + pub upload_status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub upload_error: Option, + pub created_at: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub uploaded_at: Option, +} diff --git a/services/frontend-leptos/shared-types/src/recording.rs b/services/frontend-leptos/shared-types/src/recording.rs new file mode 100644 index 0000000..5058069 --- /dev/null +++ b/services/frontend-leptos/shared-types/src/recording.rs @@ -0,0 +1,37 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VoiceRecording { + pub id: String, + pub user_id: String, + pub username: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub avatar_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub guild_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub channel_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub channel_name: Option, + pub filename: String, + pub size_bytes: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub download_url: Option, + pub upload_status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub upload_error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub transcription: Option, + pub created_at: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub uploaded_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VoiceRecordingListResponse { + pub items: Vec, + #[serde(rename = "nextCursor")] + pub next_cursor: Option, + #[serde(rename = "hasMore")] + pub has_more: bool, +} diff --git a/services/frontend-leptos/shared-types/src/ui_state.rs b/services/frontend-leptos/shared-types/src/ui_state.rs new file mode 100644 index 0000000..f0eb826 --- /dev/null +++ b/services/frontend-leptos/shared-types/src/ui_state.rs @@ -0,0 +1,29 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum Tab { + Messages, + Live, + Dashboard, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UiState { + #[serde(skip_serializing_if = "Option::is_none")] + pub selected_guild: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub selected_voice_guild: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub selected_voice_channel: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub selected_text_guild: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub selected_text_channel: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub active_tab: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_listening: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_streaming: Option, +} diff --git a/services/frontend-leptos/shared-types/src/voice.rs b/services/frontend-leptos/shared-types/src/voice.rs new file mode 100644 index 0000000..5d0329e --- /dev/null +++ b/services/frontend-leptos/shared-types/src/voice.rs @@ -0,0 +1,26 @@ +use serde::{Deserialize, Serialize}; +use crate::guild::GuildVoiceEntry; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VoiceStatus { + pub connected: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub active_guild_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub active_channel_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub active_channel_name: Option, + pub connections: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ActiveSpeaker { + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + pub user_id: String, + pub username: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub avatar: Option, + pub speaking: bool, +}