mirror of
https://github.com/kristoferssolo/cipher-workshop.git
synced 2025-12-31 13:52:29 +00:00
feat(web): add AES-CBC page with file encryption support
This commit is contained in:
parent
c208ce2e81
commit
651651780f
@ -26,7 +26,9 @@ web-sys = { version = "0.3", features = [
|
|||||||
"Blob",
|
"Blob",
|
||||||
"Clipboard",
|
"Clipboard",
|
||||||
"Crypto",
|
"Crypto",
|
||||||
|
"DataTransfer",
|
||||||
"Document",
|
"Document",
|
||||||
|
"DragEvent",
|
||||||
"Element",
|
"Element",
|
||||||
"Event",
|
"Event",
|
||||||
"EventTarget",
|
"EventTarget",
|
||||||
|
|||||||
@ -105,7 +105,8 @@ pub fn CipherFormCbc() -> impl IntoView {
|
|||||||
OutputFormat::Text => {
|
OutputFormat::Text => {
|
||||||
String::from_utf8(plaintext).unwrap_or_else(|_| {
|
String::from_utf8(plaintext).unwrap_or_else(|_| {
|
||||||
set_error_msg(
|
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()
|
String::new()
|
||||||
})
|
})
|
||||||
@ -242,8 +243,7 @@ fn parse_hex_string(s: &str) -> Result<Vec<u8>, String> {
|
|||||||
(0..s.len())
|
(0..s.len())
|
||||||
.step_by(2)
|
.step_by(2)
|
||||||
.map(|i| {
|
.map(|i| {
|
||||||
u8::from_str_radix(&s[i..i + 2], 16)
|
u8::from_str_radix(&s[i..i + 2], 16).map_err(|_| format!("Invalid hex at position {i}"))
|
||||||
.map_err(|_| format!("Invalid hex at position {i}"))
|
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@ -275,16 +275,28 @@ fn download_bytes(bytes: &[u8], filename: &str) {
|
|||||||
let array = Array::new();
|
let array = Array::new();
|
||||||
array.push(&uint8_array.buffer());
|
array.push(&uint8_array.buffer());
|
||||||
|
|
||||||
let blob = Blob::new_with_u8_array_sequence(&array).unwrap();
|
let Some(blob) = Blob::new_with_u8_array_sequence(&array).ok() else {
|
||||||
let url = Url::create_object_url_with_blob(&blob).unwrap();
|
return;
|
||||||
|
};
|
||||||
|
let Some(url) = Url::create_object_url_with_blob(&blob).ok() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
let document = web_sys::window().unwrap().document().unwrap();
|
let Some(window) = web_sys::window() else {
|
||||||
let a = document.create_element("a").unwrap();
|
return;
|
||||||
a.set_attribute("href", &url).unwrap();
|
};
|
||||||
a.set_attribute("download", filename).unwrap();
|
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();
|
let a: web_sys::HtmlElement = a.unchecked_into();
|
||||||
a.click();
|
a.click();
|
||||||
|
|
||||||
Url::revoke_object_url(&url).unwrap();
|
let _ = Url::revoke_object_url(&url);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
use js_sys::{ArrayBuffer, Uint8Array};
|
use js_sys::{ArrayBuffer, Uint8Array};
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use wasm_bindgen::{prelude::*, JsCast};
|
use wasm_bindgen::{JsCast, prelude::*};
|
||||||
use web_sys::{Event, File, FileList, FileReader, HtmlInputElement};
|
use web_sys::{DragEvent, Event, File, FileReader, HtmlInputElement};
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub enum InputMode {
|
pub enum InputMode {
|
||||||
@ -9,30 +9,17 @@ pub enum InputMode {
|
|||||||
File,
|
File,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
fn read_file(
|
||||||
pub fn FileTextInput(
|
file: File,
|
||||||
input_mode: ReadSignal<InputMode>,
|
|
||||||
set_input_mode: WriteSignal<InputMode>,
|
|
||||||
text_content: ReadSignal<String>,
|
|
||||||
set_text_content: WriteSignal<String>,
|
|
||||||
file_data: ReadSignal<Option<Vec<u8>>>,
|
|
||||||
set_file_data: WriteSignal<Option<Vec<u8>>>,
|
|
||||||
file_name: ReadSignal<Option<String>>,
|
|
||||||
set_file_name: WriteSignal<Option<String>>,
|
set_file_name: WriteSignal<Option<String>>,
|
||||||
is_decrypt_mode: Memo<bool>,
|
set_file_data: WriteSignal<Option<Vec<u8>>>,
|
||||||
) -> 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();
|
let name = file.name();
|
||||||
set_file_name(Some(name));
|
set_file_name(Some(name));
|
||||||
|
|
||||||
let reader = FileReader::new().unwrap();
|
let Some(reader) = FileReader::new().ok() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let reader_clone = reader.clone();
|
let reader_clone = reader.clone();
|
||||||
|
|
||||||
let onload = Closure::wrap(Box::new(move |_: web_sys::ProgressEvent| {
|
let onload = Closure::wrap(Box::new(move |_: web_sys::ProgressEvent| {
|
||||||
@ -40,8 +27,7 @@ pub fn FileTextInput(
|
|||||||
&& let Some(array_buffer) = result.dyn_ref::<ArrayBuffer>()
|
&& let Some(array_buffer) = result.dyn_ref::<ArrayBuffer>()
|
||||||
{
|
{
|
||||||
let uint8_array = Uint8Array::new(array_buffer);
|
let uint8_array = Uint8Array::new(array_buffer);
|
||||||
let data: Vec<u8> = uint8_array.to_vec();
|
set_file_data(Some(uint8_array.to_vec()));
|
||||||
set_file_data(Some(data));
|
|
||||||
}
|
}
|
||||||
}) as Box<dyn FnMut(_)>);
|
}) as Box<dyn FnMut(_)>);
|
||||||
|
|
||||||
@ -50,40 +36,49 @@ pub fn FileTextInput(
|
|||||||
|
|
||||||
let _ = reader.read_as_array_buffer(&file);
|
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());
|
|
||||||
};
|
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn InputModeToggle(
|
||||||
|
input_mode: ReadSignal<InputMode>,
|
||||||
|
set_input_mode: WriteSignal<InputMode>,
|
||||||
|
) -> impl IntoView {
|
||||||
view! {
|
view! {
|
||||||
<div class="form-group">
|
|
||||||
<div class="label-header">
|
|
||||||
<label>{move || if is_decrypt_mode.get() { "Ciphertext Input" } else { "Plaintext Input" }}</label>
|
|
||||||
<div class="input-mode-toggle">
|
<div class="input-mode-toggle">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class=move || if input_mode.get() == InputMode::Text { "mode-btn active" } else { "mode-btn" }
|
class=move || {
|
||||||
|
if input_mode.get() == InputMode::Text { "mode-btn active" } else { "mode-btn" }
|
||||||
|
}
|
||||||
on:click=move |_| set_input_mode(InputMode::Text)
|
on:click=move |_| set_input_mode(InputMode::Text)
|
||||||
>
|
>
|
||||||
"Text"
|
"Text"
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class=move || if input_mode.get() == InputMode::File { "mode-btn active" } else { "mode-btn" }
|
class=move || {
|
||||||
|
if input_mode.get() == InputMode::File { "mode-btn active" } else { "mode-btn" }
|
||||||
|
}
|
||||||
on:click=move |_| set_input_mode(InputMode::File)
|
on:click=move |_| set_input_mode(InputMode::File)
|
||||||
>
|
>
|
||||||
"File"
|
"File"
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn TextAreaInput(
|
||||||
|
text_content: ReadSignal<String>,
|
||||||
|
set_text_content: WriteSignal<String>,
|
||||||
|
is_decrypt_mode: Memo<bool>,
|
||||||
|
) -> 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());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
{move || {
|
|
||||||
match input_mode.get() {
|
|
||||||
InputMode::Text => {
|
|
||||||
view! {
|
view! {
|
||||||
<div class="textarea-wrapper">
|
<div class="textarea-wrapper">
|
||||||
<textarea
|
<textarea
|
||||||
@ -100,52 +95,190 @@ pub fn FileTextInput(
|
|||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="char-count">
|
<div class="char-count">
|
||||||
{move || {
|
{move || format!("{} characters", text_content.get().len())}
|
||||||
let len = text_content.get().len();
|
|
||||||
format!("{} characters", len)
|
|
||||||
}}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}.into_any()
|
|
||||||
}
|
}
|
||||||
InputMode::File => {
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn FileDropZone(
|
||||||
|
file_data: ReadSignal<Option<Vec<u8>>>,
|
||||||
|
set_file_data: WriteSignal<Option<Vec<u8>>>,
|
||||||
|
file_name: ReadSignal<Option<String>>,
|
||||||
|
set_file_name: WriteSignal<Option<String>>,
|
||||||
|
) -> 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! {
|
view! {
|
||||||
<div class="file-upload-area">
|
<div
|
||||||
|
class="file-upload-area"
|
||||||
|
on:dragover=handle_drag_over
|
||||||
|
on:dragenter=handle_drag_enter
|
||||||
|
on:dragleave=handle_drag_leave
|
||||||
|
on:drop=handle_drop
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
id="file-input"
|
id="file-input"
|
||||||
on:change=handle_file_change
|
on:change=handle_file_change
|
||||||
class="file-input-hidden"
|
class="file-input-hidden"
|
||||||
/>
|
/>
|
||||||
<label for="file-input" class="file-upload-label">
|
<label
|
||||||
|
for="file-input"
|
||||||
|
class=move || {
|
||||||
|
if is_dragging.get() {
|
||||||
|
"file-upload-label dragging"
|
||||||
|
} else {
|
||||||
|
"file-upload-label"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
{move || {
|
{move || {
|
||||||
match file_name.get() {
|
file_name
|
||||||
Some(name) => view! {
|
.get()
|
||||||
|
.map_or_else(
|
||||||
|
|| view! { <FilePlaceholder /> }.into_any(),
|
||||||
|
|name| {
|
||||||
|
view! {
|
||||||
|
<FileSelected
|
||||||
|
name=name
|
||||||
|
file_data=file_data
|
||||||
|
set_file_name=set_file_name
|
||||||
|
set_file_data=set_file_data
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn FileSelected(
|
||||||
|
name: String,
|
||||||
|
file_data: ReadSignal<Option<Vec<u8>>>,
|
||||||
|
set_file_name: WriteSignal<Option<String>>,
|
||||||
|
set_file_data: WriteSignal<Option<Vec<u8>>>,
|
||||||
|
) -> 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! {
|
||||||
<div class="file-selected">
|
<div class="file-selected">
|
||||||
<span class="file-icon">"[FILE]"</span>
|
<span class="file-icon">"[FILE]"</span>
|
||||||
<span class="file-name">{name}</span>
|
<span class="file-name">{name}</span>
|
||||||
<span class="file-size">
|
<span class="file-size">
|
||||||
{move || {
|
{move || file_data.get().map_or(String::new(), |d| format!("({} bytes)", d.len()))}
|
||||||
file_data.get().map(|d| format!("({} bytes)", d.len())).unwrap_or_default()
|
|
||||||
}}
|
|
||||||
</span>
|
</span>
|
||||||
|
<button type="button" class="btn-clear-file" on:click=clear_file title="Remove file">
|
||||||
|
"X"
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}.into_any(),
|
}
|
||||||
None => view! {
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn FilePlaceholder() -> impl IntoView {
|
||||||
|
view! {
|
||||||
<div class="file-placeholder">
|
<div class="file-placeholder">
|
||||||
<span class="upload-icon">"[+]"</span>
|
<span class="upload-icon">"[+]"</span>
|
||||||
<span>"Click to select a file or drag and drop"</span>
|
<span>"Click to select a file or drag and drop"</span>
|
||||||
</div>
|
</div>
|
||||||
}.into_any(),
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn FileTextInput(
|
||||||
|
input_mode: ReadSignal<InputMode>,
|
||||||
|
set_input_mode: WriteSignal<InputMode>,
|
||||||
|
text_content: ReadSignal<String>,
|
||||||
|
set_text_content: WriteSignal<String>,
|
||||||
|
file_data: ReadSignal<Option<Vec<u8>>>,
|
||||||
|
set_file_data: WriteSignal<Option<Vec<u8>>>,
|
||||||
|
file_name: ReadSignal<Option<String>>,
|
||||||
|
set_file_name: WriteSignal<Option<String>>,
|
||||||
|
is_decrypt_mode: Memo<bool>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label-header">
|
||||||
|
<label>
|
||||||
|
{move || {
|
||||||
|
if is_decrypt_mode.get() { "Ciphertext Input" } else { "Plaintext Input" }
|
||||||
}}
|
}}
|
||||||
</label>
|
</label>
|
||||||
|
<InputModeToggle input_mode=input_mode set_input_mode=set_input_mode />
|
||||||
</div>
|
</div>
|
||||||
}.into_any()
|
|
||||||
}
|
{move || match input_mode.get() {
|
||||||
}
|
InputMode::Text => {
|
||||||
}}
|
view! {
|
||||||
</div>
|
<TextAreaInput
|
||||||
|
text_content=text_content
|
||||||
|
set_text_content=set_text_content
|
||||||
|
is_decrypt_mode=is_decrypt_mode
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
.into_any()
|
.into_any()
|
||||||
}
|
}
|
||||||
|
InputMode::File => {
|
||||||
|
view! {
|
||||||
|
<FileDropZone
|
||||||
|
file_data=file_data
|
||||||
|
set_file_data=set_file_data
|
||||||
|
file_name=file_name
|
||||||
|
set_file_name=set_file_name
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -12,7 +12,9 @@ fn generate_random_bytes(len: usize) -> Option<Vec<u8>> {
|
|||||||
let window = web_sys::window()?;
|
let window = web_sys::window()?;
|
||||||
let crypto = window.crypto().ok()?;
|
let crypto = window.crypto().ok()?;
|
||||||
let array = Uint8Array::new_with_length(len as u32);
|
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())
|
Some(array.to_vec())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -21,10 +23,7 @@ fn bytes_to_hex(bytes: &[u8]) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn IvInput(
|
pub fn IvInput(iv_input: ReadSignal<String>, set_iv_input: WriteSignal<String>) -> AnyView {
|
||||||
iv_input: ReadSignal<String>,
|
|
||||||
set_iv_input: WriteSignal<String>,
|
|
||||||
) -> AnyView {
|
|
||||||
let handle_hex_input = move |ev| {
|
let handle_hex_input = move |ev| {
|
||||||
let val = event_target_value(&ev);
|
let val = event_target_value(&ev);
|
||||||
let cleaned = clean_hex_input(val);
|
let cleaned = clean_hex_input(val);
|
||||||
|
|||||||
@ -5,7 +5,9 @@ fn generate_random_bytes(len: usize) -> Option<Vec<u8>> {
|
|||||||
let window = web_sys::window()?;
|
let window = web_sys::window()?;
|
||||||
let crypto = window.crypto().ok()?;
|
let crypto = window.crypto().ok()?;
|
||||||
let array = Uint8Array::new_with_length(len as u32);
|
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())
|
Some(array.to_vec())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -775,6 +775,12 @@ main {
|
|||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
background: var(--bg-highlight);
|
background: var(--bg-highlight);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.dragging {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--bg-highlight);
|
||||||
|
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-placeholder {
|
.file-placeholder {
|
||||||
@ -795,6 +801,10 @@ main {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 40px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
.file-icon {
|
.file-icon {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
@ -812,10 +822,33 @@ main {
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--text-muted);
|
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 {
|
.result-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user