diff --git a/Cargo.toml b/Cargo.toml index 1ed1e48..9a16b39 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ claims = "0.8" color-eyre = "0.6" rand = "0.9" rstest = "0.26" +strum = "0.27" thiserror = "2" [workspace.dependencies.aes] diff --git a/cipher-core/src/lib.rs b/cipher-core/src/lib.rs index 309d950..c17713a 100644 --- a/cipher-core/src/lib.rs +++ b/cipher-core/src/lib.rs @@ -7,3 +7,7 @@ pub use { traits::{BlockCipher, BlockParser, InputBlock}, types::{CipherAction, Output}, }; + +pub mod prelude { + pub use super::{CipherAction, CipherResult, InputBlock, Output}; +} diff --git a/cipher-factory/Cargo.toml b/cipher-factory/Cargo.toml index 33d8391..f07a377 100644 --- a/cipher-factory/Cargo.toml +++ b/cipher-factory/Cargo.toml @@ -9,6 +9,7 @@ aes.workspace = true cipher-core.workspace = true clap = { workspace = true, optional = true } des.workspace = true +strum = { workspace = true, features = ["derive"] } [features] default = [] diff --git a/cipher-factory/src/algorithm.rs b/cipher-factory/src/algorithm.rs index 5eb2c6e..903edf2 100644 --- a/cipher-factory/src/algorithm.rs +++ b/cipher-factory/src/algorithm.rs @@ -74,8 +74,8 @@ impl Algorithm { impl Display for Algorithm { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let s = match self { - Self::Des => "Des", - Self::Aes => "Aes", + Self::Des => "DES", + Self::Aes => "AES", }; f.write_str(s) } diff --git a/cipher-factory/src/context.rs b/cipher-factory/src/context.rs index 7891285..e384d14 100644 --- a/cipher-factory/src/context.rs +++ b/cipher-factory/src/context.rs @@ -1,15 +1,34 @@ -use crate::{Algorithm, OperationChoice, OutputFormat}; +use crate::{Algorithm, OperationMode, OutputFormat}; use cipher_core::{BlockCipher, CipherResult}; +#[derive(Clone)] pub struct CipherContext { pub algorithm: Algorithm, - pub operation: OperationChoice, + pub operation: OperationMode, pub key: String, pub input_text: String, pub output_format: OutputFormat, } impl CipherContext { + #[inline] + #[must_use] + pub const fn new( + algorithm: Algorithm, + operation: OperationMode, + key: String, + input_text: String, + output_format: OutputFormat, + ) -> Self { + Self { + algorithm, + operation, + key, + input_text, + output_format, + } + } + pub fn process(&self) -> CipherResult { let text_bytes = self.algorithm.parse_text(&self.input_text)?; let cipher = self.algorithm.new_cipher(&self.key)?; @@ -18,13 +37,13 @@ impl CipherContext { fn execute(&self, cipher: &dyn BlockCipher, text_bytes: &[u8]) -> CipherResult { match self.operation { - OperationChoice::Encrypt => { + OperationMode::Encrypt => { let ciphertext = cipher.encrypt(text_bytes)?; Ok(format!("{ciphertext:X}")) } - OperationChoice::Decrypt => { + OperationMode::Decrypt => { let plaintext = cipher.decrypt(text_bytes)?; - let output = self.output_format.to_string(&plaintext); + let output = self.output_format.format(&plaintext); Ok(output) } } diff --git a/cipher-factory/src/lib.rs b/cipher-factory/src/lib.rs index 2bfd4fb..ef98a38 100644 --- a/cipher-factory/src/lib.rs +++ b/cipher-factory/src/lib.rs @@ -4,5 +4,9 @@ mod operation; mod output; pub use { - algorithm::Algorithm, context::CipherContext, operation::OperationChoice, output::OutputFormat, + algorithm::Algorithm, context::CipherContext, operation::OperationMode, output::OutputFormat, }; + +pub mod prelude { + pub use super::{Algorithm, CipherContext, OperationMode, OutputFormat}; +} diff --git a/cipher-factory/src/operation.rs b/cipher-factory/src/operation.rs index 76d2279..49716db 100644 --- a/cipher-factory/src/operation.rs +++ b/cipher-factory/src/operation.rs @@ -1,6 +1,39 @@ +use std::{convert::Infallible, fmt::Display, str::FromStr}; + #[cfg_attr(feature = "clap", derive(clap::ValueEnum))] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum OperationChoice { +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum OperationMode { + #[default] Encrypt, Decrypt, } + +impl OperationMode { + #[must_use] + pub const fn invert(self) -> Self { + match self { + Self::Encrypt => Self::Decrypt, + Self::Decrypt => Self::Encrypt, + } + } +} + +impl Display for OperationMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + Self::Encrypt => "Encrypt", + Self::Decrypt => "Decrypt", + }; + f.write_str(s) + } +} + +impl FromStr for OperationMode { + type Err = Infallible; + fn from_str(s: &str) -> Result { + match s.trim().to_lowercase().as_ref() { + "decrypt" => Ok(Self::Decrypt), + _ => Ok(Self::Encrypt), + } + } +} diff --git a/cipher-factory/src/output.rs b/cipher-factory/src/output.rs index 1ee6b98..7e0de90 100644 --- a/cipher-factory/src/output.rs +++ b/cipher-factory/src/output.rs @@ -1,7 +1,9 @@ use cipher_core::Output; +use std::{convert::Infallible, fmt::Display, str::FromStr}; +use strum::EnumIter; #[cfg_attr(feature = "clap", derive(clap::ValueEnum))] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, EnumIter)] pub enum OutputFormat { /// Binary output Binary, @@ -16,7 +18,7 @@ pub enum OutputFormat { impl OutputFormat { #[must_use] - pub fn to_string(&self, value: &Output) -> String { + pub fn format(&self, value: &Output) -> String { match self { Self::Binary => format!("{value:b}"), Self::Octal => format!("{value:o}"), @@ -25,3 +27,27 @@ impl OutputFormat { } } } + +impl FromStr for OutputFormat { + type Err = Infallible; + fn from_str(s: &str) -> Result { + Ok(match s.trim().to_lowercase().as_ref() { + "binary" | "bin" => Self::Binary, + "octal" | "oct" => Self::Octal, + "text" | "txt" => Self::Text, + _ => Self::Hex, + }) + } +} + +impl Display for OutputFormat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + Self::Binary => "Binary", + Self::Octal => "Octal", + Self::Hex => "Hexadecimal", + Self::Text => "Text", + }; + f.write_str(s) + } +} diff --git a/cli/src/args.rs b/cli/src/args.rs index c43a187..f5608fd 100644 --- a/cli/src/args.rs +++ b/cli/src/args.rs @@ -1,4 +1,4 @@ -use cipher_factory::{Algorithm, CipherContext, OperationChoice, OutputFormat}; +use cipher_factory::{Algorithm, CipherContext, OperationMode, OutputFormat}; use clap::Parser; #[derive(Debug, Clone, Parser)] @@ -6,7 +6,7 @@ use clap::Parser; pub struct Args { /// Operation to perform #[arg(value_name = "OPERATION")] - pub operation: OperationChoice, + pub operation: OperationMode, /// Encryption algorithm #[arg(short, long)] diff --git a/web/Cargo.toml b/web/Cargo.toml index 142447c..914292d 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -8,14 +8,20 @@ edition.workspace = true crate-type = ["cdylib", "rlib"] [dependencies] -leptos = { version = "0.8.0", features = ["nightly"] } -leptos_router = { version = "0.8.0", features = ["nightly"] } -axum = { version = "0.8.0", optional = true } +aes.workspace = true +axum = { version = "0.8", optional = true } +cipher-core.workspace = true +cipher-factory.workspace = true console_error_panic_hook = { version = "0.1", optional = true } -leptos_axum = { version = "0.8.0", optional = true } -leptos_meta = { version = "0.8.0" } +des.workspace = true +leptos = { version = "0.8", features = ["nightly"] } +leptos_axum = { version = "0.8", optional = true } +leptos_meta = { version = "0.8" } +leptos_router = { version = "0.8", features = ["nightly"] } +strum.workspace = true tokio = { version = "1", features = ["rt-multi-thread"], optional = true } wasm-bindgen = { version = "=0.2.104", optional = true } +web-sys = "0.3" [features] hydrate = ["leptos/hydrate", "dep:console_error_panic_hook", "dep:wasm-bindgen"] diff --git a/web/src/app.rs b/web/src/app.rs index 0097e73..662ef70 100644 --- a/web/src/app.rs +++ b/web/src/app.rs @@ -1,10 +1,11 @@ -use crate::pages::{des::DesPage, home::Home}; +use crate::pages::{aes::AesPage, des::DesPage, home::Home}; use leptos::prelude::*; use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context}; use leptos_router::{ StaticSegment, components::{A, Route, Router, Routes}, }; +use std::fmt::Display; #[must_use] pub fn shell(options: LeptosOptions) -> impl IntoView { @@ -25,22 +26,52 @@ pub fn shell(options: LeptosOptions) -> impl IntoView { } } +#[derive(Clone, Copy, PartialEq)] +enum Theme { + Light, + Dark, +} + +impl Theme { + const fn inverse(self) -> Self { + match self { + Self::Light => Self::Dark, + Self::Dark => Self::Light, + } + } +} + +impl Display for Theme { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + Self::Light => "☀️ Light", + Self::Dark => "🌙 Dark", + }; + f.write_str(s) + } +} + #[component] // Provides context that manages stylesheets, titles, meta tags, etc. pub fn App() -> impl IntoView { provide_meta_context(); - let (is_light, set_is_light) = signal(false); + let (theme, set_theme) = signal(Theme::Dark); let toggle_theme = move |_| { - set_is_light.update(|light| *light = !*light); + set_theme.update(|t| *t = t.inverse()); if let Some(body) = document().body() { let class_list = body.class_list(); - if is_light.get() { - let _ = class_list.add_1("light-theme"); - } else { - let _ = class_list.remove_1("light-theme"); + match theme.get() { + Theme::Light => { + let _ = class_list.remove_1("dark-theme"); + let _ = class_list.add_1("light-theme"); + } + Theme::Dark => { + let _ = class_list.remove_1("light-theme"); + let _ = class_list.add_1("dark-theme"); + } } } }; @@ -69,14 +100,14 @@ pub fn App() -> impl IntoView {
- +
diff --git a/web/src/components/cipher_form.rs b/web/src/components/cipher_form.rs index 3a0c3d3..cbb5985 100644 --- a/web/src/components/cipher_form.rs +++ b/web/src/components/cipher_form.rs @@ -1,62 +1,93 @@ +use cipher_factory::prelude::*; use leptos::prelude::*; -type LogicFn = Box (String, String)>; +use std::str::FromStr; +use strum::IntoEnumIterator; #[component] -pub fn CipherForm(title: &'static str, logic: LogicFn) -> impl IntoView { - let (mode, set_mode) = signal("Encrypt".to_string()); +pub fn CipherForm(algorithm: Algorithm) -> impl IntoView { + let (mode, set_mode) = signal(OperationMode::Encrypt); + let (output_fmt, set_output_fmt) = signal(OutputFormat::Hex); + let (key_input, set_key_input) = signal(String::new()); let (text_input, set_text_input) = signal(String::new()); + let (output, set_output) = signal(String::new()); let (error_msg, set_error_msg) = signal(String::new()); + let (copy_feedback, set_copy_feedback) = signal(false); - let handle_submit = move |_| { + let handle_submit = move || { set_error_msg(String::new()); set_output(String::new()); + set_copy_feedback(false); - let is_encrypt = mode.get() == "Encrypt"; let key = key_input.get(); let text = text_input.get(); if key.is_empty() || text.is_empty() { - set_error_msg("Please enter both key and text/hex.".to_string()); + set_error_msg("Please enter both key and input text.".to_string()); return; } - let (res_out, res_err) = logic(is_encrypt, key, text); - - if !res_err.is_empty() { - set_error_msg(res_err); - return; + let context = CipherContext::new(algorithm, mode.get(), key, text, output_fmt.get()); + match context.process() { + Ok(out) => set_output(out), + Err(e) => set_error_msg(e.to_string()), } - set_output(res_out); }; view! {
-

{title} " Encryption"

+
+

{algorithm.to_string()}

+
+
- -
- +
+
+ - "Encrypt" - - +
+ {move || { + if mode.get() != OperationMode::Decrypt { + return view! { }.into_any(); + } + view! { +
+
+ + +
+
+ } + .into_any() + }}
@@ -71,10 +102,9 @@ pub fn CipherForm(title: &'static str, logic: LogicFn) -> impl IntoView {
@@ -86,31 +116,55 @@ pub fn CipherForm(title: &'static str, logic: LogicFn) -> impl IntoView { />
- - {move || { - if error_msg.get().is_empty() { - view! { }.into_any() - } else { - view! {
{error_msg.get()}
}.into_any() - } - }} - + // Output Section {move || { if output.get().is_empty() { - view! { }.into_any() - } else { - view! { -
- "Output:" + return view! { }.into_any(); + } + view! { +
+
+ "Output ("{output_fmt.get().to_string()}")" {output.get()}
- } - .into_any() +
} + .into_any() + }} + + // Error Section + {move || { + if error_msg.get().is_empty() { + return view! { }.into_any(); + } + view! {
{error_msg.get()}
}.into_any() }}
} } + +#[component] +fn RadioButton( + value: OperationMode, + current: ReadSignal, + set_current: WriteSignal, +) -> impl IntoView { + view! { +
+ +
+ } +} diff --git a/web/src/pages/aes.rs b/web/src/pages/aes.rs new file mode 100644 index 0000000..1886b36 --- /dev/null +++ b/web/src/pages/aes.rs @@ -0,0 +1,8 @@ +use crate::components::cipher_form::CipherForm; +use cipher_factory::Algorithm; +use leptos::prelude::*; + +#[component] +pub fn AesPage() -> impl IntoView { + view! { } +} diff --git a/web/src/pages/des.rs b/web/src/pages/des.rs index f6f7bdc..32bf1a4 100644 --- a/web/src/pages/des.rs +++ b/web/src/pages/des.rs @@ -1,13 +1,8 @@ use crate::components::cipher_form::CipherForm; +use cipher_factory::Algorithm; use leptos::prelude::*; #[component] pub fn DesPage() -> impl IntoView { - let des_logic = Box::new( - |encrypt: bool, key_str: String, text_str: String| -> (String, String) { - (String::new(), String::new()) - }, - ); - - view! { } + view! { } } diff --git a/web/src/pages/mod.rs b/web/src/pages/mod.rs index 9e0e9b4..06fe82c 100644 --- a/web/src/pages/mod.rs +++ b/web/src/pages/mod.rs @@ -1,2 +1,3 @@ +pub mod aes; pub mod des; pub mod home; diff --git a/web/style/main.scss b/web/style/main.scss index bb06a9e..a1248ab 100644 --- a/web/style/main.scss +++ b/web/style/main.scss @@ -26,6 +26,8 @@ $l-iris: #907aa9; $l-hl-low: #f4ede8; $l-hl-high: #cecacd; +$control-height: 46px; + :root, body.dark-theme { // Default to Dark Mode @@ -135,13 +137,116 @@ main { border-radius: 12px; padding: 2rem; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); +} + +.card-header { + border-bottom: 1px solid var(--border); + padding-bottom: 1rem; + margin-bottom: 1.5rem; h2 { - margin-top: 0; + margin: 0; + border: none; + padding: none; color: var(--secondary); - border-bottom: 1px solid var(--border); - padding-bottom: 15px; - margin-bottom: 25px; + font-size: 1.5rem; + } +} + +.controls-row { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 10px; + min-height: $control-height; +} + +.radio-group { + display: flex; + justify-content: center; + align-items: center; + gap: 20px; + background: var(--bg-input); + border-radius: 8px; + width: fit-content; + + height: $control-height; + padding: 0 16px; + box-sizing: border-box; + + .radio-button { + label { + margin: 0; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + color: var(--text-main); + text-transform: none; + height: 100%; + } + + input[type="radio"] { + accent-color: var(--primary); + margin: 0; + } + } +} + +.format-controls-box { + display: flex; + font-size: 0.9rem; + background: var(--bg-input); + border-radius: 8px; + + height: $control-height; + padding: 0 12px; + box-sizing: border-box; + + animation: fadeIn 0.2s ease-in-out; + + .format-controls { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + + label { + margin: 0; + color: var(--text-muted); + font-weight: normal; + white-space: nowrap; + } + + select { + height: 32px; + padding: 0 12px; + border-radius: 6px; + border: 1px solid var(--border); + background-color: var(--bg-input); + color: var(--text-main); + cursor: pointer; + font-size: 0.9rem; + box-sizing: border-box; + + &:focus { + outline: none; + border-color: var(--primary); + } + } + } +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateX(5px); + } + + to { + opacity: 1; + transform: translateX(0); } } @@ -183,29 +288,6 @@ main { } } -.radio-group { - display: flex; - gap: 20px; - background: var(--bg-input); - padding: 10px; - border-radius: 8px; - width: fit-content; - - label { - margin: 0; - cursor: pointer; - display: flex; - align-items: center; - gap: 8px; - color: var(--text-main); - text-transform: none; - } - - input[type="radio"] { - accent-color: var(--primary); - } -} - .btn-primary { background-color: var(--primary); color: var(--bg-body); @@ -232,25 +314,66 @@ main { border-radius: 6px; } +.error-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background-color: rgba(0, 0, 0, 0.03); + border-bottom: 1px solid var(--border); + + strong { + background: transparent; + padding: 0; + color: var(--text-muted); + } +} + .result-box { margin-top: 1.5rem; background: var(--bg-highlight); border-radius: 8px; + border: 1px solid var(--border); overflow: hidden; - strong { - display: block; - padding: 8px 12px; - background: rgba(0, 0, 0, 0.1); - color: var(--text-muted); - font-size: 0.85rem; - } - code { display: block; padding: 15px; word-break: break-all; font-family: "Consolas", "Monaco", monospace; color: var(--accent); + background: transparent; + } +} + +.result-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background-color: rgba(0, 0, 0, 0.05); + border-bottom: 1px solid var(--border); + + strong { + font-size: 0.85rem; + color: var(--text-muted); + } +} + +.btn-copy { + background: transparent; + border: none; + color: var(--primary); + font-weight: 700; + font-size: 0.85rem; + cursor: pointer; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 4px 8px; + border-radius: 4px; + transition: all 0.2s; + + &:hover { + background-color: rgba(0, 0, 0, 0.05); } }