diff --git a/web/Cargo.toml b/web/Cargo.toml index 7dabfb5..d02bee1 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -26,7 +26,9 @@ web-sys = { version = "0.3", features = [ "Blob", "Clipboard", "Crypto", + "DataTransfer", "Document", + "DragEvent", "Element", "Event", "EventTarget", diff --git a/web/src/components/cipher_form_cbc.rs b/web/src/components/cipher_form_cbc.rs index 74db93f..d67e0a0 100644 --- a/web/src/components/cipher_form_cbc.rs +++ b/web/src/components/cipher_form_cbc.rs @@ -105,7 +105,8 @@ pub fn CipherFormCbc() -> impl IntoView { OutputFormat::Text => { String::from_utf8(plaintext).unwrap_or_else(|_| { set_error_msg( - "Output contains invalid UTF-8. Try Hex format.".to_string(), + "Output contains invalid UTF-8. Try Hex format." + .to_string(), ); String::new() }) @@ -242,8 +243,7 @@ fn parse_hex_string(s: &str) -> Result, String> { (0..s.len()) .step_by(2) .map(|i| { - u8::from_str_radix(&s[i..i + 2], 16) - .map_err(|_| format!("Invalid hex at position {i}")) + u8::from_str_radix(&s[i..i + 2], 16).map_err(|_| format!("Invalid hex at position {i}")) }) .collect() } @@ -275,16 +275,28 @@ fn download_bytes(bytes: &[u8], filename: &str) { let array = Array::new(); array.push(&uint8_array.buffer()); - let blob = Blob::new_with_u8_array_sequence(&array).unwrap(); - let url = Url::create_object_url_with_blob(&blob).unwrap(); + let Some(blob) = Blob::new_with_u8_array_sequence(&array).ok() else { + return; + }; + let Some(url) = Url::create_object_url_with_blob(&blob).ok() else { + return; + }; - let document = web_sys::window().unwrap().document().unwrap(); - let a = document.create_element("a").unwrap(); - a.set_attribute("href", &url).unwrap(); - a.set_attribute("download", filename).unwrap(); + let Some(window) = web_sys::window() else { + return; + }; + let Some(document) = window.document() else { + return; + }; + let Some(a) = document.create_element("a").ok() else { + return; + }; + + let _ = a.set_attribute("href", &url); + let _ = a.set_attribute("download", filename); let a: web_sys::HtmlElement = a.unchecked_into(); a.click(); - Url::revoke_object_url(&url).unwrap(); + let _ = Url::revoke_object_url(&url); } diff --git a/web/src/components/file_input.rs b/web/src/components/file_input.rs index f26bb45..b85fbb3 100644 --- a/web/src/components/file_input.rs +++ b/web/src/components/file_input.rs @@ -1,7 +1,7 @@ use js_sys::{ArrayBuffer, Uint8Array}; use leptos::prelude::*; -use wasm_bindgen::{prelude::*, JsCast}; -use web_sys::{Event, File, FileList, FileReader, HtmlInputElement}; +use wasm_bindgen::{JsCast, prelude::*}; +use web_sys::{DragEvent, Event, File, FileReader, HtmlInputElement}; #[derive(Clone, Debug, PartialEq, Eq)] pub enum InputMode { @@ -9,6 +9,230 @@ pub enum InputMode { File, } +fn read_file( + file: File, + set_file_name: WriteSignal>, + set_file_data: WriteSignal>>, +) { + let name = file.name(); + set_file_name(Some(name)); + + let Some(reader) = FileReader::new().ok() else { + return; + }; + let reader_clone = reader.clone(); + + let onload = Closure::wrap(Box::new(move |_: web_sys::ProgressEvent| { + if let Ok(result) = reader_clone.result() + && let Some(array_buffer) = result.dyn_ref::() + { + let uint8_array = Uint8Array::new(array_buffer); + set_file_data(Some(uint8_array.to_vec())); + } + }) as Box); + + reader.set_onload(Some(onload.as_ref().unchecked_ref())); + onload.forget(); + + let _ = reader.read_as_array_buffer(&file); +} + +#[component] +fn InputModeToggle( + input_mode: ReadSignal, + set_input_mode: WriteSignal, +) -> impl IntoView { + view! { +
+ + +
+ } +} + +#[component] +fn TextAreaInput( + text_content: ReadSignal, + set_text_content: WriteSignal, + is_decrypt_mode: Memo, +) -> impl IntoView { + let handle_text_change = move |ev: Event| { + if let Some(target) = ev.target() { + let textarea: web_sys::HtmlTextAreaElement = target.unchecked_into(); + set_text_content(textarea.value()); + } + }; + + view! { +
+ +
+ {move || format!("{} characters", text_content.get().len())} +
+
+ } +} + +#[component] +fn FileDropZone( + file_data: ReadSignal>>, + set_file_data: WriteSignal>>, + file_name: ReadSignal>, + set_file_name: WriteSignal>, +) -> impl IntoView { + let (is_dragging, set_is_dragging) = signal(false); + + let handle_file_change = move |ev: Event| { + if let Some(target) = ev.target() { + let input: HtmlInputElement = target.unchecked_into(); + if let Some(files) = input.files() + && let Some(file) = files.get(0) + { + read_file(file, set_file_name, set_file_data); + } + } + }; + + let handle_drag_over = move |ev: DragEvent| { + ev.prevent_default(); + set_is_dragging(true); + }; + + let handle_drag_enter = move |ev: DragEvent| { + ev.prevent_default(); + set_is_dragging(true); + }; + + let handle_drag_leave = move |ev: DragEvent| { + ev.prevent_default(); + set_is_dragging(false); + }; + + let handle_drop = move |ev: DragEvent| { + ev.prevent_default(); + set_is_dragging(false); + + if let Some(data_transfer) = ev.data_transfer() + && let Some(files) = data_transfer.files() + && let Some(file) = files.get(0) + { + read_file(file, set_file_name, set_file_data); + } + }; + + view! { +
+ + +
+ } +} + +#[component] +fn FileSelected( + name: String, + file_data: ReadSignal>>, + set_file_name: WriteSignal>, + set_file_data: WriteSignal>>, +) -> impl IntoView { + let clear_file = move |ev: web_sys::MouseEvent| { + ev.prevent_default(); + ev.stop_propagation(); + set_file_name(None); + set_file_data(None); + }; + + view! { +
+ "[FILE]" + {name} + + {move || file_data.get().map_or(String::new(), |d| format!("({} bytes)", d.len()))} + + +
+ } +} + +#[component] +fn FilePlaceholder() -> impl IntoView { + view! { +
+ "[+]" + "Click to select a file or drag and drop" +
+ } +} + #[component] pub fn FileTextInput( input_mode: ReadSignal, @@ -20,132 +244,41 @@ pub fn FileTextInput( file_name: ReadSignal>, set_file_name: WriteSignal>, is_decrypt_mode: Memo, -) -> AnyView { - let handle_file_change = move |ev: Event| { - let target = ev.target().unwrap(); - let input: HtmlInputElement = target.unchecked_into(); - - if let Some(files) = input.files() { - let files: FileList = files; - if let Some(file) = files.get(0) { - let file: File = file; - let name = file.name(); - set_file_name(Some(name)); - - let reader = FileReader::new().unwrap(); - let reader_clone = reader.clone(); - - let onload = Closure::wrap(Box::new(move |_: web_sys::ProgressEvent| { - if let Ok(result) = reader_clone.result() - && let Some(array_buffer) = result.dyn_ref::() - { - let uint8_array = Uint8Array::new(array_buffer); - let data: Vec = uint8_array.to_vec(); - set_file_data(Some(data)); - } - }) as Box); - - reader.set_onload(Some(onload.as_ref().unchecked_ref())); - onload.forget(); - - let _ = reader.read_as_array_buffer(&file); - } - } - }; - - let handle_text_change = move |ev: Event| { - let target = ev.target().unwrap(); - let textarea: web_sys::HtmlTextAreaElement = target.unchecked_into(); - set_text_content(textarea.value()); - }; - +) -> impl IntoView { view! {
- -
- - -
+ +
- {move || { - match input_mode.get() { - InputMode::Text => { - view! { -
- -
- {move || { - let len = text_content.get().len(); - format!("{} characters", len) - }} -
-
- }.into_any() + {move || match input_mode.get() { + InputMode::Text => { + view! { + } - InputMode::File => { - view! { -
- - -
- }.into_any() + .into_any() + } + InputMode::File => { + view! { + } + .into_any() } }}
} - .into_any() } diff --git a/web/src/components/iv_input.rs b/web/src/components/iv_input.rs index 46017e8..f27f329 100644 --- a/web/src/components/iv_input.rs +++ b/web/src/components/iv_input.rs @@ -12,7 +12,9 @@ fn generate_random_bytes(len: usize) -> Option> { let window = web_sys::window()?; let crypto = window.crypto().ok()?; let array = Uint8Array::new_with_length(len as u32); - crypto.get_random_values_with_array_buffer_view(&array).ok()?; + crypto + .get_random_values_with_array_buffer_view(&array) + .ok()?; Some(array.to_vec()) } @@ -21,10 +23,7 @@ fn bytes_to_hex(bytes: &[u8]) -> String { } #[component] -pub fn IvInput( - iv_input: ReadSignal, - set_iv_input: WriteSignal, -) -> AnyView { +pub fn IvInput(iv_input: ReadSignal, set_iv_input: WriteSignal) -> AnyView { let handle_hex_input = move |ev| { let val = event_target_value(&ev); let cleaned = clean_hex_input(val); diff --git a/web/src/components/key_input.rs b/web/src/components/key_input.rs index 9707161..ccf53da 100644 --- a/web/src/components/key_input.rs +++ b/web/src/components/key_input.rs @@ -5,7 +5,9 @@ fn generate_random_bytes(len: usize) -> Option> { let window = web_sys::window()?; let crypto = window.crypto().ok()?; let array = Uint8Array::new_with_length(len as u32); - crypto.get_random_values_with_array_buffer_view(&array).ok()?; + crypto + .get_random_values_with_array_buffer_view(&array) + .ok()?; Some(array.to_vec()) } diff --git a/web/style/main.scss b/web/style/main.scss index bc67173..c9b17cf 100644 --- a/web/style/main.scss +++ b/web/style/main.scss @@ -775,6 +775,12 @@ main { border-color: var(--primary); background: var(--bg-highlight); } + + &.dragging { + border-color: var(--accent); + background: var(--bg-highlight); + box-shadow: 0 0 0 3px var(--focus-ring); + } } .file-placeholder { @@ -795,6 +801,10 @@ main { flex-direction: column; align-items: center; gap: 4px; + position: relative; + width: 100%; + padding: 0 40px; + box-sizing: border-box; .file-icon { font-size: 1.5rem; @@ -812,10 +822,33 @@ main { font-size: 0.85rem; color: var(--text-muted); } + + .btn-clear-file { + position: absolute; + right: 8px; + background: var(--bg-input); + border: 1px solid var(--border); + color: var(--error); + width: 24px; + height: 24px; + border-radius: 4px; + cursor: pointer; + font-size: 0.8rem; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + + &:hover { + background: var(--error); + color: var(--bg-body); + border-color: var(--error); + } + } } } -// Result actions .result-actions { display: flex; gap: 8px;