├── .cargo └── config.toml ├── .github ├── dependabot.yml └── workflows │ ├── build-site.yml │ └── rust-clippy.yml ├── .gitignore ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── Cargo.lock ├── Cargo.toml ├── Dioxus.toml ├── LICENSE ├── README.md ├── build.rs ├── index.html ├── public ├── 404.html └── js │ ├── darkmode.js │ └── ghpages_redirect.js ├── rust-toolchain.toml ├── scss ├── components │ ├── card.scss │ ├── components.scss │ ├── number_input.scss │ ├── select_form.scss │ ├── sidebar.scss │ ├── switch.scss │ ├── textarea_form.scss │ └── widget_view.scss ├── main.scss └── pages │ ├── base64_encoder.scss │ ├── cidr_decoder.scss │ ├── color_picker.scss │ ├── date_converter.scss │ ├── hash_generator.scss │ ├── home_page.scss │ ├── number_base_converter.scss │ ├── qr_code_generator.scss │ └── uuid_generator.scss └── src ├── assets.rs ├── components ├── accordion.rs ├── inputs.rs └── mod.rs ├── environment ├── desktop.rs ├── mod.rs └── web.rs ├── lib.rs ├── logger ├── mod.rs └── simple_logger.rs ├── main.rs ├── pages ├── converter │ ├── date_converter.rs │ ├── json_yaml_converter.rs │ ├── mod.rs │ └── number_base_converter.rs ├── encoder_decoder │ ├── base64_encoder.rs │ ├── cidr_decoder.rs │ └── mod.rs ├── generator │ ├── hash_generator.rs │ ├── mod.rs │ ├── qr_code_generator.rs │ └── uuid_generator.rs ├── home_page.rs ├── layout.rs ├── media │ ├── color_picker.rs │ └── mod.rs └── mod.rs └── utils.rs /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [target.'cfg(target_family = "wasm")'] 2 | rustflags = '--cfg getrandom_backend="wasm_js"' 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "cargo" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | - package-ecosystem: "github-actions" 13 | directory: "/.github/workflows/" 14 | schedule: 15 | interval: "weekly" 16 | -------------------------------------------------------------------------------- /.github/workflows/build-site.yml: -------------------------------------------------------------------------------- 1 | name: Build static site 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | workflow_dispatch: 7 | 8 | env: 9 | TARGET_DIR: ./target/dx/dev-widgets/release/web/public 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout repo 16 | uses: actions/checkout@v4 17 | 18 | # Install toolchain 19 | - name: Install Rust Toolchain 20 | uses: dtolnay/rust-toolchain@master 21 | with: 22 | toolchain: stable 23 | targets: wasm32-unknown-unknown 24 | - name: Configure Cargo cache 25 | uses: Swatinem/rust-cache@v2 26 | - name: Install dioxus-cli 27 | uses: taiki-e/install-action@v2 28 | with: 29 | tool: dioxus-cli 30 | 31 | # Build app 32 | - name: Build static site 33 | run: | 34 | dx build --platform web --release 35 | 36 | # Upload artifact 37 | - name: Fix permissions for artifact upload 38 | run: | 39 | chmod -c -R +rX "${{env.TARGET_DIR}}/" | while read line; do 40 | echo "::warning title=Invalid file permissions automatically fixed::$line" 41 | done 42 | - name: Upload Pages artifact 43 | uses: actions/upload-pages-artifact@v3 44 | with: 45 | path: ${{env.TARGET_DIR}} 46 | deploy: 47 | runs-on: ubuntu-latest 48 | needs: build 49 | 50 | # Grant GITHUB_TOKEN the permissions required to make a Pages deployment 51 | permissions: 52 | pages: write # to deploy to Pages 53 | id-token: write # to verify the deployment originates from an appropriate source 54 | 55 | # Deploy to the github-pages environment 56 | environment: 57 | name: github-pages 58 | url: ${{ steps.deployment.outputs.page_url }} 59 | 60 | steps: 61 | - name: Deploy to GitHub Pages 62 | id: deployment 63 | uses: actions/deploy-pages@v4 64 | -------------------------------------------------------------------------------- /.github/workflows/rust-clippy.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # rust-clippy is a tool that runs a bunch of lints to catch common 6 | # mistakes in your Rust code and help improve your Rust code. 7 | # More details at https://github.com/rust-lang/rust-clippy 8 | # and https://rust-lang.github.io/rust-clippy/ 9 | 10 | name: rust-clippy analyze 11 | 12 | on: 13 | push: 14 | branches: ["main"] 15 | pull_request: 16 | # The branches below must be a subset of the branches above 17 | branches: ["main"] 18 | schedule: 19 | - cron: "42 10 * * 2" 20 | 21 | jobs: 22 | rust-clippy-analyze: 23 | name: Run rust-clippy analyzing 24 | runs-on: ubuntu-latest 25 | permissions: 26 | contents: read 27 | security-events: write 28 | actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status 29 | steps: 30 | - name: Checkout code 31 | uses: actions/checkout@v4 32 | 33 | - name: Install Dioxus dependencies 34 | run: sudo apt update && sudo apt install libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev 35 | 36 | 37 | - name: Install Rust Toolchain 38 | uses: dtolnay/rust-toolchain@master 39 | with: 40 | toolchain: stable 41 | components: clippy 42 | 43 | - name: Configure Cargo cache 44 | uses: Swatinem/rust-cache@v2 45 | 46 | - name: Install required cargo 47 | run: cargo install clippy-sarif sarif-fmt 48 | 49 | - name: Run rust-clippy 50 | run: cargo clippy 51 | --all-features 52 | --message-format=json | clippy-sarif | tee rust-clippy-results.sarif | sarif-fmt 53 | continue-on-error: true 54 | 55 | - name: Upload analysis results to GitHub 56 | uses: github/codeql-action/upload-sarif@v3 57 | with: 58 | sarif_file: rust-clippy-results.sarif 59 | wait-for-processing: true 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | build 6 | 7 | # These are backup files generated by rustfmt 8 | **/*.rs.bk 9 | 10 | # MSVC Windows builds of rustc generate these, which store debugging information 11 | *.pdb 12 | 13 | # Bootstrap 14 | bootstrap/ 15 | public/style/ 16 | public/js/[!darkmode.js,!ghpages_redirect.js]* 17 | 18 | # macOS files 19 | .DS_Store 20 | 21 | # Dioxus 22 | dioxusin 23 | dist/ 24 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "rust-lang.rust-analyzer", 4 | "vadimcn.vscode-lldb", 5 | "ms-vscode.cpptools", 6 | "tamasfe.even-better-toml", 7 | "dioxuslabs.dioxus" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "lldb", 9 | "request": "launch", 10 | "name": "Cargo run", 11 | "cargo": { 12 | "args": [ 13 | "build" 14 | ] 15 | }, 16 | "args": [] 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "debug.allowBreakpointsEverywhere": true, 3 | "rust-analyzer.check.command": "clippy", 4 | "rust-analyzer.linkedProjects": [ 5 | "./Cargo.toml" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "dev-widgets" 3 | version = "0.3.0" 4 | edition = "2021" 5 | description = "Dev Widgets" 6 | readme = "README.md" 7 | build = "build.rs" 8 | 9 | [build-dependencies] 10 | curl = "0.4" 11 | grass = "0.13" 12 | zip-extract = "0.4" 13 | 14 | [dependencies] 15 | base64ct = { version = "1.7", features = ["alloc"] } 16 | cidr = "0.3.0" 17 | digest = "0.10" 18 | dioxus-free-icons = { version = "0.9", features = ["bootstrap"] } 19 | dioxus-router = "0.6.3" 20 | md-5 = "0.10" 21 | num-traits = "0.2" 22 | qrcode-generator = "5.0.0" 23 | sha1 = "0.10" 24 | sha2 = "0.10" 25 | strum = "0.27" 26 | strum_macros = "0.27" 27 | time-tz = { version = "2.0", features = ["db", "system"] } 28 | log = { version = "0.4", features = ["std"] } 29 | color_processing = "0.6" 30 | manganis = "0.6.2" 31 | 32 | [target.'cfg(not(target_family = "wasm"))'.dependencies] 33 | dioxus = {version = "0.6.3", features = ["desktop"]} 34 | uuid = { version = "1.17", features = ["v4", "v7", "rng-getrandom"] } 35 | getrandom = "0.3" 36 | time = "0.3" 37 | 38 | [target.'cfg(target_family = "wasm")'.dependencies] 39 | dioxus = {version = "0.6.1", features = ["web"]} 40 | getrandom = { version = "0.3", features = ["wasm_js"] } 41 | time = { version = "0.3", features = ["wasm-bindgen"] } 42 | wasm-bindgen = { version = "0.2.100", features = ["enable-interning"] } 43 | wasm-logger = "0.2.0" 44 | uuid = { version = "1.17", features = ["v4", "v7", "rng-getrandom", "js"] } 45 | 46 | 47 | [profile.release] 48 | lto = true # Use Link-Time-Optimization 49 | codegen-units = 1 # Fewer codegen units to increase optimizations. 50 | panic = "abort" # Aborting strips the stack unwind code from the binary. 51 | 52 | [profile.wasm-dev] 53 | inherits = "dev" 54 | opt-level = 1 55 | 56 | [profile.server-dev] 57 | inherits = "dev" 58 | 59 | [profile.android-dev] 60 | inherits = "dev" 61 | 62 | [target.'cfg(not(any(target_os = "windows", target_os = "none")))'.profile.release] 63 | strip = true # Automatically strip symbols from the binary. Only available for *nix targets. 64 | 65 | [package.metadata.bundle] 66 | name = "Dev Widgets" 67 | identifier = "com.esimkowitz.devwidgets" 68 | version = "0.3.0" 69 | resources = ["public/**/*"] 70 | copyright = "Copyright (c) Evan Simkowitz 2025. All rights reserved." 71 | category = "Developer Tool" 72 | short_description = "A set of helpful widgets written in Rust." 73 | osx_url_schemes = ["com.esimkowitz.devwidgets"] 74 | -------------------------------------------------------------------------------- /Dioxus.toml: -------------------------------------------------------------------------------- 1 | [application] 2 | 3 | # dioxus project name 4 | name = "Dev Widgets" 5 | 6 | # default platfrom 7 | # you can also use `dioxus serve/build --platform XXX` to use other platform 8 | # value: web | desktop 9 | default_platform = "web" 10 | 11 | # Web `build` & `serve` dist path 12 | out_dir = "dist" 13 | 14 | # resource (static) file folder 15 | asset_dir = "public" 16 | 17 | [web.app] 18 | 19 | # HTML title tag content 20 | title = "Dev Widgets" 21 | 22 | [web.watcher] 23 | 24 | watch_path = ["src", "scss", "index.html"] 25 | reload_html = true 26 | index_on_404 = true 27 | 28 | # include `assets` in web platform 29 | [web.resource] 30 | 31 | # CSS style file 32 | style = [] 33 | 34 | # Javascript code file 35 | script = [] 36 | 37 | [web.resource.dev] 38 | 39 | # Javascript code file 40 | # serve: [dev-server] only 41 | script = [] 42 | 43 | [application.tools] 44 | 45 | [bundle] 46 | identifier = "com.esimkowitz.devwidgets" 47 | publisher = "Evan Simkowitz" 48 | resources = ["public/**/*"] 49 | copyright = "Copyright (c) Evan Simkowitz 2024. All rights reserved." 50 | category = "Developer Tool" 51 | short_description = "A set of helpful widgets written in Rust." 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Evan Simkowitz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dev-widgets 2 | 3 | ## Overview 4 | 5 | Collection of useful conversions and widgets built as a pure Rust app on the [Dioxus framework](https://github.com/DioxusLabs/dioxus). 6 | 7 | The following widgets are now stable: 8 | 9 | * Number Base Converter 10 | * Base64 Encoder/Decoder 11 | * QR Code Generator 12 | * Date/Timestamp Converter 13 | * UUID Generator 14 | * Hash Generator 15 | * CIDR Decoder 16 | * Color Picker 17 | 18 | ## Development Setup 19 | 20 | If you haven't already, [install Rust](https://www.rust-lang.org/tools/install). 21 | 22 | Follow the Dioxus instructions to [install platform-specific dependencies](https://dioxuslabs.com/docs/0.3/guide/en/getting_started/desktop.html#platform-specific-dependencies). 23 | 24 | Install the Dioxus CLI: 25 | 26 | ```bash 27 | cargo install dioxus-cli 28 | ``` 29 | 30 | Clone this repository and enter its root directory. 31 | 32 | ## Desktop App 33 | 34 | The primary target for this project is a universal desktop app. Currently, it has been manually validated on macOS and Windows. I plan to setup automated releases soon. 35 | 36 | ### Run app from command-line 37 | 38 | Run `cargo run` to start the application. The first build should take a couple minutes as it fetches Bootstrap and all other Rust packages, subsequent builds should only take a few seconds. 39 | 40 | If you would like to enable hot-reloading, you can do so by setting the `USE_HOT_RELOAD` flag in [main.rs](src/main.rs). This is only necessary for the desktop app, hot-reload is on by default for web development. 41 | 42 | ### Bundle app 43 | 44 | You can bundle the app into an executable for your platform using the Dioxus CLI 45 | 46 | ```bash 47 | dx bundle --platform desktop --release 48 | ``` 49 | 50 | ## Web App 51 | 52 | [![Build static site](https://github.com/esimkowitz/dev-widgets/actions/workflows/build-site.yml/badge.svg)](https://github.com/esimkowitz/dev-widgets/actions/workflows/build-site.yml) 53 | 54 | Dev Widgets now works as a web app! You can find it hosted at . It will stay up to date with the main branch using GitHub Actions. 55 | 56 | ### Run from command line - Dioxus CLI 57 | 58 | You can run the web app locally using the [dioxus-cli](https://github.com/DioxusLabs/dioxus/tree/master/packages/cli): 59 | 60 | ```bash 61 | dx serve --platform web 62 | ``` 63 | 64 | This will automatically enable hot-reloading for any changes you make to the source code. 65 | 66 | ### Validate release buld 67 | 68 | When packaging for release, I use the Dioxus CLI: 69 | 70 | ```bash 71 | dx build --platform web --release 72 | ``` 73 | 74 | ## Roadmap 75 | 76 | This app is heavily inspired by [DevToys](https://github.com/veler/DevToys) and [DevToysMac](https://github.com/ObuchiYuki/DevToysMac) and my roadmap for widgets I plan to support will align with these projects. 77 | 78 | Currently, I have only validated on macOS, and performed very crude validations on Windows, though I now have a fairly stable programming model so I plan to set up some automated testing for macOS, Windows, and Linux soon, as well as start publishing releases. 79 | 80 | I also plan to publish this as a single-page application using dioxus-web and Github Pages. 81 | 82 | I will be tracking new development in the [dev-widgets project board](https://github.com/users/esimkowitz/projects/2). New widgets will be organized under the "Widgets" area, and all other development will be tracked under the "Platform" area. 83 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::fs::File; 3 | use std::io::Write; 4 | use std::panic; 5 | use std::path::{Path, PathBuf}; 6 | 7 | fn main() { 8 | // Tell Cargo to rerun the build script if it changes. 9 | println!("cargo:rerun-if-changed=$CARGO_MANIFEST_DIR/scss"); 10 | 11 | env::set_var("RUST_BACKTRACE", "1"); 12 | 13 | let cargo_manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); 14 | let cargo_manifest_dir = cargo_manifest_dir.as_str(); 15 | 16 | // Install Bootstrap 17 | let bs_fetch_result = panic::catch_unwind(|| { 18 | // Download Bootstrap archive 19 | let mut bootstrap_zip = Vec::new(); 20 | let mut curl_handle = curl::easy::Easy::new(); 21 | curl_handle 22 | .url("https://github.com/twbs/bootstrap/archive/v5.3.0.zip") 23 | .unwrap(); 24 | curl_handle.follow_location(true).unwrap(); 25 | { 26 | let mut transfer = curl_handle.transfer(); 27 | transfer 28 | .write_function(|new_data| { 29 | bootstrap_zip.extend_from_slice(new_data); 30 | Ok(new_data.len()) 31 | }) 32 | .unwrap(); 33 | transfer.perform().unwrap(); 34 | } 35 | let response_code = curl_handle.response_code().unwrap(); 36 | let vec_len = bootstrap_zip.len(); 37 | println!("response code: {response_code}, vec length: {vec_len}"); 38 | 39 | // Extract Bootstrap archive 40 | let bootstrap_extract_target_dir: PathBuf = 41 | [cargo_manifest_dir, "bootstrap"].iter().collect(); 42 | zip_extract::extract( 43 | std::io::Cursor::new(bootstrap_zip), 44 | &bootstrap_extract_target_dir, 45 | true, 46 | ) 47 | .unwrap(); 48 | 49 | // Copy Bootstrap JS files 50 | let bootstrap_js_filename = "bootstrap.min.js"; 51 | let bootstrap_js_origin_path: PathBuf = 52 | ["dist", "js", bootstrap_js_filename].iter().collect(); 53 | let bootstrap_js_origin_path = bootstrap_extract_target_dir.join(bootstrap_js_origin_path); 54 | 55 | let bootstrap_js_target_path: PathBuf = 56 | [cargo_manifest_dir, "public", "js", bootstrap_js_filename] 57 | .iter() 58 | .collect(); 59 | 60 | // Create js path if it does not already exist 61 | create_dir_all(&bootstrap_js_target_path); 62 | std::fs::copy(bootstrap_js_origin_path, bootstrap_js_target_path).unwrap(); 63 | }); 64 | 65 | if let Err(err) = bs_fetch_result { 66 | println!("{:?}", err) 67 | } 68 | 69 | // Compile Sass 70 | { 71 | let grass_input_path: PathBuf = [cargo_manifest_dir, "scss", "main.scss"].iter().collect(); 72 | 73 | let grass_output_path: PathBuf = [cargo_manifest_dir, "public", "style", "style.css"] 74 | .iter() 75 | .collect(); 76 | 77 | // Create grass output path if it does not already exist 78 | create_dir_all(&grass_output_path); 79 | 80 | let mut grass_output_file = File::create(&grass_output_path).unwrap(); 81 | 82 | // We want to compress the output CSS in release builds, but not in debug builds. 83 | let grass_output_style = cfg!(debug_assertions) 84 | .then(|| grass::OutputStyle::Expanded) 85 | .unwrap_or(grass::OutputStyle::Compressed); 86 | let grass_options = grass::Options::default().style(grass_output_style); 87 | grass_output_file 88 | .write_all( 89 | grass::from_path(grass_input_path, &grass_options) 90 | .unwrap() 91 | .as_bytes(), 92 | ) 93 | .unwrap(); 94 | } 95 | } 96 | 97 | fn create_dir_all(dir: &Path) { 98 | std::fs::create_dir_all(dir.parent().unwrap()).unwrap(); 99 | } 100 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Dev Widgets 6 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /public/js/darkmode.js: -------------------------------------------------------------------------------- 1 | // Set theme to the user's preferred color scheme 2 | function updateTheme() { 3 | const colorMode = window.matchMedia("(prefers-color-scheme: dark)").matches ? 4 | "dark" : 5 | "light"; 6 | document.querySelector("html").setAttribute("data-bs-theme", colorMode); 7 | } 8 | 9 | // Set theme on load 10 | updateTheme() 11 | 12 | // Update theme when the preferred scheme changes 13 | window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateTheme) -------------------------------------------------------------------------------- /public/js/ghpages_redirect.js: -------------------------------------------------------------------------------- 1 | (function(l) { 2 | if (l.search[1] === '/' ) { 3 | var decoded = l.search.slice(1).split('&').map(function(s) { 4 | return s.replace(/~and~/g, '&') 5 | }).join('?'); 6 | window.history.replaceState(null, null, 7 | l.pathname.slice(0, -1) + decoded + l.hash 8 | ); 9 | } 10 | }(window.location)) -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "stable" 3 | components = ["rustfmt", "rustc", "clippy", "cargo"] 4 | targets = ["wasm32-unknown-unknown"] 5 | profile = "minimal" 6 | -------------------------------------------------------------------------------- /scss/components/card.scss: -------------------------------------------------------------------------------- 1 | /* Taken from https://ordinarycoders.com/blog/article/codepen-bootstrap-card-hovers */ 2 | .card { 3 | border-radius: 4px; 4 | background: var(--bs-body-bg); 5 | box-shadow: 0 6px 10px rgba(0, 0, 0, 0.08), 0 0 6px rgba(0, 0, 0, 0.05); 6 | transition: 0.3s transform cubic-bezier(0.155, 1.105, 0.295, 1.12), 7 | 0.3s box-shadow, 8 | 0.3s -webkit-transform cubic-bezier(0.155, 1.105, 0.295, 1.12); 9 | padding: 14px 80px 18px 36px; 10 | cursor: pointer; 11 | } 12 | 13 | .card:hover { 14 | transform: scale(1.02); 15 | box-shadow: 0 10px 20px rgba(0, 0, 0, 0.12), 0 4px 8px rgba(0, 0, 0, 0.06); 16 | } 17 | 18 | .card-body { 19 | width: 100%; 20 | } 21 | -------------------------------------------------------------------------------- /scss/components/components.scss: -------------------------------------------------------------------------------- 1 | @import "textarea_form.scss"; 2 | @import "number_input.scss"; 3 | @import "select_form.scss"; 4 | @import "switch.scss"; 5 | @import "card.scss"; 6 | @import "widget_view.scss"; 7 | @import "sidebar.scss"; -------------------------------------------------------------------------------- /scss/components/number_input.scss: -------------------------------------------------------------------------------- 1 | div.number-input { 2 | .input-and-label { 3 | @extend .form-floating; 4 | input { 5 | @extend .form-control, .h-100; 6 | } 7 | } 8 | 9 | .inc-dec-buttons { 10 | @extend .btn-group-vertical, .input-group-text, .p-1; 11 | 12 | button { 13 | @extend .btn, .btn-outline-secondary, .p-0; 14 | 15 | &:hover { 16 | @extend .btn-outline-primary; 17 | } 18 | } 19 | } 20 | 21 | input::-webkit-inner-spin-button { 22 | -webkit-appearance: none; 23 | margin: 0; 24 | } 25 | 26 | input[type="number"] { 27 | -moz-appearance: textfield; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /scss/components/select_form.scss: -------------------------------------------------------------------------------- 1 | div.select-form { 2 | @extend .form-floating; 3 | select { 4 | @extend .form-select, .h-100; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /scss/components/sidebar.scss: -------------------------------------------------------------------------------- 1 | div.sidebar { 2 | @extend .flex-row, .d-flex; 3 | width: 15em; 4 | 5 | .sidebar-list { 6 | overflow-y: scroll; 7 | width: 100%; 8 | 9 | .btn { 10 | @extend .btn-sm; 11 | width: 100%; 12 | text-align: left; 13 | white-space: nowrap; 14 | 15 | .icon { 16 | height: 1em; 17 | width: 1em; 18 | margin-right: 0.5em; 19 | margin-top: -0.25em; 20 | } 21 | } 22 | .accordion { 23 | @extend .accordion-flush, .flex-column, .ms-2, .mb-2, .pt-2, .pe-3; 24 | } 25 | .accordion-button:not(.collapsed) { 26 | background-color: var(--bs-body-bg); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /scss/components/switch.scss: -------------------------------------------------------------------------------- 1 | div.switch-input { 2 | @extend .form-check, .form-switch; 3 | input[type="checkbox"] { 4 | @extend .form-check-input; 5 | border-radius: 0.5em; 6 | } 7 | label { 8 | @extend .form-check-label; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /scss/components/textarea_form.scss: -------------------------------------------------------------------------------- 1 | div.textarea-form { 2 | @extend .form-floating; 3 | textarea { 4 | @extend .form-control, .h-100; 5 | font-family: "Monaco", Courier, monospace; 6 | letter-spacing: 0.05rem; 7 | } 8 | 9 | textarea::-webkit-resizer { 10 | display: none; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /scss/components/widget_view.scss: -------------------------------------------------------------------------------- 1 | div.widget-view { 2 | @extend .ps-3, .pe-3, .w-100, .h-100; 3 | overflow-y: scroll; 4 | overflow-x: clip; 5 | 6 | $widget-title-left-overflow: 0.5em; 7 | .widget-title { 8 | @extend .sticky-top, .mb-0, .pt-1, .pb-1; 9 | background-color: var(--bs-body-bg); 10 | width: calc(100% + (2 * #{$widget-title-left-overflow})); 11 | z-index: 100; 12 | /* prevent widget body elements that may overflow the margin from appearing behind the title bar */ 13 | margin-left: -$widget-title-left-overflow; 14 | margin-right: 0; 15 | padding-left: $widget-title-left-overflow; 16 | } 17 | 18 | .widget-body { 19 | @extend .pt-2, .w-100; 20 | height: 1px; 21 | min-height: calc(100% - 3em); 22 | 23 | > div { 24 | @extend .pb-3; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /scss/main.scss: -------------------------------------------------------------------------------- 1 | @import "../bootstrap/scss/bootstrap"; 2 | $spacer: 1em; 3 | 4 | html { 5 | overscroll-behavior: none; 6 | } 7 | 8 | html::-webkit-scrollbar { 9 | display: none; 10 | } 11 | 12 | body { 13 | margin: unset; 14 | padding: unset; 15 | font-family: Arial, Helvetica, sans-serif; 16 | user-select: none; 17 | -webkit-user-select: none; 18 | overflow-y: scroll; 19 | } 20 | 21 | div.container-fluid { 22 | @extend .d-flex, .flex-row, .wrapper, .pe-0; 23 | width: 100vw; 24 | height: 100vh; 25 | overflow-y: scroll; 26 | justify-content: flex-start; 27 | } 28 | 29 | div.container-fluid::-webkit-scrollbar { 30 | display: none; 31 | } 32 | 33 | @import "components/components.scss"; 34 | @import "pages/home_page.scss"; 35 | @import "pages/base64_encoder.scss"; 36 | @import "pages/color_picker.scss"; 37 | @import "pages/cidr_decoder.scss"; 38 | @import "pages/date_converter.scss"; 39 | @import "pages/hash_generator.scss"; 40 | @import "pages/number_base_converter.scss"; 41 | @import "pages/qr_code_generator.scss"; 42 | @import "pages/uuid_generator.scss"; 43 | -------------------------------------------------------------------------------- /scss/pages/base64_encoder.scss: -------------------------------------------------------------------------------- 1 | .base64-encoder { 2 | @extend .d-flex, .flex-column, .row-gap-3, .h-100; 3 | .textarea-form { 4 | flex: 1 1 auto; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /scss/pages/cidr_decoder.scss: -------------------------------------------------------------------------------- 1 | div.cidr-decoder { 2 | @extend .d-flex, .flex-column, .row-gap-3, .h-100; 3 | .textarea-form { 4 | flex: 1 1 auto; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /scss/pages/color_picker.scss: -------------------------------------------------------------------------------- 1 | div.color-picker { 2 | $colorwheel_diameter: 30em; 3 | 4 | width: $colorwheel_diameter; 5 | 6 | .color-picker-inner { 7 | position: relative; 8 | .colorwheel-wrapper { 9 | width: $colorwheel_diameter; 10 | height: $colorwheel_diameter; 11 | 12 | .colorwheel { 13 | height: 100%; 14 | width: 100%; 15 | position: relative; 16 | user-select: none; 17 | display: block; 18 | 19 | .colorwheel-svg { 20 | position: absolute; 21 | z-index: 10; 22 | } 23 | 24 | .colorwheel-gradient { 25 | height: 100%; 26 | width: 100%; 27 | background: conic-gradient( 28 | from 90deg, 29 | rgb(255 0 0), 30 | rgb(255 0 255), 31 | rgb(0 0 255), 32 | rgb(0 255 255), 33 | rgb(0 255 0), 34 | rgb(255 255 0), 35 | rgb(255, 0, 0) 36 | ); 37 | } 38 | 39 | .colorwheel-cursor { 40 | position: relative; 41 | height: $colorwheel_diameter; 42 | width: $colorwheel_diameter; 43 | z-index: 11; 44 | } 45 | } 46 | } 47 | 48 | $saturation_brightness_size: calc($colorwheel_diameter * 0.5); 49 | .saturation-brightness-wrapper { 50 | position: absolute; 51 | display: block; 52 | height: $saturation_brightness_size; 53 | width: $saturation_brightness_size; 54 | top: calc($saturation_brightness_size * 0.5); 55 | left: calc($saturation_brightness_size * 0.5); 56 | 57 | .saturation-brightness-box { 58 | position: relative; 59 | z-index: 10; 60 | height: 100%; 61 | width: 100%; 62 | 63 | .saturation-brightness-gradient { 64 | position: absolute; 65 | height: 100%; 66 | width: 100%; 67 | background-image: linear-gradient(to top, #000 0%, transparent 100%), 68 | linear-gradient(to right, #fff 0%, transparent 100%); 69 | } 70 | .saturation-brightness-cursor { 71 | position: absolute; 72 | } 73 | } 74 | } 75 | } 76 | 77 | --color-view-background: white; 78 | $color_view_height: 2rem; 79 | .color-view { 80 | margin-top: 1rem; 81 | display: flex; 82 | flex-direction: row; 83 | height: calc($color_view_height * 2); 84 | .color-view-display { 85 | margin: auto; 86 | height: $color_view_height; 87 | width: $color_view_height; 88 | border-radius: calc($color_view_height * 0.5); 89 | background-color: var(--color-view-background); 90 | border: solid var(--bs-border-width) var(--bs-border-color); 91 | } 92 | .text-input, .select-form { 93 | margin: auto; 94 | flex-grow: 1; 95 | margin-left: 1rem; 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /scss/pages/date_converter.scss: -------------------------------------------------------------------------------- 1 | div.date-converter { 2 | @extend .d-grid, .column, .row-gap-3; 3 | 4 | .selectors-wrapper { 5 | @extend .flex-row, .d-flex, .row-gap-3, .column-gap-2; 6 | 7 | flex-wrap: nowrap; 8 | 9 | .selectors { 10 | @extend .flex-column; 11 | .selectors-inner { 12 | @extend .flex-row, .d-flex, .flex-nowrap, .column-gap-2; 13 | .number-input { 14 | @extend .flex-column, .flex-fill; 15 | } 16 | } 17 | } 18 | } 19 | 20 | @media screen and (max-width: 835px) { 21 | .selectors-wrapper { 22 | flex-wrap: wrap; 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /scss/pages/hash_generator.scss: -------------------------------------------------------------------------------- 1 | div.hash-generator { 2 | @extend .d-grid, .column, .row-gap-3; 3 | .textarea-form { 4 | height: 20em; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /scss/pages/home_page.scss: -------------------------------------------------------------------------------- 1 | .home-page { 2 | @extend .d-flex, .flex-row, .flex-wrap, .gap-2; 3 | padding-bottom: 1em; 4 | 5 | .card { 6 | @extend .p-0; 7 | width: 10em; 8 | height: 14em; 9 | 10 | .card-img-top { 11 | @extend .text-center; 12 | margin-top: 1em; 13 | } 14 | 15 | .icon { 16 | height: 3em; 17 | width: 3em; 18 | } 19 | 20 | .card-title { 21 | @extend .text-start; 22 | font-size: 1.2em; 23 | font-weight: bold; 24 | } 25 | .card-body { 26 | @extend .text-start; 27 | font-size: 0.8em; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /scss/pages/number_base_converter.scss: -------------------------------------------------------------------------------- 1 | div.number-base-converter { 2 | @extend .d-grid, .column, .row-gap-3; 3 | } 4 | -------------------------------------------------------------------------------- /scss/pages/qr_code_generator.scss: -------------------------------------------------------------------------------- 1 | .qr-code-generator { 2 | @extend .d-grid, .column, .row-gap-3; 3 | .textarea-form { 4 | height: 14em; 5 | } 6 | .qr-code { 7 | width: 30em; 8 | height: 30em; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /scss/pages/uuid_generator.scss: -------------------------------------------------------------------------------- 1 | div.uuid-generator { 2 | @extend .d-flex, .flex-column, .row-gap-3, .h-100; 3 | .params { 4 | @extend .d-flex, .flex-row, .column-gap-3, .row-gap-3, .flex-wrap; 5 | >div:not(.switches) { 6 | @extend .d-flex, .flex-grow-1; 7 | min-width: 225px; 8 | } 9 | 10 | .switches { 11 | @extend .d-flex, .flex-column, .row-gap-3; 12 | .switch { 13 | @extend .column-gap-1; 14 | } 15 | } 16 | } 17 | .textarea-form { 18 | flex: 1 1 auto; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/assets.rs: -------------------------------------------------------------------------------- 1 | use manganis::*; 2 | 3 | pub const CSS: Asset = asset!("/public/style/style.css"); 4 | pub const BOOTSTRAP: Asset = asset!("/public/js/bootstrap.min.js"); 5 | pub const DARKMODE: Asset = asset!("/public/js/darkmode.js"); 6 | pub const GHPAGES: Asset = asset!("/public/js/ghpages_redirect.js"); -------------------------------------------------------------------------------- /src/components/accordion.rs: -------------------------------------------------------------------------------- 1 | #![allow(non_snake_case)] 2 | use dioxus::prelude::*; 3 | 4 | #[component] 5 | pub fn Accordion( 6 | children: Element, 7 | title: String, 8 | is_open: Option, 9 | ) -> Element { 10 | let default_open_flag = !is_open.unwrap_or(false); 11 | let mut is_close_accordion = use_signal(|| default_open_flag); 12 | let buttoncss = if *is_close_accordion.read() { 13 | "accordion-button p-2 collapsed" 14 | } else { 15 | "accordion-button p-2" 16 | }; 17 | let accordioncss = if *is_close_accordion.read() { 18 | "accordion-collapse collapse" 19 | } else { 20 | "accordion-collapse collapse show" 21 | }; 22 | rsx! { 23 | div { 24 | class: "accordion-item", 25 | h3 { 26 | class: "accordion-header", 27 | button { 28 | class: "{buttoncss}", 29 | r#type: "button", 30 | aria_expanded: "{!*is_close_accordion.read()}", 31 | onclick: move |_| { 32 | is_close_accordion.with_mut(|flag| *flag = !*flag); 33 | }, 34 | {title} 35 | } 36 | } 37 | div { 38 | class: "{accordioncss}", 39 | div { 40 | class: "accordion-body p-0", 41 | {children} 42 | } 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/components/inputs.rs: -------------------------------------------------------------------------------- 1 | #![allow(non_snake_case)] 2 | use std::fmt::{Debug, Display}; 3 | use std::str::FromStr; 4 | 5 | use dioxus::prelude::*; 6 | use dioxus_free_icons::{ 7 | icons::bs_icons::{BsDash, BsPlus}, 8 | Icon, 9 | }; 10 | use num_traits::PrimInt; 11 | use strum::IntoEnumIterator; 12 | 13 | pub trait SelectFormEnum: 14 | IntoEnumIterator 15 | + Into 16 | + FromStr 17 | + Default 18 | + Debug 19 | + Display 20 | + Copy 21 | + Clone 22 | + PartialEq 23 | { 24 | } 25 | 26 | pub fn SelectForm(props: SelectFormProps) -> Element { 27 | rsx! { 28 | div { 29 | class: "select-form", 30 | select { 31 | id: "{props.label}", 32 | aria_label: "{props.label}", 33 | oninput: move |event| if let Ok(value) = event.parsed::() { 34 | props.oninput.call(value); 35 | }, 36 | for enumInst in T::iter() { 37 | option { 38 | value: "{enumInst.into()}", 39 | selected: enumInst == props.value, 40 | "{enumInst.into()}" 41 | } 42 | } 43 | } 44 | label { 45 | r#for: "{props.label}", 46 | "{props.label}" 47 | } 48 | } 49 | } 50 | } 51 | 52 | #[derive(Props, Clone, PartialEq)] 53 | pub struct SelectFormProps { 54 | label: String, 55 | value: T, 56 | oninput: EventHandler, 57 | } 58 | 59 | #[component] 60 | pub fn SwitchInput( 61 | label: String, 62 | checked: bool, 63 | oninput: EventHandler, 64 | ) -> Element { 65 | rsx! { 66 | div { 67 | class: "switch-input", 68 | input { 69 | r#type: "checkbox", 70 | id: "{label}", 71 | role: "switch", 72 | checked: "{checked}", 73 | oninput: move |event| { 74 | let is_enabled = event.checked(); 75 | oninput.call(is_enabled); 76 | } 77 | } 78 | label { 79 | r#for: "{label}", 80 | "{label}" 81 | } 82 | } 83 | } 84 | } 85 | 86 | #[component] 87 | pub fn TextAreaForm( 88 | class: Option, 89 | value: String, 90 | label: String, 91 | readonly: Option, 92 | oninput: Option>>, 93 | onchange: Option>>, 94 | ) -> Element { 95 | let readonly = readonly.unwrap_or(false); 96 | let classLocal: String = class.unwrap_or_default(); 97 | rsx! { 98 | div { 99 | class: "textarea-form {classLocal}", 100 | id: "{label}", 101 | textarea { 102 | value: "{value}", 103 | oninput: move |event| if let Some(oninput) = oninput { 104 | oninput.call(event); 105 | }, 106 | onchange: move |event| if let Some(onchange) = onchange { 107 | onchange.call(event); 108 | }, 109 | readonly: readonly, 110 | } 111 | label { 112 | r#for: "{label}", 113 | {label.clone()} 114 | } 115 | } 116 | } 117 | } 118 | 119 | #[component] 120 | pub fn TextInput( 121 | value: String, 122 | label: String, 123 | oninput: Option>>, 124 | onchange: Option>>, 125 | readonly: Option, 126 | ) -> Element { 127 | let readonly = readonly.unwrap_or(false); 128 | 129 | rsx! { 130 | div { 131 | class: "text-input", 132 | div { 133 | class: "form-floating", 134 | input { 135 | class: "form-control", 136 | r#type: "text", 137 | value: "{value}", 138 | oninput: move |event| { 139 | if let Some(oninput) = oninput { 140 | oninput.call(event); 141 | } 142 | }, 143 | onchange: move |event| { 144 | if let Some(onchange) = onchange { 145 | onchange.call(event); 146 | } 147 | }, 148 | readonly: readonly 149 | } 150 | label { 151 | r#for: "{label}", 152 | {label.clone()} 153 | } 154 | } 155 | } 156 | } 157 | } 158 | 159 | #[component] 160 | pub fn NumberInput( 161 | class: Option<&'static str>, 162 | value: T, 163 | label: &'static str, 164 | onchange: EventHandler, 165 | ) -> Element { 166 | rsx! { 167 | div { 168 | class: "number-input {class.unwrap_or_default()}", 169 | div { 170 | class: "input-group", 171 | div { 172 | class: "input-and-label", 173 | input { 174 | r#type: "number", 175 | value: "{value}", 176 | id: "{label}", 177 | onchange: move |event| if let Ok(value) = event.parsed::() { 178 | onchange.call(value); 179 | } 180 | } 181 | label { 182 | r#for: "{label}", 183 | {label} 184 | } 185 | } 186 | div { 187 | class: "inc-dec-buttons", 188 | button { 189 | onclick: move |_| if let Some(value) = value.checked_add(&T::one()) { 190 | onchange.call(value); 191 | }, 192 | Icon { 193 | icon: BsPlus, 194 | class: "button-icon", 195 | height: 15, 196 | width: 15, 197 | } 198 | } 199 | button { 200 | onclick: move |_| if let Some(value) = value.checked_sub(&T::one()) { 201 | onchange.call(value); 202 | }, 203 | Icon { 204 | icon: BsDash, 205 | class: "button-icon", 206 | height: 15, 207 | width: 15, 208 | } 209 | } 210 | } 211 | } 212 | } 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /src/components/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod accordion; 2 | pub mod inputs; 3 | -------------------------------------------------------------------------------- /src/environment/desktop.rs: -------------------------------------------------------------------------------- 1 | use dioxus::{ 2 | desktop::{Config, WindowBuilder, LogicalSize}, 3 | dioxus_core::Element, 4 | prelude::LaunchBuilder, 5 | }; 6 | 7 | pub fn init_app(root: fn() -> Element) { 8 | // Configure dioxus-desktop Tauri window 9 | let config_builder = Config::default().with_custom_index( 10 | r#" 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | "# 23 | .to_string(), 24 | ); 25 | 26 | let window_builder = WindowBuilder::new().with_default(); 27 | 28 | // Launch the app 29 | LaunchBuilder::desktop() 30 | .with_cfg(config_builder.with_window(window_builder)) 31 | .launch(root) 32 | } 33 | 34 | trait WindowBuilderExt { 35 | fn with_default(self) -> Self; 36 | } 37 | 38 | impl WindowBuilderExt for WindowBuilder { 39 | /// Set default window settings 40 | fn with_default(self) -> Self { 41 | self.with_title("Dev Widgets") 42 | .with_resizable(true) 43 | .with_inner_size(LogicalSize::new(800.0, 800.0)) 44 | .with_min_inner_size(LogicalSize::new(600.0, 300.0)) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/environment/mod.rs: -------------------------------------------------------------------------------- 1 | use dioxus::dioxus_core::Element; 2 | 3 | #[cfg(not(target_family = "wasm"))] 4 | mod desktop; 5 | 6 | #[cfg(target_family = "wasm")] 7 | mod web; 8 | 9 | pub fn init(root: fn() -> Element) { 10 | #[cfg(not(target_family = "wasm"))] 11 | desktop::init_app(root); 12 | 13 | #[cfg(target_family = "wasm")] 14 | web::init_app(root); 15 | } 16 | -------------------------------------------------------------------------------- /src/environment/web.rs: -------------------------------------------------------------------------------- 1 | // import the prelude to get access to the `rsx!` macro and the `Scope` and `Element` types 2 | use dioxus::prelude::*; 3 | 4 | pub fn init_app(root: fn() -> Element) { 5 | dioxus::launch(root); 6 | } 7 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![allow(non_snake_case)] 2 | pub mod components; 3 | pub mod environment; 4 | pub mod pages; 5 | pub mod utils; 6 | pub mod assets; 7 | 8 | use dioxus::prelude::*; 9 | use dioxus_router::prelude::*; 10 | 11 | use crate::pages::Route; 12 | 13 | pub fn App() -> Element { 14 | rsx! { 15 | document::Stylesheet { href: assets::CSS } 16 | document::Script { src: assets::BOOTSTRAP } 17 | document::Script { src: assets::GHPAGES } 18 | Router:: {} 19 | document::Script { src: assets::DARKMODE } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/logger/mod.rs: -------------------------------------------------------------------------------- 1 | #[cfg(not(target_family = "wasm"))] 2 | use log::*; 3 | 4 | #[cfg(not(target_family = "wasm"))] 5 | mod simple_logger; 6 | 7 | pub fn init(level: log::Level) { 8 | #[cfg(target_family = "wasm")] 9 | { 10 | wasm_logger::init(wasm_logger::Config::new(level)); 11 | } 12 | 13 | #[cfg(not(target_family = "wasm"))] 14 | { 15 | match set_boxed_logger(Box::new(simple_logger::SimpleLogger)) { 16 | Ok(_) => log::set_max_level(level.to_level_filter()), 17 | Err(e) => panic!("Failed to initialize logger: {}", e), 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /src/logger/simple_logger.rs: -------------------------------------------------------------------------------- 1 | use log::{Record, Metadata}; 2 | 3 | pub struct SimpleLogger; 4 | 5 | impl log::Log for SimpleLogger { 6 | fn enabled(&self, metadata: &Metadata) -> bool { 7 | metadata.level() <= log::max_level() 8 | } 9 | 10 | fn log(&self, record: &Record) { 11 | if self.enabled(record.metadata()) { 12 | println!("{} - {}", record.level(), record.args()); 13 | } 14 | } 15 | 16 | fn flush(&self) {} 17 | } -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![allow(non_snake_case)] 2 | use dev_widgets::*; 3 | 4 | #[cfg(not(target_family = "wasm"))] 5 | use std::env; 6 | 7 | mod logger; 8 | 9 | fn main() { 10 | let mut log_level = log::Level::Warn; 11 | if cfg!(debug_assertions) { 12 | #[cfg(not(target_family = "wasm"))] 13 | env::set_var("RUST_BACKTRACE", "1"); 14 | log_level = log::Level::Info; 15 | } 16 | 17 | logger::init(log_level); 18 | 19 | log::info!("Starting app"); 20 | 21 | environment::init(App); 22 | } 23 | -------------------------------------------------------------------------------- /src/pages/converter/date_converter.rs: -------------------------------------------------------------------------------- 1 | #![allow(non_snake_case)] 2 | use std::{fmt::Display, str::FromStr}; 3 | 4 | use dioxus::prelude::*; 5 | use dioxus_free_icons::icons::bs_icons::BsClock; 6 | use strum::IntoEnumIterator; 7 | use time::{Month, OffsetDateTime, UtcOffset}; 8 | use time_tz::{system, timezones, OffsetDateTimeExt, TimeZone, Tz}; 9 | 10 | use crate::{ 11 | components::inputs::{NumberInput, SelectForm, SelectFormEnum, TextInput}, 12 | pages::{WidgetEntry, WidgetIcon}, 13 | }; 14 | 15 | pub const WIDGET_ENTRY: WidgetEntry = WidgetEntry { 16 | title: "Date Converter", 17 | short_title: "Date", 18 | description: "Convert dates between formats", 19 | icon: move || ICON.icon(), 20 | }; 21 | 22 | const ICON: WidgetIcon = WidgetIcon { icon: BsClock }; 23 | 24 | pub fn DateConverter() -> Element { 25 | let mut date_signal = use_signal(|| DateConverterState { 26 | time_zone: DcTimeZone::default(), 27 | time_utc: OffsetDateTime::now_utc(), 28 | }); 29 | 30 | let local_datetime = date_signal.with(|date_state| date_state.local_datetime()); 31 | let unix_time = date_signal.with(|date_state| date_state.time_utc.unix_timestamp()); 32 | 33 | rsx! { 34 | div { 35 | class: "date-converter", 36 | SelectForm:: { 37 | label: "Time Zone", 38 | oninput: move |tz: DcTimeZone| { 39 | date_signal.with_mut(|date_state| { 40 | date_state.time_zone = tz; 41 | }); 42 | }, 43 | value: date_signal.with(|date_state| date_state.time_zone), 44 | } 45 | TextInput { 46 | label: "Date", 47 | value: "{local_datetime}", 48 | readonly: true, 49 | } 50 | TextInput { 51 | label: "Unix Timestamp", 52 | value: "{unix_time}", 53 | onchange: move |event: Event| { 54 | if let Ok(unix_time) = event.parsed::() { 55 | if let Ok(datetime) = OffsetDateTime::from_unix_timestamp(unix_time) { 56 | date_signal.with_mut(|date_state| { 57 | date_state.set_local_datetime(datetime); 58 | }); 59 | } 60 | } 61 | } 62 | } 63 | div { 64 | class: "selectors-wrapper", 65 | div { 66 | class: "ymd selectors", 67 | div { 68 | class: "selectors-inner", 69 | NumberInput:: { 70 | class: "year", 71 | label: "Year", 72 | value: local_datetime.year(), 73 | onchange: move |year| { 74 | date_signal.with_mut(|date_state| { 75 | date_state.set_local_datetime(local_datetime.replace_year(year).unwrap_or(local_datetime)); 76 | }); 77 | } 78 | } 79 | NumberInput:: { 80 | class: "month", 81 | label: "Month", 82 | value: u8::from(local_datetime.month()), 83 | onchange: move |month| { 84 | date_signal.with_mut(|date_state| { 85 | date_state.set_local_datetime(local_datetime.replace_month(Month::try_from(month).unwrap_or(local_datetime.month())).unwrap_or(local_datetime)); 86 | }); 87 | } 88 | } 89 | NumberInput:: { 90 | class: "day", 91 | label: "Day", 92 | value: local_datetime.day(), 93 | onchange: move |day| { 94 | date_signal.with_mut(|date_state| { 95 | date_state.set_local_datetime(local_datetime.replace_day(day).unwrap_or(local_datetime)); 96 | }); 97 | } 98 | } 99 | } 100 | } 101 | div { 102 | class: "hms selectors", 103 | div { 104 | class: "selectors-inner", 105 | NumberInput:: { 106 | class: "hour", 107 | label: "Hour", 108 | value: local_datetime.hour(), 109 | onchange: move |hour| { 110 | date_signal.with_mut(|date_state| { 111 | date_state.set_local_datetime(local_datetime.replace_hour(hour).unwrap_or(local_datetime)); 112 | }); 113 | } 114 | } 115 | NumberInput:: { 116 | class: "minute", 117 | label: "Minute", 118 | value: local_datetime.minute(), 119 | onchange: move |minute| { 120 | date_signal.with_mut(|date_state| { 121 | date_state.set_local_datetime(local_datetime.replace_minute(minute).unwrap_or(local_datetime)); 122 | }); 123 | } 124 | } 125 | NumberInput:: { 126 | class: "second", 127 | label: "Second", 128 | value: local_datetime.second(), 129 | onchange: move |second| { 130 | date_signal.with_mut(|date_state| { 131 | date_state.set_local_datetime(local_datetime.replace_second(second).unwrap_or(local_datetime)); 132 | }); 133 | } 134 | } 135 | } 136 | } 137 | } 138 | } 139 | } 140 | } 141 | 142 | struct DateConverterState { 143 | time_zone: DcTimeZone, 144 | time_utc: OffsetDateTime, 145 | } 146 | 147 | impl DateConverterState { 148 | fn local_datetime(&self) -> OffsetDateTime { 149 | self.time_utc.to_timezone(self.time_zone.inner()) 150 | } 151 | 152 | fn set_local_datetime(&mut self, datetime: OffsetDateTime) { 153 | self.time_utc = datetime.to_offset(UtcOffset::UTC); 154 | } 155 | } 156 | 157 | #[derive(Debug, Clone, Copy, Eq)] 158 | enum DcTimeZone { 159 | Base(&'static Tz), 160 | } 161 | 162 | impl Default for DcTimeZone { 163 | fn default() -> Self { 164 | Self::Base(match system::get_timezone() { 165 | Ok(tz) => tz, 166 | Err(err) => { 167 | log::warn!("Failed to get system timezone, defaulting to UTC {:?}", err); 168 | timezones::get_by_name("UTC").unwrap() 169 | }, 170 | }) 171 | } 172 | } 173 | 174 | impl Display for DcTimeZone { 175 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 176 | write!(f, "{:?}", self.inner()) 177 | } 178 | } 179 | 180 | impl FromStr for DcTimeZone { 181 | fn from_str(s: &str) -> Result { 182 | match timezones::get_by_name(s) { 183 | Some(tz) => Ok(Self::Base(tz)), 184 | None => { 185 | log::error!("Failed to parse timezone: {}", s); 186 | Err(TzParseError) 187 | }, 188 | } 189 | } 190 | 191 | type Err = TzParseError; 192 | } 193 | 194 | impl From for String { 195 | fn from(val: DcTimeZone) -> Self { 196 | val.inner().name().to_string() 197 | } 198 | } 199 | 200 | #[derive(Debug, Clone)] 201 | struct TzParseError; 202 | 203 | impl Display for TzParseError { 204 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 205 | "invalid timezone".fmt(f) 206 | } 207 | } 208 | 209 | impl PartialEq for DcTimeZone { 210 | fn eq(&self, other: &Self) -> bool { 211 | self.inner() == other.inner() 212 | } 213 | } 214 | 215 | impl IntoEnumIterator for DcTimeZone { 216 | fn iter() -> Self::Iterator { 217 | timezones::iter() 218 | .map(Self::Base) 219 | .collect::>() 220 | .into_iter() 221 | } 222 | 223 | type Iterator = std::vec::IntoIter; 224 | } 225 | 226 | impl From for &'static str { 227 | fn from(val: DcTimeZone) -> Self { 228 | val.inner().name() 229 | } 230 | } 231 | 232 | impl SelectFormEnum for DcTimeZone {} 233 | 234 | impl DcTimeZone { 235 | fn inner(&self) -> &'static Tz { 236 | match self { 237 | Self::Base(tz) => tz, 238 | } 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /src/pages/converter/json_yaml_converter.rs: -------------------------------------------------------------------------------- 1 | #![allow(non_snake_case)] 2 | use dioxus::prelude::*; 3 | use dioxus_free_icons::icons::bs_icons::BsFileText; 4 | 5 | use crate::pages::{WidgetEntry, WidgetIcon}; 6 | 7 | pub const WIDGET_ENTRY: WidgetEntry = WidgetEntry { 8 | title: "JSON <> YAML Converter", 9 | short_title: "JSON <> YAML", 10 | description: "Convert between JSON and YAML file formats", 11 | icon: move || ICON.icon(), 12 | }; 13 | 14 | const ICON: WidgetIcon = WidgetIcon { icon: BsFileText }; 15 | 16 | pub fn JsonYamlConverter() -> Element { 17 | rsx! { 18 | div { 19 | class: "json-yaml-converter", 20 | div { 21 | class: "alert alert-warning", 22 | "JSON <> YAML converter is not implemented yet." 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/pages/converter/mod.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | use dioxus_router::prelude::*; 3 | use strum_macros::EnumIter; 4 | 5 | pub mod date_converter; 6 | pub mod json_yaml_converter; 7 | pub mod number_base_converter; 8 | 9 | use crate::pages::{Route, WidgetEntry, WidgetRoute}; 10 | use date_converter::DateConverter; 11 | use json_yaml_converter::JsonYamlConverter; 12 | use number_base_converter::NumberBaseConverter; 13 | 14 | #[derive(Clone, Debug, EnumIter, PartialEq, Routable)] 15 | pub enum ConverterRoute { 16 | #[route("/")] 17 | Index {}, 18 | #[route("/date")] 19 | DateConverter {}, 20 | #[route("/json-yaml")] 21 | JsonYamlConverter {}, 22 | #[route("/number-base")] 23 | NumberBaseConverter {}, 24 | } 25 | 26 | fn Index() -> Element { 27 | rsx! { 28 | div { 29 | class: "converter", 30 | } 31 | } 32 | } 33 | 34 | impl WidgetRoute for ConverterRoute { 35 | fn get_widget_routes() -> Vec { 36 | Self::get_widgets() 37 | .iter() 38 | .map(|widget| Route::Converter { 39 | child: widget.clone(), 40 | }) 41 | .collect() 42 | } 43 | 44 | fn get_widget_type_string() -> &'static str { 45 | "Converter" 46 | } 47 | 48 | fn get_widget_entry(&self) -> Option<&'static WidgetEntry> { 49 | match self { 50 | Self::DateConverter { .. } => Some(&date_converter::WIDGET_ENTRY), 51 | Self::JsonYamlConverter { .. } => Some(&json_yaml_converter::WIDGET_ENTRY), 52 | Self::NumberBaseConverter { .. } => Some(&number_base_converter::WIDGET_ENTRY), 53 | _ => None, 54 | } 55 | } 56 | } 57 | 58 | impl Default for ConverterRoute { 59 | fn default() -> Self { 60 | Self::Index {} 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/pages/converter/number_base_converter.rs: -------------------------------------------------------------------------------- 1 | #![allow(non_snake_case)] 2 | use dioxus::prelude::*; 3 | use dioxus_free_icons::icons::bs_icons::Bs123; 4 | use std::fmt; 5 | 6 | use crate::components::inputs::{SwitchInput, TextInput}; 7 | use crate::pages::{WidgetEntry, WidgetIcon}; 8 | use crate::utils::{add_number_delimiters, sanitize_string}; 9 | 10 | pub const WIDGET_ENTRY: WidgetEntry = WidgetEntry { 11 | title: "Number Base Converter", 12 | short_title: "Number Base", 13 | description: "Convert numbers between binary, octal, decimal, and hexadecimal", 14 | icon: move || ICON.icon(), 15 | }; 16 | 17 | const ICON: WidgetIcon = WidgetIcon { icon: Bs123 }; 18 | 19 | pub fn NumberBaseConverter() -> Element { 20 | use_context_provider(|| Signal::new(ConverterValue(0))); 21 | let mut format_number_state = use_context_provider(|| Signal::new(FormatNumberState(false))); 22 | 23 | rsx! { 24 | div { 25 | class: "number-base-converter", 26 | SwitchInput { 27 | label: "Format Numbers", 28 | checked: format_number_state.read().0, 29 | oninput: move |is_enabled| { 30 | format_number_state.write().0 = is_enabled; 31 | } 32 | } 33 | converter_input { 34 | base: NumberBase::Decimal 35 | } 36 | converter_input { 37 | base: NumberBase::Hexadecimal 38 | } 39 | converter_input { 40 | base: NumberBase::Octal 41 | } 42 | converter_input { 43 | base: NumberBase::Binary 44 | } 45 | } 46 | } 47 | } 48 | 49 | #[component] 50 | fn converter_input(base: NumberBase) -> Element { 51 | let mut value_context = use_context::>(); 52 | let format_number_state = use_context::>(); 53 | 54 | rsx! { 55 | TextInput { 56 | label: "{base}", 57 | value: "{format_number(value_context.read().0, base, format_number_state.read().0)}", 58 | oninput: move |event: Event| { 59 | let event_value = sanitize_string(event.value()); 60 | value_context.write().0 = match base { 61 | NumberBase::Binary => i64::from_str_radix(&event_value, 2), 62 | NumberBase::Octal => i64::from_str_radix(&event_value, 8), 63 | NumberBase::Decimal => event_value.parse::(), 64 | NumberBase::Hexadecimal => i64::from_str_radix(&event_value, 16), 65 | }.unwrap_or(0); 66 | } 67 | } 68 | } 69 | } 70 | 71 | fn format_number(number: i64, base: NumberBase, format_number: bool) -> String { 72 | match base { 73 | NumberBase::Binary => { 74 | let number_binary = format!("{:b}", number); 75 | match format_number { 76 | true => add_number_delimiters(number_binary, ' ', 4), 77 | false => number_binary, 78 | } 79 | } 80 | NumberBase::Octal => { 81 | let number_octal = format!("{:o}", number); 82 | match format_number { 83 | true => add_number_delimiters(number_octal, ' ', 3), 84 | false => number_octal, 85 | } 86 | } 87 | NumberBase::Decimal => { 88 | let number_decimal = format!("{}", number); 89 | match format_number { 90 | true => add_number_delimiters(number_decimal, ',', 3), 91 | false => number_decimal, 92 | } 93 | } 94 | NumberBase::Hexadecimal => { 95 | let number_hexadecimal = format!("{:X}", number); 96 | match format_number { 97 | true => add_number_delimiters(number_hexadecimal, ' ', 4), 98 | false => number_hexadecimal, 99 | } 100 | } 101 | } 102 | } 103 | 104 | #[derive(Clone)] 105 | struct ConverterValue(i64); 106 | 107 | #[derive(Clone)] 108 | struct FormatNumberState(bool); 109 | 110 | #[derive(PartialEq, Debug, Clone, Copy)] 111 | enum NumberBase { 112 | Binary, 113 | Octal, 114 | Decimal, 115 | Hexadecimal, 116 | } 117 | 118 | impl fmt::Display for NumberBase { 119 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 120 | write!(f, "{:?}", self) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/pages/encoder_decoder/base64_encoder.rs: -------------------------------------------------------------------------------- 1 | #![allow(non_snake_case)] 2 | use base64ct::{Base64, Encoding}; 3 | use dioxus::prelude::*; 4 | use dioxus_free_icons::icons::bs_icons::BsHash; 5 | use std::fmt; 6 | 7 | use crate::components::inputs::TextAreaForm; 8 | use crate::pages::{WidgetEntry, WidgetIcon}; 9 | 10 | pub const WIDGET_ENTRY: WidgetEntry = WidgetEntry { 11 | title: "Base64 Encoder / Decoder", 12 | short_title: "Base64", 13 | description: "Encode and decode base64 strings", 14 | icon: move || ICON.icon(), 15 | }; 16 | 17 | const ICON: WidgetIcon = WidgetIcon { icon: BsHash }; 18 | 19 | pub fn Base64Encoder() -> Element { 20 | use_context_provider(|| Signal::new(EncoderValue { 21 | encoded_value: String::new(), 22 | decoded_value: String::new(), 23 | })); 24 | rsx! { 25 | div { 26 | class: "base64-encoder", 27 | encoder_input { 28 | direction: Direction::Encode 29 | } 30 | encoder_input { 31 | direction: Direction::Decode 32 | } 33 | } 34 | } 35 | } 36 | 37 | #[allow(unused_assignments, unused_variables)] 38 | #[component] 39 | fn encoder_input(direction: Direction) -> Element { 40 | let mut value_context = use_context::>(); 41 | 42 | let current_value = value_context.with(|value| match direction { 43 | Direction::Encode => value.decoded_value.clone(), 44 | Direction::Decode => value.encoded_value.clone(), 45 | }); 46 | 47 | const NOT_STRING: &str = "Not String"; 48 | rsx! { 49 | TextAreaForm { 50 | label: match direction { 51 | Direction::Encode => "Text", 52 | Direction::Decode => "Encoded", 53 | }, 54 | value: "{current_value}", 55 | oninput: move |event: Event| { 56 | let input_value = event.value(); 57 | match direction { 58 | Direction::Encode => { 59 | value_context.set(EncoderValue { 60 | encoded_value: Base64::encode_string(input_value.as_bytes()), 61 | decoded_value: input_value, 62 | }); 63 | }, 64 | Direction::Decode => { 65 | let decode_val = match Base64::decode_vec(input_value.as_str()) { 66 | Ok(val) => String::from_utf8(val).unwrap_or(NOT_STRING.to_string()), 67 | Err(_) => NOT_STRING.to_string(), 68 | }; 69 | value_context.set(EncoderValue { 70 | encoded_value: input_value, 71 | decoded_value: decode_val, 72 | }); 73 | }, 74 | }; 75 | } 76 | } 77 | } 78 | } 79 | 80 | #[derive(Clone)] 81 | struct EncoderValue { 82 | encoded_value: String, 83 | decoded_value: String, 84 | } 85 | 86 | #[derive(PartialEq, Debug, Clone)] 87 | enum Direction { 88 | Encode, 89 | Decode, 90 | } 91 | 92 | impl fmt::Display for Direction { 93 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 94 | write!(f, "{:?}", self) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/pages/encoder_decoder/cidr_decoder.rs: -------------------------------------------------------------------------------- 1 | #![allow(non_snake_case)] 2 | use std::{ 3 | net::{IpAddr, Ipv4Addr, Ipv6Addr}, 4 | str::FromStr, 5 | }; 6 | 7 | use cidr::{Family, IpCidr}; 8 | use dioxus::prelude::*; 9 | use dioxus_free_icons::icons::bs_icons::BsEthernet; 10 | 11 | use crate::{ 12 | components::inputs::{TextAreaForm, TextInput}, 13 | pages::{WidgetEntry, WidgetIcon}, 14 | utils::add_number_delimiters, 15 | }; 16 | 17 | pub const WIDGET_ENTRY: WidgetEntry = WidgetEntry { 18 | title: "CIDR Decoder", 19 | short_title: "CIDR", 20 | description: "Decode Classless Inter-Domain Routing (CIDR) notation to IP address range", 21 | icon: move || ICON.icon(), 22 | }; 23 | 24 | const ICON: WidgetIcon = WidgetIcon { icon: BsEthernet }; 25 | 26 | pub fn CidrDecoder() -> Element { 27 | let mut cidr_ref = use_signal(|| { 28 | IpCidr::new(std::net::IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 0).unwrap() 29 | }); 30 | 31 | let mut cidr_input_ref= use_signal(|| cidr_ref.with(|cidr| cidr.to_string())); 32 | 33 | let cidr_description = cidr_ref.with(|cidr| { 34 | let mut description = String::new(); 35 | description.push_str(&format!("Netmask: {}\n", cidr.mask())); 36 | description.push_str(&format!( 37 | "Wildcard: {}\n", 38 | match cidr.mask() { 39 | IpAddr::V4(mask) => { 40 | let wildcard = !u32::from(mask); 41 | IpAddr::from(Ipv4Addr::from(wildcard)) 42 | } 43 | IpAddr::V6(mask) => { 44 | let wildcard = !u128::from(mask); 45 | IpAddr::from(Ipv6Addr::from(wildcard)) 46 | } 47 | } 48 | )); 49 | description.push_str(&format!("First IP: {}\n", cidr.first_address())); 50 | description.push_str(&format!("Last IP: {}\n", cidr.last_address())); 51 | description.push_str(&format!("Total Addresses: {}\n", { 52 | const BASE: u128 = 2; 53 | let power = match cidr.family() { 54 | Family::Ipv4 => 32, 55 | Family::Ipv6 => 128, 56 | } - u32::from(cidr.network_length()); 57 | 58 | if power == 128 { 59 | // This is too big to fit in a u128, so we have to hardcode it or use a non-std u256 crate. 60 | "340,282,366,920,938,463,463,374,607,431,768,211,456".to_string() 61 | } else { 62 | add_number_delimiters(BASE.pow(power).to_string(), ',', 3) 63 | } 64 | })); 65 | description 66 | }); 67 | 68 | let mut show_error_state = use_signal(|| false); 69 | rsx! { 70 | div { 71 | class: "cidr-decoder", 72 | TextInput { 73 | label: "CIDR", 74 | value: "{cidr_input_ref.with(|cidr_str| cidr_str.to_string())}", 75 | oninput: move |event: Event| { 76 | let cidr = event.value(); 77 | let cidr_clone = cidr.clone(); 78 | let cidr_trim = cidr.trim(); 79 | log::info!("CIDR: {}", cidr); 80 | cidr_input_ref.with_mut(|cidr_input| { 81 | *cidr_input = cidr_clone; 82 | }); 83 | if let Ok(cidr_valid) = IpCidr::from_str(cidr_trim) { 84 | cidr_ref.with_mut(|cidr_obj| { 85 | *cidr_obj = cidr_valid; 86 | show_error_state.with_mut(|show_error_state| { 87 | *show_error_state = false; 88 | }); 89 | }); 90 | } else { 91 | show_error_state.with_mut(|show_error_state| { 92 | *show_error_state = true; 93 | }); 94 | } 95 | } 96 | } 97 | div { 98 | class: "alert alert-warning m-0", 99 | hidden: !*show_error_state.read(), 100 | "The provided CIDR is invalid." 101 | } 102 | TextAreaForm { 103 | label: "Description", 104 | readonly: true, 105 | value: "{cidr_description}", 106 | } 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/pages/encoder_decoder/mod.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | use dioxus_router::prelude::*; 3 | use strum_macros::EnumIter; 4 | 5 | pub mod base64_encoder; 6 | pub mod cidr_decoder; 7 | 8 | use crate::pages::{Route, WidgetEntry, WidgetRoute}; 9 | use base64_encoder::Base64Encoder; 10 | use cidr_decoder::CidrDecoder; 11 | 12 | #[derive(Clone, Debug, EnumIter, PartialEq, Routable)] 13 | pub enum EncoderDecoderRoute { 14 | #[route("/")] 15 | Index {}, 16 | #[route("/base64")] 17 | Base64Encoder {}, 18 | #[route("/cidr")] 19 | CidrDecoder {}, 20 | } 21 | 22 | fn Index() -> Element { 23 | rsx! { 24 | div { 25 | class: "encoder-decoder" 26 | } 27 | } 28 | } 29 | 30 | impl WidgetRoute for EncoderDecoderRoute { 31 | fn get_widget_routes() -> Vec { 32 | Self::get_widgets() 33 | .iter() 34 | .map(|widget| Route::EncoderDecoder { 35 | child: widget.clone(), 36 | }) 37 | .collect() 38 | } 39 | 40 | fn get_widget_type_string() -> &'static str { 41 | "Encoder/Decoder" 42 | } 43 | 44 | fn get_widget_entry(&self) -> Option<&'static WidgetEntry> { 45 | match self { 46 | Self::Base64Encoder { .. } => Some(&base64_encoder::WIDGET_ENTRY), 47 | Self::CidrDecoder { .. } => Some(&cidr_decoder::WIDGET_ENTRY), 48 | _ => None, 49 | } 50 | } 51 | } 52 | 53 | impl Default for EncoderDecoderRoute { 54 | fn default() -> Self { 55 | Self::Index {} 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/pages/generator/hash_generator.rs: -------------------------------------------------------------------------------- 1 | #![allow(non_snake_case)] 2 | use digest::DynDigest; 3 | use dioxus::prelude::*; 4 | use dioxus_free_icons::icons::bs_icons::BsFingerprint; 5 | use std::fmt::{self, Write}; 6 | 7 | use crate::components::inputs::{SwitchInput, TextAreaForm, TextInput}; 8 | use crate::pages::{WidgetEntry, WidgetIcon}; 9 | 10 | pub const WIDGET_ENTRY: WidgetEntry = WidgetEntry { 11 | title: "Hash Generator", 12 | short_title: "Hash", 13 | description: "Generate cryptographic hashes of strings", 14 | icon: move || ICON.icon(), 15 | }; 16 | 17 | const ICON: WidgetIcon = WidgetIcon { 18 | icon: BsFingerprint, 19 | }; 20 | 21 | pub fn HashGenerator() -> Element { 22 | let mut hash_generator_state = use_context_provider(|| Signal::new(HashGeneratorState { 23 | value: "".to_string(), 24 | uppercase: false, 25 | })); 26 | 27 | rsx! { 28 | div { 29 | class: "number-base-converter", 30 | SwitchInput { 31 | label: "Uppercase", 32 | checked: hash_generator_state.read().uppercase, 33 | oninput: move |is_enabled| { 34 | hash_generator_state.write().uppercase = is_enabled; 35 | } 36 | } 37 | TextAreaForm { 38 | label: "Value to hash", 39 | value: "{hash_generator_state.read().value}", 40 | oninput: move |event: Event| { 41 | hash_generator_state.write().value = event.value(); 42 | } 43 | } 44 | HashField { 45 | algorithm: HashingAlgorithm::MD5, 46 | } 47 | HashField { 48 | algorithm: HashingAlgorithm::SHA1, 49 | } 50 | HashField { 51 | algorithm: HashingAlgorithm::SHA256, 52 | } 53 | HashField { 54 | algorithm: HashingAlgorithm::SHA512, 55 | } 56 | } 57 | } 58 | } 59 | 60 | #[component] 61 | fn HashField(algorithm: HashingAlgorithm) -> Element { 62 | let hash_generator_state = use_context::>(); 63 | 64 | let mut hasher = select_hasher(algorithm); 65 | 66 | let hashed_value = hash_generator_state.with(|state| generate_hash( 67 | state.value.clone(), 68 | &mut *hasher, 69 | state.uppercase, 70 | )); 71 | 72 | rsx! { 73 | TextInput { 74 | label: "{algorithm}", 75 | value: "{hashed_value}", 76 | readonly: true, 77 | } 78 | } 79 | } 80 | 81 | fn select_hasher(algorithm: HashingAlgorithm) -> Box { 82 | match algorithm { 83 | HashingAlgorithm::MD5 => Box::::default(), 84 | HashingAlgorithm::SHA1 => Box::::default(), 85 | HashingAlgorithm::SHA256 => Box::::default(), 86 | HashingAlgorithm::SHA512 => Box::::default(), 87 | } 88 | } 89 | 90 | fn generate_hash(value: String, hasher: &mut dyn DynDigest, uppercase: bool) -> String { 91 | hasher.update(value.as_bytes()); 92 | let hashed_value = hasher.finalize_reset(); 93 | 94 | if uppercase { 95 | hashed_value 96 | .iter() 97 | .fold(String::new(), |mut output, b| { 98 | let _ = write!(output, "{:X}", b); 99 | output 100 | }) 101 | } else { 102 | hashed_value 103 | .iter() 104 | .fold(String::new(), |mut output, b| { 105 | let _ = write!(output, "{:x}", b); 106 | output 107 | }) 108 | } 109 | } 110 | 111 | #[derive(Clone)] 112 | struct HashGeneratorState { 113 | value: String, 114 | uppercase: bool, 115 | } 116 | 117 | #[derive(PartialEq, Debug, Clone, Copy)] 118 | enum HashingAlgorithm { 119 | MD5, 120 | SHA1, 121 | SHA256, 122 | SHA512, 123 | } 124 | 125 | impl fmt::Display for HashingAlgorithm { 126 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 127 | write!(f, "{:?}", self) 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/pages/generator/mod.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | use dioxus_router::prelude::*; 3 | use strum_macros::EnumIter; 4 | 5 | pub mod hash_generator; 6 | pub mod qr_code_generator; 7 | pub mod uuid_generator; 8 | 9 | use crate::pages::{Route, WidgetEntry, WidgetRoute}; 10 | use hash_generator::HashGenerator; 11 | use qr_code_generator::QrCodeGenerator; 12 | use uuid_generator::UuidGenerator; 13 | 14 | #[derive(Clone, Debug, EnumIter, PartialEq, Routable)] 15 | pub enum GeneratorRoute { 16 | #[route("/")] 17 | Index {}, 18 | #[route("/hash")] 19 | HashGenerator {}, 20 | #[route("/qr-code")] 21 | QrCodeGenerator {}, 22 | #[route("/uuid")] 23 | UuidGenerator {}, 24 | } 25 | 26 | fn Index() -> Element { 27 | rsx! { 28 | div { 29 | class: "generator", 30 | } 31 | } 32 | } 33 | 34 | impl WidgetRoute for GeneratorRoute { 35 | fn get_widget_routes() -> Vec { 36 | Self::get_widgets() 37 | .iter() 38 | .map(|widget| Route::Generator { 39 | child: widget.clone(), 40 | }) 41 | .collect() 42 | } 43 | 44 | fn get_widget_type_string() -> &'static str { 45 | "Generator" 46 | } 47 | 48 | fn get_widget_entry(&self) -> Option<&'static WidgetEntry> { 49 | match self { 50 | Self::HashGenerator { .. } => Some(&hash_generator::WIDGET_ENTRY), 51 | Self::QrCodeGenerator { .. } => Some(&qr_code_generator::WIDGET_ENTRY), 52 | Self::UuidGenerator { .. } => Some(&uuid_generator::WIDGET_ENTRY), 53 | _ => None, 54 | } 55 | } 56 | } 57 | 58 | impl Default for GeneratorRoute { 59 | fn default() -> Self { 60 | Self::Index {} 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/pages/generator/qr_code_generator.rs: -------------------------------------------------------------------------------- 1 | #![allow(non_snake_case)] 2 | use base64ct::{Base64, Encoding}; 3 | use dioxus::prelude::*; 4 | use dioxus_free_icons::icons::bs_icons::BsQrCode; 5 | 6 | use qrcode_generator::{self}; 7 | use strum_macros::{Display, EnumIter, EnumString, IntoStaticStr}; 8 | 9 | use crate::{ 10 | components::inputs::{SelectForm, SelectFormEnum, TextAreaForm}, 11 | pages::{WidgetEntry, WidgetIcon}, 12 | }; 13 | 14 | pub const WIDGET_ENTRY: WidgetEntry = WidgetEntry { 15 | title: "QR Code Generator", 16 | short_title: "QR Code", 17 | description: "Generate QR codes from text", 18 | icon: move || ICON.icon(), 19 | }; 20 | 21 | const ICON: WidgetIcon = WidgetIcon { icon: BsQrCode }; 22 | 23 | pub fn QrCodeGenerator() -> Element { 24 | let mut qr_code_value = use_signal(|| "".to_string()); 25 | let mut qr_code_error_correction = use_signal(Ecc::default); 26 | 27 | let qr_code_result = qrcode_generator::to_svg_to_string( 28 | (*qr_code_value.read()).clone(), 29 | (*qr_code_error_correction.read()).into(), 30 | 1024, 31 | None::<&str>, 32 | ); 33 | let qr_code_result = match qr_code_result { 34 | Ok(result) => Base64::encode_string(result.as_bytes()), 35 | Err(_) => "".to_string(), 36 | }; 37 | 38 | rsx! { 39 | div { 40 | class: "qr-code-generator", 41 | SelectForm:: { 42 | label: "Error Correction Level", 43 | oninput: move |ecc: Ecc| { 44 | qr_code_error_correction.set(ecc); 45 | }, 46 | value: *qr_code_error_correction.read(), 47 | } 48 | TextAreaForm { 49 | label: "Input", 50 | value: qr_code_value, 51 | oninput: move |event: Event| { 52 | qr_code_value.set(event.value()); 53 | }, 54 | } 55 | 56 | div { 57 | class: "alert alert-warning", 58 | display: if !qr_code_result.is_empty() { "none" } else { "block" }, 59 | "Input string is too long to generate a QR code with this level of error correction." 60 | } 61 | img { 62 | class: "qr-code", 63 | display: if qr_code_result.is_empty() { "none" } else { "block" }, 64 | src: "data:image/svg+xml;base64,{qr_code_result}" 65 | } 66 | } 67 | } 68 | } 69 | 70 | #[derive( 71 | Copy, Clone, Default, Debug, Display, EnumIter, EnumString, Hash, IntoStaticStr, PartialEq 72 | )] 73 | enum Ecc { 74 | #[default] 75 | Low, 76 | Medium, 77 | Quartile, 78 | High, 79 | } 80 | 81 | impl SelectFormEnum for Ecc {} 82 | 83 | impl From for String { 84 | fn from(ecc: Ecc) -> Self { 85 | ecc.to_string() 86 | } 87 | 88 | } 89 | 90 | impl From for qrcode_generator::QrCodeEcc { 91 | fn from(ecc: Ecc) -> Self { 92 | match ecc { 93 | Ecc::Low => qrcode_generator::QrCodeEcc::Low, 94 | Ecc::Medium => qrcode_generator::QrCodeEcc::Medium, 95 | Ecc::Quartile => qrcode_generator::QrCodeEcc::Quartile, 96 | Ecc::High => qrcode_generator::QrCodeEcc::High, 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/pages/generator/uuid_generator.rs: -------------------------------------------------------------------------------- 1 | #![allow(non_snake_case)] 2 | use dioxus::prelude::*; 3 | use dioxus_free_icons::icons::bs_icons::BsGlobe2; 4 | use strum_macros::{Display, EnumIter, EnumString, IntoStaticStr}; 5 | 6 | use crate::{ 7 | components::inputs::{NumberInput, SelectForm, SelectFormEnum, SwitchInput, TextAreaForm}, 8 | pages::{WidgetEntry, WidgetIcon}, 9 | }; 10 | 11 | pub const WIDGET_ENTRY: WidgetEntry = WidgetEntry { 12 | title: "UUID/GUID Generator", 13 | short_title: "UUID", 14 | description: "Generate unique identifiers", 15 | icon: move || ICON.icon(), 16 | }; 17 | 18 | const ICON: WidgetIcon = WidgetIcon { icon: BsGlobe2 }; 19 | 20 | pub fn UuidGenerator() -> Element { 21 | let mut hyphens_state = use_signal(|| true); 22 | let mut uppercase_state = use_signal(|| true); 23 | let mut num_uuids_state = use_signal(|| 1); 24 | let mut uuids_state= use_signal(Vec::::new); 25 | let mut uuid_version_state = use_signal(|| UUIDVersion::V4); 26 | 27 | let uuids_str = uuids_state.with(|uuids_vec| uuids_vec.join("\n")); 28 | rsx! { 29 | div { 30 | class: "uuid-generator", 31 | div { 32 | class: "params", 33 | div { 34 | class: "switches", 35 | SwitchInput { 36 | label: "Hyphens", 37 | checked: true, 38 | oninput: move |value| { 39 | hyphens_state.set(value); 40 | } 41 | } 42 | SwitchInput { 43 | label: "Uppercase", 44 | checked: true, 45 | oninput: move |value| { 46 | uppercase_state.set(value); 47 | } 48 | } 49 | } 50 | SelectForm:: { 51 | label: "UUID Version", 52 | value: *uuid_version_state.read(), 53 | oninput: move |uuid_version| { 54 | uuid_version_state.set(uuid_version); 55 | } 56 | } 57 | NumberInput:: { 58 | label: "Number of UUIDs to generate", 59 | value: *num_uuids_state.read(), 60 | onchange: move |value| { 61 | num_uuids_state.set(value); 62 | } 63 | } 64 | } 65 | 66 | div { 67 | class: "buttons", 68 | button { 69 | class: "btn btn-primary me-3", 70 | onclick: move |_| { 71 | let mut uuids = vec![]; 72 | for _ in 0..*num_uuids_state.read() { 73 | let uuid = uuid::Uuid::new_v4(); 74 | let mut uuid = if *hyphens_state.read() { 75 | uuid.hyphenated().to_string() 76 | } else { 77 | uuid.simple().to_string() 78 | }; 79 | if *uppercase_state.read() { 80 | uuid = uuid.to_uppercase(); 81 | } 82 | uuids.push(uuid); 83 | } 84 | uuids_state.write().append(&mut uuids); 85 | }, 86 | "Generate" 87 | } 88 | button { 89 | class: "btn btn-secondary", 90 | onclick: move |_| { 91 | uuids_state.write().clear(); 92 | }, 93 | "Clear" 94 | } 95 | } 96 | TextAreaForm { 97 | label: "UUIDs", 98 | value: "{uuids_str}", 99 | readonly: true, 100 | } 101 | } 102 | } 103 | } 104 | 105 | 106 | #[derive( 107 | Copy, Clone, Default, Debug, Display, EnumIter, EnumString, Hash, IntoStaticStr, PartialEq, 108 | )] 109 | #[allow(clippy::upper_case_acronyms)] 110 | enum UUIDVersion { 111 | #[default] 112 | V4, 113 | V7, 114 | } 115 | 116 | impl SelectFormEnum for UUIDVersion {} 117 | 118 | impl From for String { 119 | fn from(uuid_version: UUIDVersion) -> Self { 120 | uuid_version.to_string() 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/pages/home_page.rs: -------------------------------------------------------------------------------- 1 | #![allow(non_snake_case)] 2 | use dioxus::prelude::*; 3 | use dioxus_router::prelude::*; 4 | use dioxus_free_icons::icons::bs_icons::BsHouseDoorFill; 5 | use strum::IntoEnumIterator; 6 | 7 | use crate::pages::{Route, WidgetEntry, WidgetIcon}; 8 | 9 | pub static HOME_PAGE_WIDGET_ENTRY: WidgetEntry = WidgetEntry { 10 | title: "Home", 11 | short_title: "Home", 12 | description: "Home page", 13 | icon: || HOME_ICON.icon(), 14 | }; 15 | 16 | const HOME_ICON: WidgetIcon = WidgetIcon { 17 | icon: BsHouseDoorFill, 18 | }; 19 | 20 | pub fn HomePage() -> Element { 21 | rsx! { 22 | div { 23 | class: "home-page", 24 | for route in Route::iter() { 25 | for widget_route in route.get_widgets() { 26 | if let Some(widget_entry) = widget_route.clone().get_widget_entry() { 27 | {rsx! { 28 | div { 29 | class: "card", 30 | div { 31 | class: "card-img-top", 32 | {(widget_entry.icon)()} 33 | } 34 | div { 35 | class: "card-body", 36 | div { 37 | class: "card-title", 38 | {widget_entry.title} 39 | } 40 | div { 41 | class: "card-text", 42 | {widget_entry.description} 43 | } 44 | Link { 45 | class: "stretched-link", 46 | to: widget_route 47 | } 48 | } 49 | } 50 | }} 51 | } 52 | } 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/pages/layout.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | use dioxus_router::prelude::*; 3 | use strum::IntoEnumIterator; 4 | 5 | use crate::components; 6 | use crate::pages::home_page::HOME_PAGE_WIDGET_ENTRY; 7 | use crate::pages::Route; 8 | 9 | pub fn Container() -> Element { 10 | rsx! { 11 | div { 12 | class: "container-fluid", 13 | Sidebar {} 14 | Outlet:: { } 15 | } 16 | } 17 | } 18 | 19 | fn Sidebar() -> Element { 20 | rsx! { 21 | div { 22 | class: "sidebar", 23 | div { 24 | class: "sidebar-list", 25 | div { 26 | class: "accordion", 27 | SidebarListItem { 28 | widget_route: Route::HomePage {}, 29 | widget_entry_title: HOME_PAGE_WIDGET_ENTRY.short_title, 30 | icon: (HOME_PAGE_WIDGET_ENTRY.icon)() 31 | } 32 | for widget_type_route in Route::iter() { 33 | if let Some(widget_type_string) = widget_type_route.get_widget_type_string() { 34 | {rsx! { 35 | components::accordion::Accordion { 36 | title: "{widget_type_string}", 37 | is_open: true, 38 | for widget_route in widget_type_route.get_widgets() { 39 | if let Some(widget_entry) = widget_route.clone().get_widget_entry() { 40 | {rsx! { 41 | SidebarListItem { 42 | widget_route: widget_route, 43 | widget_entry_title: widget_entry.short_title, 44 | icon: (widget_entry.icon)() 45 | } 46 | }} 47 | } 48 | } 49 | } 50 | }} 51 | } 52 | } 53 | } 54 | } 55 | div { 56 | class: "vr" 57 | } 58 | } 59 | } 60 | } 61 | 62 | #[component] 63 | fn SidebarListItem( 64 | widget_route: Route, 65 | widget_entry_title: &'static str, 66 | icon: Element, 67 | ) -> Element { 68 | let route = use_route::(); 69 | 70 | let active_str = if widget_route == route { "active" } else { "" }; 71 | 72 | rsx! { 73 | Link { 74 | class: "btn {active_str}", 75 | to: widget_route.clone(), 76 | {icon} 77 | "{widget_entry_title}" 78 | } 79 | } 80 | } 81 | 82 | #[component] 83 | pub fn WidgetView() -> Element { 84 | let route = use_route::(); 85 | let mut title = "Home"; 86 | if let Some(widget_entry) = route.get_widget_entry() { 87 | title = widget_entry.title; 88 | } 89 | rsx! { 90 | div { 91 | class: "widget-view", 92 | h3 { 93 | class: "widget-title", 94 | "{title}" 95 | } 96 | div { 97 | class: "widget-body", 98 | Outlet:: { } 99 | } 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/pages/media/color_picker.rs: -------------------------------------------------------------------------------- 1 | #![allow(non_snake_case)] 2 | use color_processing::Color; 3 | use dioxus::{ 4 | html::geometry::{ 5 | euclid::{default, Point2D, Rect}, 6 | PageSpace, PixelsRect, 7 | }, 8 | prelude::*, 9 | }; 10 | use dioxus_free_icons::icons::bs_icons::BsEyedropper; 11 | use strum_macros::{Display, EnumIter, EnumString, IntoStaticStr}; 12 | 13 | use crate::{ 14 | components::inputs::{SelectForm, SelectFormEnum, TextInput}, 15 | pages::{WidgetEntry, WidgetIcon}, 16 | }; 17 | 18 | pub const WIDGET_ENTRY: WidgetEntry = WidgetEntry { 19 | title: "Color Picker", 20 | short_title: "Color Picker", 21 | description: "Pick a color and get its output in different formats", 22 | icon: move || ICON.icon(), 23 | }; 24 | 25 | const ICON: WidgetIcon = WidgetIcon { icon: BsEyedropper }; 26 | 27 | const SATURATION_BRIGHTNESS_BOX_ID: &str = "saturation-brightness-box"; 28 | const COLORWHEEL_ID: &str = "colorwheel"; 29 | 30 | pub fn ColorPicker() -> Element { 31 | let mut target = use_signal(|| None::<&'static str>); 32 | let mut tracking = use_signal(|| false); 33 | let mut color_state = use_context_provider(|| { 34 | Signal::new(ColorPickerState { 35 | hue: 0.0, 36 | saturation: 1.0, 37 | brightness: 1.0, 38 | alpha: 1.0, 39 | colorwheel_rect: Rect::zero(), 40 | saturation_brightness_rect: Rect::zero(), 41 | }) 42 | }); 43 | 44 | let mut process_pointer_event = move |event: Event| { 45 | color_state.with_mut(|color_state| match *target.read() { 46 | Some(SATURATION_BRIGHTNESS_BOX_ID) => { 47 | let page_coordinates = event.data().page_coordinates(); 48 | let cursor_coordinates = Point2D::::new( 49 | page_coordinates.x - color_state.saturation_brightness_rect.min().x, 50 | page_coordinates.y - color_state.saturation_brightness_rect.min().y, 51 | ); 52 | let sv_scale = 53 | default::Scale::new(color_state.saturation_brightness_rect.size.width / 100.0); 54 | let point_sv = cursor_coordinates.cast_unit() / sv_scale; 55 | color_state.saturation = x_axis_to_saturation(point_sv.x); 56 | color_state.brightness = y_axis_to_brightness(point_sv.y); 57 | } 58 | Some(COLORWHEEL_ID) => { 59 | let page_coordinates: Point2D = event.data().page_coordinates(); 60 | let center_coordinates = color_state.colorwheel_rect.center().cast_unit(); 61 | color_state.hue = cursor_position_to_hue(page_coordinates, center_coordinates); 62 | } 63 | _ => {} 64 | }) 65 | }; 66 | 67 | let modify_capture_pointer = use_signal(|| { 68 | move |pointer_id: i32, is_capturing: bool| { 69 | log::trace!("modifying capture pointer ColorPicker"); 70 | let eval = document::eval(match is_capturing { 71 | true => { 72 | r#" 73 | let pointer_id = await dioxus.recv(); 74 | console.log("capturing " + pointer_id); 75 | document.getElementById('color-picker-inner').setPointerCapture(pointer_id); 76 | "# 77 | } 78 | false => { 79 | r#" 80 | let pointer_id = await dioxus.recv(); 81 | console.log("releasing " + pointer_id); 82 | document.getElementById('color-picker-inner').releasePointerCapture(pointer_id); 83 | "# 84 | } 85 | }); 86 | eval.send(pointer_id).unwrap(); 87 | } 88 | }); 89 | 90 | rsx! { 91 | div { 92 | class: "color-picker", 93 | div { 94 | class: "color-picker-inner", 95 | id: "color-picker-inner", 96 | onpointerdown: move |event| { 97 | let pointerId = event.data().pointer_id(); 98 | event.stop_propagation(); 99 | modify_capture_pointer.with(|modify_capture_pointer| modify_capture_pointer(pointerId, true)); 100 | let pointerRect = event.data().page_coordinates(); 101 | if pointerRect.x >= color_state.read().saturation_brightness_rect.min().x 102 | && pointerRect.x <= color_state.read().saturation_brightness_rect.max().x 103 | && pointerRect.y >= color_state.read().saturation_brightness_rect.min().y 104 | && pointerRect.y <= color_state.read().saturation_brightness_rect.max().y 105 | { 106 | target.set(Some(SATURATION_BRIGHTNESS_BOX_ID)); 107 | } else { 108 | target.set(Some(COLORWHEEL_ID)); 109 | } 110 | process_pointer_event(event); 111 | }, 112 | onpointerup: move |event| { 113 | let pointerId = event.data().pointer_id(); 114 | modify_capture_pointer.with(|modify_capture_pointer| modify_capture_pointer(pointerId, false)); 115 | }, 116 | ongotpointercapture: move |_| { 117 | log::trace!("gotpointercapture"); 118 | tracking.set(true); 119 | }, 120 | onlostpointercapture: move |_| { 121 | log::trace!("lostpointercapture"); 122 | tracking.set(false); 123 | target.set(None); 124 | }, 125 | onpointermove: move |event| { 126 | if *tracking.read() { 127 | process_pointer_event(event); 128 | } 129 | }, 130 | ColorWheel {} 131 | SaturationBrightnessBox {} 132 | } 133 | ColorView {} 134 | } 135 | } 136 | } 137 | 138 | fn ColorWheel() -> Element { 139 | let mut color_state = use_context::>(); 140 | 141 | rsx! { 142 | div { 143 | class: "colorwheel-wrapper", 144 | div { 145 | class: COLORWHEEL_ID, 146 | id: COLORWHEEL_ID, 147 | onmounted: move |event| { 148 | async move { 149 | if let Ok(rect) = event.get_client_rect().await { 150 | color_state.write().colorwheel_rect = rect; 151 | } 152 | } 153 | }, 154 | ColorWheelSvg {} 155 | ColorWheelCursorSvg { 156 | hue: color_state.read().hue, 157 | } 158 | } 159 | } 160 | } 161 | } 162 | 163 | fn ColorWheelSvg() -> Element { 164 | rsx! { 165 | svg { 166 | view_box: "0 0 100 100", 167 | class: "colorwheel-svg", 168 | mask { 169 | id: "colorwheel-mask", 170 | circle { 171 | cx: 50, 172 | cy: 50, 173 | r: 50, 174 | fill: "white", 175 | } 176 | circle { 177 | cx: 50, 178 | cy: 50, 179 | r: 42.5, 180 | fill: "black", 181 | } 182 | }, 183 | foreignObject { 184 | x: 0, 185 | y: 0, 186 | width: 100, 187 | height: 100, 188 | mask: "url(#colorwheel-mask)", 189 | div { 190 | class: "colorwheel-gradient", 191 | } 192 | }, 193 | } 194 | } 195 | } 196 | 197 | #[component] 198 | fn ColorWheelCursorSvg(hue: f64) -> Element { 199 | rsx! { 200 | CursorPrimitiveSvg { 201 | class: "colorwheel-cursor", 202 | fill: "hsl({hue}deg, 100%, 50%)", 203 | transform: "rotate({hue_to_css_rotation(hue)} 50 50)", 204 | } 205 | } 206 | } 207 | 208 | fn SaturationBrightnessBox() -> Element { 209 | let mut color_state = use_context::>(); 210 | 211 | rsx! { 212 | div { 213 | class: "saturation-brightness-wrapper", 214 | div { 215 | class: SATURATION_BRIGHTNESS_BOX_ID, 216 | id: SATURATION_BRIGHTNESS_BOX_ID, 217 | onmounted: move |event| { 218 | async move { 219 | if let Ok(rect) = event.get_client_rect().await { 220 | color_state.write().saturation_brightness_rect = rect; 221 | } 222 | } 223 | }, 224 | div { 225 | class: "saturation-brightness-gradient", 226 | style: "background-color: hsl({color_state.read().hue}deg, 100%, 50%);" 227 | } 228 | CursorPrimitiveSvg { 229 | class: "saturation-brightness-cursor", 230 | fill: "{color_state.read().get_rgb_string()}", 231 | x: saturation_to_x_axis(color_state.read().saturation), 232 | y: brightness_to_y_axis(color_state.read().brightness), 233 | scale_factor: 2, 234 | } 235 | } 236 | } 237 | } 238 | } 239 | 240 | #[component] 241 | fn CursorPrimitiveSvg( 242 | x: Option, 243 | y: Option, 244 | class: Option, 245 | fill: String, 246 | transform: Option, 247 | scale_factor: Option, 248 | ) -> Element { 249 | let scale_factor = scale_factor.unwrap_or(1); 250 | rsx! { 251 | svg { 252 | view_box: "0 0 100 100", 253 | class: class.unwrap_or("".to_string()), 254 | defs { 255 | radialGradient { 256 | id: "cursor-border", 257 | stop { 258 | offset: "0%", 259 | stop_color: "white", 260 | stop_opacity: 0, 261 | } 262 | stop { 263 | offset: "50%", 264 | stop_color: "white", 265 | stop_opacity: 1, 266 | } 267 | stop { 268 | offset: "90%", 269 | stop_color: "white", 270 | stop_opacity: 1, 271 | } 272 | stop { 273 | offset: "90%", 274 | stop_color: "lightgray", 275 | stop_opacity: 1, 276 | } 277 | stop { 278 | offset: "100%", 279 | stop_color: "lightgray", 280 | stop_opacity: 0, 281 | } 282 | } 283 | } 284 | g { 285 | transform: transform.unwrap_or("".to_string()), 286 | circle { 287 | cx: x.unwrap_or(50f64), 288 | cy: y.unwrap_or(3.75 * scale_factor as f64), 289 | r: 3.75 * scale_factor as f64, 290 | stroke: "url(#cursor-border)", 291 | stroke_width: 2 * scale_factor, 292 | fill: fill 293 | } 294 | } 295 | } 296 | } 297 | } 298 | 299 | fn ColorView() -> Element { 300 | let mut color_format = use_signal(ColorFormat::default); 301 | let color_state = use_context::>(); 302 | let color = color_state.read().get_color(); 303 | let rgb_string = color.to_rgb_string(); 304 | let color_text = match *color_format.read() { 305 | ColorFormat::RGB => rgb_string.clone(), 306 | ColorFormat::HSL => color.to_hsl_string(), 307 | ColorFormat::HSV => color.to_hsv_string(), 308 | ColorFormat::HEX => color.to_hex_string(), 309 | ColorFormat::HWB => color.to_hwb_string(), 310 | ColorFormat::CMYK => color.to_cmyk_string(), 311 | }; 312 | rsx! { 313 | div { 314 | class: "color-view", 315 | div { 316 | class: "color-view-display", 317 | style: "--color-view-background: {rgb_string};" 318 | } 319 | TextInput { 320 | label: "Color", 321 | value: color_text, 322 | readonly: true 323 | } 324 | SelectForm:: { 325 | label: "Color Format", 326 | oninput: move |new_format: ColorFormat| { 327 | color_format.set(new_format) 328 | }, 329 | value: *color_format.read(), 330 | } 331 | } 332 | } 333 | } 334 | 335 | struct ColorPickerState { 336 | hue: f64, 337 | saturation: f64, 338 | brightness: f64, 339 | alpha: f64, 340 | colorwheel_rect: PixelsRect, 341 | saturation_brightness_rect: PixelsRect, 342 | } 343 | 344 | #[derive( 345 | Copy, Clone, Default, Debug, Display, EnumIter, EnumString, Hash, IntoStaticStr, PartialEq, 346 | )] 347 | #[allow(clippy::upper_case_acronyms)] 348 | enum ColorFormat { 349 | #[default] 350 | RGB, 351 | HSL, 352 | HSV, 353 | HEX, 354 | HWB, 355 | CMYK, 356 | } 357 | 358 | impl SelectFormEnum for ColorFormat {} 359 | 360 | impl From for String { 361 | fn from(color_format: ColorFormat) -> Self { 362 | color_format.to_string() 363 | } 364 | } 365 | 366 | impl ColorPickerState { 367 | fn get_color(&self) -> Color { 368 | Color::new_hsva(self.hue, self.saturation, self.brightness, self.alpha) 369 | } 370 | 371 | fn get_rgb_string(&self) -> String { 372 | self.get_color().to_rgb_string() 373 | } 374 | } 375 | 376 | fn cursor_position_to_hue( 377 | cursor_coordinates: Point2D, 378 | center_coordinates: Point2D, 379 | ) -> f64 { 380 | log::trace!( 381 | "cursor_coordinates: {:?}, center_coordinates: {:?}", 382 | cursor_coordinates, 383 | center_coordinates 384 | ); 385 | let vector = cursor_coordinates - center_coordinates; 386 | let angle = vector.yx().angle_from_x_axis().positive().to_degrees() - 90f64; 387 | let angle = angle % 360f64; 388 | log::trace!("vector: {:?}, angle: {:?}", vector, angle); 389 | angle 390 | } 391 | 392 | fn hue_to_css_rotation(hue: f64) -> f64 { 393 | (450f64 - hue).abs() % 360f64 394 | } 395 | 396 | fn saturation_to_x_axis(saturation: f64) -> f64 { 397 | saturation * 100f64 398 | } 399 | 400 | fn brightness_to_y_axis(brightness: f64) -> f64 { 401 | 100f64 - (brightness * 100f64) 402 | } 403 | 404 | fn x_axis_to_saturation(x: f64) -> f64 { 405 | x / 100f64 406 | } 407 | 408 | fn y_axis_to_brightness(y: f64) -> f64 { 409 | (100f64 - y) / 100f64 410 | } 411 | -------------------------------------------------------------------------------- /src/pages/media/mod.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | use dioxus_router::prelude::*; 3 | use strum_macros::EnumIter; 4 | 5 | pub mod color_picker; 6 | 7 | use crate::pages::{Route, WidgetEntry, WidgetRoute}; 8 | use color_picker::ColorPicker; 9 | 10 | #[derive(Clone, Debug, EnumIter, PartialEq, Routable)] 11 | pub enum MediaRoute { 12 | #[route("/")] 13 | Index {}, 14 | #[route("/color-picker")] 15 | ColorPicker {}, 16 | } 17 | 18 | fn Index() -> Element { 19 | rsx! { 20 | div { 21 | class: "media", 22 | } 23 | } 24 | } 25 | 26 | impl WidgetRoute for MediaRoute { 27 | fn get_widget_routes() -> Vec { 28 | Self::get_widgets() 29 | .iter() 30 | .map(|widget| Route::Media { 31 | child: widget.clone(), 32 | }) 33 | .collect() 34 | } 35 | 36 | fn get_widget_type_string() -> &'static str { 37 | "Media" 38 | } 39 | 40 | fn get_widget_entry(&self) -> Option<&'static WidgetEntry> { 41 | match self { 42 | Self::ColorPicker { .. } => Some(&color_picker::WIDGET_ENTRY), 43 | _ => None, 44 | } 45 | } 46 | } 47 | 48 | impl Default for MediaRoute { 49 | fn default() -> Self { 50 | Self::Index {} 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/pages/mod.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | use dioxus_free_icons::{Icon, IconShape}; 3 | use dioxus_router::prelude::*; 4 | use strum::IntoEnumIterator; 5 | use strum_macros::EnumIter; 6 | 7 | pub mod converter; 8 | pub mod encoder_decoder; 9 | pub mod generator; 10 | pub mod home_page; 11 | pub mod layout; 12 | pub mod media; 13 | 14 | use converter::ConverterRoute; 15 | use encoder_decoder::EncoderDecoderRoute; 16 | use generator::GeneratorRoute; 17 | use home_page::HomePage; 18 | use layout::{Container, WidgetView}; 19 | use media::MediaRoute; 20 | 21 | #[rustfmt::skip] 22 | #[derive(Clone, Debug, EnumIter, PartialEq, Routable)] 23 | pub enum Route { 24 | #[layout(Container)] 25 | #[layout(WidgetView)] 26 | #[child("/encoder-decoder")] 27 | EncoderDecoder { 28 | child: EncoderDecoderRoute, 29 | }, 30 | #[child("/media")] 31 | Media { 32 | child: MediaRoute, 33 | }, 34 | #[child("/converter")] 35 | Converter { 36 | child: ConverterRoute, 37 | }, 38 | #[child("/generator")] 39 | Generator { 40 | child: GeneratorRoute, 41 | }, 42 | #[route("/home")] 43 | HomePage {}, 44 | #[end_layout] 45 | #[end_layout] 46 | #[redirect("/", || Route::HomePage {})] 47 | #[route("/:..route")] 48 | PageNotFound { 49 | route: Vec, 50 | }, 51 | } 52 | 53 | #[component] 54 | fn PageNotFound(route: Vec) -> Element { 55 | rsx! { 56 | h1 { "Page not found" } 57 | p { "We are terribly sorry, but the page you requested doesn't exist." } 58 | pre { 59 | color: "red", 60 | "log:\nattemped to navigate to: {route:?}" 61 | } 62 | } 63 | } 64 | 65 | impl Route { 66 | pub fn get_widget_entry(&self) -> Option<&'static WidgetEntry> { 67 | match self { 68 | Self::EncoderDecoder { child } => child.get_widget_entry(), 69 | Self::Converter { child } => child.get_widget_entry(), 70 | Self::Media { child } => child.get_widget_entry(), 71 | Self::Generator { child } => child.get_widget_entry(), 72 | _ => None, 73 | } 74 | } 75 | 76 | pub fn get_widget_type_string(&self) -> Option<&'static str> { 77 | match self { 78 | Self::EncoderDecoder { .. } => Some(EncoderDecoderRoute::get_widget_type_string()), 79 | Self::Converter { .. } => Some(ConverterRoute::get_widget_type_string()), 80 | Self::Media { .. } => Some(MediaRoute::get_widget_type_string()), 81 | Self::Generator { .. } => Some(GeneratorRoute::get_widget_type_string()), 82 | _ => None, 83 | } 84 | } 85 | 86 | pub fn get_widgets(&self) -> Vec { 87 | match self { 88 | Self::EncoderDecoder { .. } => EncoderDecoderRoute::get_widget_routes(), 89 | Self::Converter { .. } => ConverterRoute::get_widget_routes(), 90 | Self::Media { .. } => MediaRoute::get_widget_routes(), 91 | Self::Generator { .. } => GeneratorRoute::get_widget_routes(), 92 | _ => vec![], 93 | } 94 | } 95 | } 96 | 97 | pub trait WidgetRoute: Routable + IntoEnumIterator + PartialEq + Clone { 98 | fn get_widget_routes() -> Vec; 99 | 100 | fn get_widgets() -> Vec { 101 | Self::iter() 102 | .filter(|route| route.get_widget_entry().is_some()) 103 | .collect() 104 | } 105 | 106 | fn get_widget_title_string(&self) -> Option<&'static str> { 107 | Some(self.get_widget_entry()?.title) 108 | } 109 | 110 | fn get_widget_short_title_string(&self) -> Option<&'static str> { 111 | Some(self.get_widget_entry()?.short_title) 112 | } 113 | 114 | fn get_widget_description_string(&self) -> Option<&'static str> { 115 | Some(self.get_widget_entry()?.description) 116 | } 117 | 118 | fn get_widget_type_string() -> &'static str; 119 | 120 | fn get_widget_entry(&self) -> Option<&'static WidgetEntry>; 121 | } 122 | 123 | #[derive(PartialEq, Eq, Clone, Copy, Debug)] 124 | pub struct WidgetEntry { 125 | pub title: &'static str, 126 | pub short_title: &'static str, 127 | pub description: &'static str, 128 | pub icon: fn() -> Element, 129 | } 130 | 131 | pub struct WidgetIcon { 132 | pub(crate) icon: T, 133 | } 134 | 135 | impl WidgetIcon { 136 | pub fn icon(self) -> Element { 137 | rsx! { 138 | Icon:: { 139 | class: "icon", 140 | icon: self.icon, 141 | } 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | /// Adds a delimiter to a number string at a given frequency. 2 | pub fn add_number_delimiters(number_str: String, delimiter: char, frequency: usize) -> String { 3 | number_str 4 | .chars() 5 | .rev() 6 | .enumerate() 7 | .flat_map(|(i, c)| { 8 | if i != 0 && i % frequency == 0 { 9 | Some(delimiter) 10 | } else { 11 | None 12 | } 13 | .into_iter() 14 | .chain(std::iter::once(c)) 15 | }) 16 | .collect::() 17 | .chars() 18 | .rev() 19 | .collect::() 20 | } 21 | 22 | /// Removes all non-alphanumeric characters from a string. 23 | pub fn sanitize_string(string: String) -> String { 24 | string 25 | .chars() 26 | .filter(|character| character.is_ascii_alphanumeric()) 27 | .collect::() 28 | } --------------------------------------------------------------------------------