diff --git a/frontend/src/api.rs b/frontend/src/api.rs new file mode 100644 index 0000000..a337b6b --- /dev/null +++ b/frontend/src/api.rs @@ -0,0 +1,37 @@ +use lazy_static::lazy_static; + +lazy_static! { + static ref API_BASE_URL: String = option_env!("API_BASE_URL") + .unwrap_or( + (option_env!("TRUNK_PUBLIC_URL") + .unwrap_or("http://127.0.0.1:8000/") + .to_string() + + "api/v1") + .as_str(), + ) + .to_owned(); +} + +pub struct APIEndpoints {} + +impl APIEndpoints { + pub fn get_base() -> &'static str { + &API_BASE_URL + } + + pub fn get_dirs() -> String { + format!("{}/get", Self::get_base()) + } + + pub fn get_configs(dir_name: &str) -> String { + format!("{}/get/{}", Self::get_base(), dir_name) + } + + pub fn generate() -> String { + format!("{}/generate", Self::get_base()) + } + + pub fn get_contents(dir_name: &str, file_name: &str) -> String { + format!("{}/get/{}/{}", Self::get_base(), dir_name, file_name) + } +} diff --git a/frontend/src/components/mod.rs b/frontend/src/components/mod.rs index afea519..7742ea3 100644 --- a/frontend/src/components/mod.rs +++ b/frontend/src/components/mod.rs @@ -1,2 +1,4 @@ pub mod alerts; +pub mod navbar; +pub mod pages; pub mod refresh_button; diff --git a/frontend/src/components/navbar.rs b/frontend/src/components/navbar.rs new file mode 100644 index 0000000..af1e883 --- /dev/null +++ b/frontend/src/components/navbar.rs @@ -0,0 +1,51 @@ +use yew::prelude::*; +use yew_router::prelude::Link; + +use crate::routes::Route; + +#[derive(Properties, PartialEq)] +pub struct NavProps { + #[prop_or("".into())] + pub currrent_dir: AttrValue, + #[prop_or(Route::Home)] + pub currrent_route: Route, +} + +#[function_component(Navbar)] +pub fn navbar(props: &NavProps) -> Html { + let classes_active = "block py-2 px-3 text-white bg-blue-700 rounded-sm md:bg-transparent md:text-blue-700 md:p-0 dark:text-white md:dark:text-blue-500"; + let classes_inactive = "block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent"; + let has_dir = props.currrent_dir.len() > 0; + let is_current_home = props.currrent_route == Route::Home; + let is_current_dir = props.currrent_route == Route::Directories; + + html! { + <nav class="bg-white border-gray-200 dark:bg-gray-900"> + <div class="max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4"> + <a href="/" class="flex items-center space-x-3 rtl:space-x-reverse"> + <span class="self-center text-2xl font-semibold whitespace-nowrap dark:text-white">{"PeazyWeb"}</span> + </a> + <button data-collapse-toggle="navbar-default" type="button" class="inline-flex items-center p-2 w-10 h-10 justify-center text-sm text-gray-500 rounded-lg md:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600" aria-controls="navbar-default" aria-expanded="false"> + <span class="sr-only">{"Меню"}</span> + <svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 17 14"> + <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 1h15M1 7h15M1 13h15"/> + </svg> + </button> + <div class="hidden w-full md:block md:w-auto" id="navbar-default"> + <ul class="font-medium flex flex-col p-4 md:p-0 mt-4 border border-gray-100 rounded-lg bg-gray-50 md:flex-row md:space-x-8 rtl:space-x-reverse md:mt-0 md:border-0 md:bg-white dark:bg-gray-800 md:dark:bg-gray-900 dark:border-gray-700"> + <li class={if is_current_home {classes_active} else {classes_inactive}}><Link<Route> to={Route::Home}>{ "Главная" }</Link<Route>></li> + if has_dir { + <li class={ + match props.currrent_route.clone() { + Route::Configs{dir_name} if dir_name == props.currrent_dir => classes_active, + _ => classes_inactive, + } + }><Link<Route> to={Route::Configs{dir_name: props.currrent_dir.to_string()}}>{ props.currrent_dir.clone() }</Link<Route>></li> + } + <li class={if is_current_dir {classes_active} else {classes_inactive}}><Link<Route> to={Route::Directories}>{ "Каталоги" }</Link<Route>></li> + </ul> + </div> + </div> + </nav> + } +} diff --git a/frontend/src/components/pages/config.rs b/frontend/src/components/pages/config.rs new file mode 100644 index 0000000..b9c2f87 --- /dev/null +++ b/frontend/src/components/pages/config.rs @@ -0,0 +1,81 @@ +use gloo::net::http::Request; +use wasm_bindgen_futures::spawn_local; +use yew::prelude::*; + +use crate::{api::APIEndpoints, components::refresh_button::RefreshButton}; + +use base64::{Engine as _, engine::general_purpose}; + +fn string_to_base64_data_url(data: &str, content_type: &str) -> String { + let mut buf = String::new(); + general_purpose::STANDARD.encode_string(data, &mut buf); + format!("data:{};base64,{}", content_type, buf) +} + +#[derive(Properties, PartialEq)] +pub struct ConfigProps { + pub dir: AttrValue, + pub file_name: AttrValue, + #[prop_or("".into())] + pub contents: AttrValue, +} + +#[function_component(Config)] +pub fn config(props: &ConfigProps) -> Html { + let dir_name = props.dir.clone(); + let file_name = props.file_name.clone(); + let contents = use_state(|| props.contents.to_string()); + + let get_contents = { + let contents = contents.clone(); + Callback::from(move |_| { + let url = + APIEndpoints::get_contents(dir_name.clone().as_str(), file_name.clone().as_str()); + let contents = contents.clone(); + spawn_local(async move { + match Request::get(&url).send().await { + Ok(response) if response.ok() => { + if let Ok(text) = response.text().await { + contents.set(text); + } + } + _ => contents.set("...Ошибка загрузки содержимого...".into()), + } + }) + }) + }; + + { + let get_contents_clone = get_contents.clone(); + let is_contents_empty = props.contents.is_empty(); + use_effect_with((), move |_| { + if is_contents_empty { + get_contents_clone.emit(()); + } + }); + } + + html! { + <div class="w-full"> + <RefreshButton onclick={get_contents.reform(|_| ())} /> + <div class="mb-2 flex justify-between items-center"> + <p class="text-sm font-medium text-gray-900">{"Конфигурация "}<b>{&props.file_name}</b>{" для "}<b>{&props.dir}</b>{":"}</p> + </div> + <div class="relative bg-gray-50 rounded-lg dark:bg-gray-700 p-4"> + <div class="overflow-scroll max-h-full"> + <pre><code id="code-block" class="text-sm text-gray-500 dark:text-gray-400 whitespace-pre">{&*contents}</code></pre> + </div> + <div class="absolute top-2 end-2 bg-gray-50 dark:bg-gray-700"> + <a href={string_to_base64_data_url(&contents, "application/octet-stream")} download={&props.file_name}> + <button type="button" class="text-white bg-gradient-to-br from-purple-600 to-blue-500 hover:bg-gradient-to-bl focus:ring-4 focus:outline-none focus:ring-blue-300 dark:focus:ring-blue-800 font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 mb-2"> + <svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24"> + <path fill-rule="evenodd" d="M13 11.15V4a1 1 0 1 0-2 0v7.15L8.78 8.374a1 1 0 1 0-1.56 1.25l4 5a1 1 0 0 0 1.56 0l4-5a1 1 0 1 0-1.56-1.25L13 11.15Z" clip-rule="evenodd"/> + <path fill-rule="evenodd" d="M9.657 15.874 7.358 13H5a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2h-2.358l-2.3 2.874a3 3 0 0 1-4.685 0ZM17 16a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H17Z" clip-rule="evenodd"/> + </svg>{"Скачать"} + </button> + </a> + </div> + </div> + </div> + } +} diff --git a/frontend/src/components/pages/configs.rs b/frontend/src/components/pages/configs.rs new file mode 100644 index 0000000..1de9fc0 --- /dev/null +++ b/frontend/src/components/pages/configs.rs @@ -0,0 +1,82 @@ +use gloo::net::http::Request; +use wasm_bindgen_futures::spawn_local; +use yew::prelude::*; +use yew_router::prelude::Link; + +use crate::{ + api::APIEndpoints, + components::{alerts::ErrorAlert, refresh_button::RefreshButton}, + routes::Route, +}; + +#[derive(Properties, PartialEq)] +pub struct ConfigsProps { + pub dir: AttrValue, +} + +#[function_component(Configs)] +pub fn configs(props: &ConfigsProps) -> Html { + let configs = use_state(Vec::new); + let error_message = use_state(|| "".to_string()); + let dir_name = props.dir.clone(); + + let get_configs = { + let configs = configs.clone(); + let error_message = error_message.clone(); + Callback::from(move |_| { + let url = APIEndpoints::get_configs(dir_name.clone().as_str()); + let configs = configs.clone(); + let error_message = error_message.clone(); + spawn_local(async move { + match Request::get(&url).send().await { + Ok(response) if response.ok() => match response.json::<Vec<String>>().await { + Ok(mut json) => { + json.sort(); + configs.set(json); + } + _ => error_message.set("Ошибка чтения конфигураций".into()), + }, + _ => error_message.set("Ошибка получения конфигураций".into()), + } + }) + }) + }; + + { + let get_configs_clone = get_configs.clone(); + use_effect_with((), move |_| { + get_configs_clone.emit(()); + }); + } + + html! { + <div> + <p><RefreshButton onclick={get_configs.reform(|_| ())} /> + <Link<Route> to={Route::CreateConfig { dir_name: props.dir.clone().to_string() }}> <button class="text-white bg-gradient-to-br from-pink-500 to-orange-400 hover:bg-gradient-to-bl focus:ring-4 focus:outline-none focus:ring-pink-200 dark:focus:ring-pink-800 font-bold rounded-lg text-sm px-5 py-2.5 text-center me-2 mb-2"> + { "Создать" } </button> </Link<Route>> </p> + <h2 class="text-2xl font-bold text-gray-700 mb-2">{format!("Конфигурации для {}:", props.dir.clone())}</h2> + <table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400"> + <thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400"> + <tr> + <th scope="col" class="px-6 py-3"> + {"Имя"} + </th> + </tr> + </thead> + <tbody> + { for (*configs).iter().map(|file_name| { + html! { + <tr class="odd:bg-white odd:dark:bg-gray-900 even:bg-gray-50 even:dark:bg-gray-800 border-b dark:border-gray-700 border-gray-200"> + <th scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white"> + <Link<Route> to={Route::Config { dir_name: props.dir.clone().to_string(), file_name: file_name.clone() }}>{file_name}</Link<Route>> + </th> + </tr> + } + }) } + </tbody> + </table> + + <ErrorAlert error={error_message.as_str().to_owned()} /> + </div> + } +} diff --git a/frontend/src/components/pages/create_config.rs b/frontend/src/components/pages/create_config.rs new file mode 100644 index 0000000..042a22c --- /dev/null +++ b/frontend/src/components/pages/create_config.rs @@ -0,0 +1,103 @@ +use gloo::net::http::Request; +use serde::{Deserialize, Serialize}; +use wasm_bindgen_futures::spawn_local; +use yew::prelude::*; + +use crate::{ + api::APIEndpoints, + components::{alerts::ErrorAlert, pages::config::Config}, +}; + +#[derive(Serialize, Deserialize, Clone, Debug)] +struct GenerationRequest { + directory: String, + common_name: String, +} + +#[derive(Properties, PartialEq)] +pub struct GenerateProps { + pub dir: AttrValue, +} + +#[function_component(CreateConfig)] +pub fn create_config(props: &GenerateProps) -> Html { + let dir_name = props.dir.clone(); + let config_name = use_state(|| "".to_string()); + let result = use_state(|| "".to_string()); + let error = use_state(|| "".to_string()); + let done = use_state(|| false); + + let generate_config_request = { + let dir_name = dir_name.clone(); + let config_name = config_name.clone(); + let done = done.clone(); + let result = result.clone(); + let error = error.clone(); + Callback::from(move |_| { + let request_body = GenerationRequest { + directory: dir_name.clone().to_string(), + common_name: config_name.clone().to_string(), + }; + let json_payload = serde_json::to_string(&request_body).unwrap(); + let result = result.clone(); + let done = done.clone(); + let error = error.clone(); + spawn_local(async move { + match Request::post(APIEndpoints::generate().as_str()) + .header("Content-Type", "application/json") + .body(json_payload) + .unwrap() + .send() + .await + { + Ok(response) if response.ok() => { + if let Ok(text) = response.text().await { + result.set(text); + done.set(true); + error.set("".into()); + } + } + _ => { + error.set("Ошибка создания конфигурации".into()); + } + } + }) + }) + }; + + html! { + <> + if !*done.clone() { + <div class="grid place-items-center"> + <br/> + <div class="w-full max-w-sm p-4 bg-white border border-gray-200 rounded-lg shadow-sm sm:p-6 md:p-8 dark:bg-gray-800 dark:border-gray-700"> + <form class="space-y-6" action="#"> + <h5 class="text-xl font-medium text-gray-900 dark:text-white">{format!("Создание конфигурации для {}:", props.dir.clone())}</h5> + <div> + <label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white" for="common-name">{"Имя:"} + </label> + <input type="text" id="common-name" placeholder="введите название" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white" required=true oninput={Callback::from({ + let config_name = config_name.clone(); + move |e: InputEvent| { + let input = e.target_dyn_into::<web_sys::HtmlInputElement>().unwrap(); + config_name.set(input.value()); + } + })} /> + </div> + <button type="button" onclick={generate_config_request.reform(|_| ())} + class="text-white bg-gradient-to-r from-red-400 via-red-500 to-red-600 hover:bg-gradient-to-br focus:ring-4 focus:outline-none focus:ring-red-300 dark:focus:ring-red-800 shadow-lg shadow-red-500/50 dark:shadow-lg dark:shadow-red-800/80 font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 mb-2"> + { "Создать" } + </button> + </form> + </div> + </div> + <ErrorAlert error={error.as_str().to_owned()} /> + } else { + <div class="p-4 mb-4 text-sm text-green-800 rounded-lg bg-green-50 dark:bg-gray-800 dark:text-green-400" role="alert"> + <span class="font-medium">{"Успешно!"}</span>{" Конфигурация создана!"} + </div> + <Config dir={dir_name} file_name={format!("{}.ovpn", config_name.as_str())} contents={result.as_str().to_owned()} /> + } + </> + } +} diff --git a/frontend/src/components/pages/directories.rs b/frontend/src/components/pages/directories.rs new file mode 100644 index 0000000..8c1cdba --- /dev/null +++ b/frontend/src/components/pages/directories.rs @@ -0,0 +1,72 @@ +use gloo::net::http::Request; +use wasm_bindgen_futures::spawn_local; +use yew::prelude::*; +use yew_router::prelude::Link; + +use crate::api::APIEndpoints; +use crate::{ + components::{alerts::ErrorAlert, refresh_button::RefreshButton}, + routes::Route, +}; + +#[function_component(Directories)] +pub fn directories() -> Html { + let dirs = use_state(Vec::new); + let error_message = use_state(|| "".to_string()); + + let get_dirs = { + let dirs = dirs.clone(); + let error_message = error_message.clone(); + Callback::from(move |_| { + let dirs = dirs.clone(); + let error_message = error_message.clone(); + spawn_local(async move { + match Request::get(APIEndpoints::get_dirs().as_ref()).send().await { + Ok(response) if response.ok() => match response.json::<Vec<String>>().await { + Ok(mut json) => { + json.sort(); + dirs.set(json); + } + _ => error_message.set("Ошибка чтения катологов".into()), + }, + _ => error_message.set("Ошибка получения каталогов".into()), + } + }) + }) + }; + + { + let get_dirs = get_dirs.clone(); + use_effect_with((), move |_| { + get_dirs.emit(()); + }); + } + + html! { + <div> + <RefreshButton onclick={get_dirs.reform(|_| ())} /> + <h2 class="text-2xl font-bold text-gray-700 mb-2">{"Каталоги конфигураций"}</h2> + <table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400"> + <thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400"> + <tr> + <th scope="col" class="px-6 py-3"> + {"Каталог"} + </th> + </tr> + </thead> + <tbody> + { for (*dirs).iter().map(|dir| { + html! { + <tr class="odd:bg-white odd:dark:bg-gray-900 even:bg-gray-50 even:dark:bg-gray-800 border-b dark:border-gray-700 border-gray-200"> + <th scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white"> + <Link<Route> to={Route::Configs { dir_name: dir.to_string() }}>{dir}</Link<Route>> + </th> + </tr> + } + }) } + </tbody> + </table> + <ErrorAlert error={error_message.as_str().to_owned()} /> + </div> + } +} diff --git a/frontend/src/components/pages/homepage.rs b/frontend/src/components/pages/homepage.rs new file mode 100644 index 0000000..fcc9b52 --- /dev/null +++ b/frontend/src/components/pages/homepage.rs @@ -0,0 +1,29 @@ +use yew::prelude::*; +use yew_router::prelude::Link; + +use crate::routes::Route; + +#[function_component(Homepage)] +pub fn homepage() -> Html { + html! { + <> + <section class="bg-white dark:bg-gray-900"> + <div class="py-8 px-4 mx-auto max-w-screen-xl text-center lg:py-16"> + <h1 class="mb-4 text-4xl font-extrabold tracking-tight leading-none text-gray-900 md:text-5xl lg:text-6xl dark:text-white">{"PeazyWeb"}</h1> + <p class="mb-8 text-lg font-normal text-gray-500 lg:text-xl sm:px-16 lg:px-48 dark:text-gray-400">{"Ваши файлы конфигурации OpenVPN у вас под рукой!"}</p> + </div> + </section> + + <div class="mt-2 pt-1 ml-2 pl-2 mb-2 pb-2 grid place-items-center text-2xl inline-flex items-center font-medium text-blue-700 hover:underline"> + <Link<Route> to={Route::Directories}>{ "Начать" }</Link<Route>> + </div> + + <div class="mt-4 pt-4 ml-2 pl-2"><h2 class="mb-2 text-lg font-semibold">{"Исходники"}</h2> + <ul class="max-w-md space-y-1 list-disc list-inside"> + <li><a class="inline-flex items-center font-medium text-blue-700 hover:underline" target="_blank" href="https://gitea.b4tman.ru/b4tman/peazyweb">{"Интерфейс"}</a></li> + <li><a class="inline-flex items-center font-medium text-blue-700 hover:underline" target="_blank" href="https://gitea.b4tman.ru/b4tman/peazyrsa">{"Генератор конфигов"}</a></li> + </ul> + </div> + </> + } +} diff --git a/frontend/src/components/pages/mod.rs b/frontend/src/components/pages/mod.rs new file mode 100644 index 0000000..a6f34b2 --- /dev/null +++ b/frontend/src/components/pages/mod.rs @@ -0,0 +1,6 @@ +pub mod config; +pub mod configs; +pub mod create_config; +pub mod directories; +pub mod homepage; +pub mod not_found; diff --git a/frontend/src/components/pages/not_found.rs b/frontend/src/components/pages/not_found.rs new file mode 100644 index 0000000..8c0fe04 --- /dev/null +++ b/frontend/src/components/pages/not_found.rs @@ -0,0 +1,12 @@ +use yew::prelude::*; + +#[function_component(NotFound)] +pub fn not_found() -> Html { + html!( + <div class="grid-cols-3 place-items-center"> + <br/> + <h1 class="font-extrabold text-9xl">{"404"}</h1> + <span class="text-2xl font-normal">{"Не найдено"}</span> + </div> + ) +} diff --git a/frontend/src/main.rs b/frontend/src/main.rs index 43c15e1..4f2586f 100644 --- a/frontend/src/main.rs +++ b/frontend/src/main.rs @@ -1,476 +1,58 @@ -use base64::{Engine as _, engine::general_purpose}; -use gloo::net::http::Request; -use lazy_static::lazy_static; -use serde::{Deserialize, Serialize}; -use wasm_bindgen_futures::spawn_local; -use yew::Properties; use yew::prelude::*; -#[derive(Serialize, Deserialize, Clone, Debug)] -struct GenerationRequest { - directory: String, - common_name: String, -} - use yew_router::prelude::*; +mod api; mod components; +mod routes; -use components::alerts::ErrorAlert; -use components::refresh_button::RefreshButton; - -#[derive(Clone, Routable, PartialEq)] -pub enum Route { - #[at("/")] - Home, - #[at("/dir")] - Directories, - #[at("/dir/:dir_name")] - Configs { dir_name: String }, - #[at("/dir/:dir_name/:file_name")] - Config { dir_name: String, file_name: String }, - #[at("/gen/:dir_name")] - CreateConfig { dir_name: String }, - #[not_found] - #[at("/404")] - NotFound, -} - -lazy_static! { - static ref API_BASE_URL: String = option_env!("API_BASE_URL") - .unwrap_or( - (option_env!("TRUNK_PUBLIC_URL") - .unwrap_or("http://127.0.0.1:8000/") - .to_string() - + "api/v1") - .as_str(), - ) - .to_owned(); -} - -pub struct APIEndpoints {} - -impl APIEndpoints { - pub fn get_base() -> &'static str { - &API_BASE_URL - } - - pub fn get_dirs() -> String { - format!("{}/get", Self::get_base()) - } - - pub fn get_configs(dir_name: &str) -> String { - format!("{}/get/{}", Self::get_base(), dir_name) - } - - pub fn generate() -> String { - format!("{}/generate", Self::get_base()) - } - - pub fn get_contents(dir_name: &str, file_name: &str) -> String { - format!("{}/get/{}/{}", Self::get_base(), dir_name, file_name) - } -} - -#[derive(Properties, PartialEq)] -pub struct NavProps { - #[prop_or("".into())] - pub currrent_dir: AttrValue, - #[prop_or(Route::Home)] - pub currrent_route: Route, -} - -// component to show the navbar -#[function_component(Navbar)] -fn navbar(props: &NavProps) -> Html { - let classes_active = "block py-2 px-3 text-white bg-blue-700 rounded-sm md:bg-transparent md:text-blue-700 md:p-0 dark:text-white md:dark:text-blue-500"; - let classes_inactive = "block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent"; - let has_dir = props.currrent_dir.len() > 0; - let is_current_home = props.currrent_route == Route::Home; - let is_current_dir = props.currrent_route == Route::Directories; - - html! { - <nav class="bg-white border-gray-200 dark:bg-gray-900"> - <div class="max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4"> - <a href="/" class="flex items-center space-x-3 rtl:space-x-reverse"> - <span class="self-center text-2xl font-semibold whitespace-nowrap dark:text-white">{"PeazyWeb"}</span> - </a> - <button data-collapse-toggle="navbar-default" type="button" class="inline-flex items-center p-2 w-10 h-10 justify-center text-sm text-gray-500 rounded-lg md:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600" aria-controls="navbar-default" aria-expanded="false"> - <span class="sr-only">{"Меню"}</span> - <svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 17 14"> - <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 1h15M1 7h15M1 13h15"/> - </svg> - </button> - <div class="hidden w-full md:block md:w-auto" id="navbar-default"> - <ul class="font-medium flex flex-col p-4 md:p-0 mt-4 border border-gray-100 rounded-lg bg-gray-50 md:flex-row md:space-x-8 rtl:space-x-reverse md:mt-0 md:border-0 md:bg-white dark:bg-gray-800 md:dark:bg-gray-900 dark:border-gray-700"> - <li class={if is_current_home {classes_active} else {classes_inactive}}><Link<Route> to={Route::Home}>{ "Главная" }</Link<Route>></li> - if has_dir { - <li class={ - match props.currrent_route.clone() { - Route::Configs{dir_name} if dir_name == props.currrent_dir => classes_active, - _ => classes_inactive, - } - }><Link<Route> to={Route::Configs{dir_name: props.currrent_dir.to_string()}}>{ props.currrent_dir.clone() }</Link<Route>></li> - } - <li class={if is_current_dir {classes_active} else {classes_inactive}}><Link<Route> to={Route::Directories}>{ "Каталоги" }</Link<Route>></li> - </ul> - </div> - </div> - </nav> - } -} - -// component to show the directories -#[function_component(Directories)] -fn directories() -> Html { - let dirs = use_state(Vec::new); - let error_message = use_state(|| "".to_string()); - - let get_dirs = { - let dirs = dirs.clone(); - let error_message = error_message.clone(); - Callback::from(move |_| { - let dirs = dirs.clone(); - let error_message = error_message.clone(); - spawn_local(async move { - match Request::get(APIEndpoints::get_dirs().as_ref()).send().await { - Ok(response) if response.ok() => match response.json::<Vec<String>>().await { - Ok(mut json) => { - json.sort(); - dirs.set(json); - } - _ => error_message.set("Ошибка чтения катологов".into()), - }, - _ => error_message.set("Ошибка получения каталогов".into()), - } - }) - }) - }; - - // Добавляем use_effect с пустым вектором зависимостей - { - let get_dirs = get_dirs.clone(); - use_effect_with((), move |_| { - get_dirs.emit(()); - }); - } - - html! { - <div> - <RefreshButton onclick={get_dirs.reform(|_| ())} /> - <h2 class="text-2xl font-bold text-gray-700 mb-2">{"Каталоги конфигураций"}</h2> - <table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400"> - <thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400"> - <tr> - <th scope="col" class="px-6 py-3"> - {"Каталог"} - </th> - </tr> - </thead> - <tbody> - { for (*dirs).iter().map(|dir| { - html! { - <tr class="odd:bg-white odd:dark:bg-gray-900 even:bg-gray-50 even:dark:bg-gray-800 border-b dark:border-gray-700 border-gray-200"> - <th scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white"> - <Link<Route> to={Route::Configs { dir_name: dir.to_string() }}>{dir}</Link<Route>> - </th> - </tr> - } - }) } - </tbody> - </table> - <ErrorAlert error={error_message.as_str().to_owned()} /> - </div> - } -} - -#[derive(Properties, PartialEq)] -pub struct ConfigsProps { - pub dir: AttrValue, -} - -#[function_component(Configs)] -fn configs(props: &ConfigsProps) -> Html { - let configs = use_state(Vec::new); - let error_message = use_state(|| "".to_string()); - let dir_name = props.dir.clone(); - - let get_configs = { - let configs = configs.clone(); - let error_message = error_message.clone(); - Callback::from(move |_| { - let url = APIEndpoints::get_configs(dir_name.clone().as_str()); - let configs = configs.clone(); - let error_message = error_message.clone(); - spawn_local(async move { - match Request::get(&url).send().await { - Ok(response) if response.ok() => match response.json::<Vec<String>>().await { - Ok(mut json) => { - json.sort(); - configs.set(json); - } - _ => error_message.set("Ошибка чтения конфигураций".into()), - }, - _ => error_message.set("Ошибка получения конфигураций".into()), - } - }) - }) - }; - - { - let get_configs_clone = get_configs.clone(); - use_effect_with((), move |_| { - get_configs_clone.emit(()); - }); - } - - html! { - <div> - <p><RefreshButton onclick={get_configs.reform(|_| ())} /> - <Link<Route> to={Route::CreateConfig { dir_name: props.dir.clone().to_string() }}> <button class="text-white bg-gradient-to-br from-pink-500 to-orange-400 hover:bg-gradient-to-bl focus:ring-4 focus:outline-none focus:ring-pink-200 dark:focus:ring-pink-800 font-bold rounded-lg text-sm px-5 py-2.5 text-center me-2 mb-2"> - { "Создать" } </button> </Link<Route>> </p> - <h2 class="text-2xl font-bold text-gray-700 mb-2">{format!("Конфигурации для {}:", props.dir.clone())}</h2> - <table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400"> - <thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400"> - <tr> - <th scope="col" class="px-6 py-3"> - {"Имя"} - </th> - </tr> - </thead> - <tbody> - { for (*configs).iter().map(|file_name| { - html! { - <tr class="odd:bg-white odd:dark:bg-gray-900 even:bg-gray-50 even:dark:bg-gray-800 border-b dark:border-gray-700 border-gray-200"> - <th scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white"> - <Link<Route> to={Route::Config { dir_name: props.dir.clone().to_string(), file_name: file_name.clone() }}>{file_name}</Link<Route>> - </th> - </tr> - } - }) } - </tbody> - </table> - - <ErrorAlert error={error_message.as_str().to_owned()} /> - </div> - } -} - -fn string_to_base64_data_url(data: &str, content_type: &str) -> String { - let mut buf = String::new(); - general_purpose::STANDARD.encode_string(data, &mut buf); - format!("data:{};base64,{}", content_type, buf) -} - -#[derive(Properties, PartialEq)] -pub struct ConfigProps { - pub dir: AttrValue, - pub file_name: AttrValue, - #[prop_or("".into())] - pub contents: AttrValue, -} - -#[function_component(Config)] -fn config(props: &ConfigProps) -> Html { - let dir_name = props.dir.clone(); - let file_name = props.file_name.clone(); - let contents = use_state(|| props.contents.to_string()); - - let get_contents = { - let contents = contents.clone(); - Callback::from(move |_| { - let url = - APIEndpoints::get_contents(dir_name.clone().as_str(), file_name.clone().as_str()); - let contents = contents.clone(); - spawn_local(async move { - match Request::get(&url).send().await { - Ok(response) if response.ok() => { - if let Ok(text) = response.text().await { - contents.set(text); - } - } - _ => contents.set("...Ошибка загрузки содержимого...".into()), - } - }) - }) - }; - - { - let get_contents_clone = get_contents.clone(); - let is_contents_empty = props.contents.is_empty(); - use_effect_with((), move |_| { - if is_contents_empty { - get_contents_clone.emit(()); - } - }); - } - - html! { - <div class="w-full"> - <RefreshButton onclick={get_contents.reform(|_| ())} /> - <div class="mb-2 flex justify-between items-center"> - <p class="text-sm font-medium text-gray-900">{"Конфигурация "}<b>{&props.file_name}</b>{" для "}<b>{&props.dir}</b>{":"}</p> - </div> - <div class="relative bg-gray-50 rounded-lg dark:bg-gray-700 p-4"> - <div class="overflow-scroll max-h-full"> - <pre><code id="code-block" class="text-sm text-gray-500 dark:text-gray-400 whitespace-pre">{&*contents}</code></pre> - </div> - <div class="absolute top-2 end-2 bg-gray-50 dark:bg-gray-700"> - <a href={string_to_base64_data_url(&contents, "application/octet-stream")} download={&props.file_name}><button type="button" class="text-white bg-gradient-to-br from-purple-600 to-blue-500 hover:bg-gradient-to-bl focus:ring-4 focus:outline-none focus:ring-blue-300 dark:focus:ring-blue-800 font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 mb-2"> - <svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24"> - <path fill-rule="evenodd" d="M13 11.15V4a1 1 0 1 0-2 0v7.15L8.78 8.374a1 1 0 1 0-1.56 1.25l4 5a1 1 0 0 0 1.56 0l4-5a1 1 0 1 0-1.56-1.25L13 11.15Z" clip-rule="evenodd"/> - <path fill-rule="evenodd" d="M9.657 15.874 7.358 13H5a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2h-2.358l-2.3 2.874a3 3 0 0 1-4.685 0ZM17 16a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H17Z" clip-rule="evenodd"/> - </svg>{"Скачать"}</button></a> - </div> - </div> - </div> - } -} - -#[derive(Properties, PartialEq)] -pub struct GenerateProps { - pub dir: AttrValue, -} - -#[function_component(CreateConfig)] -fn create_config(props: &GenerateProps) -> Html { - let dir_name = props.dir.clone(); - let config_name = use_state(|| "".to_string()); - let result = use_state(|| "".to_string()); - let error = use_state(|| "".to_string()); - let done = use_state(|| false); - - let generate_config_request = { - let dir_name = dir_name.clone(); - let config_name = config_name.clone(); - let done = done.clone(); - let result = result.clone(); - let error = error.clone(); - Callback::from(move |_| { - let request_body = GenerationRequest { - directory: dir_name.clone().to_string(), - common_name: config_name.clone().to_string(), - }; - let json_payload = serde_json::to_string(&request_body).unwrap(); - let result = result.clone(); - let done = done.clone(); - let error = error.clone(); - spawn_local(async move { - match Request::post(APIEndpoints::generate().as_str()) - .header("Content-Type", "application/json") - .body(json_payload) - .unwrap() - .send() - .await - { - Ok(response) if response.ok() => { - if let Ok(text) = response.text().await { - result.set(text); - done.set(true); - error.set("".into()); - } - } - _ => { - error.set("Ошибка создания конфигурации".into()); - } - } - }) - }) - }; - - html! { - <> - if !*done.clone() { - <div class="grid place-items-center"> - <br/> - <div class="w-full max-w-sm p-4 bg-white border border-gray-200 rounded-lg shadow-sm sm:p-6 md:p-8 dark:bg-gray-800 dark:border-gray-700"> - <form class="space-y-6" action="#"> - <h5 class="text-xl font-medium text-gray-900 dark:text-white">{format!("Создание конфигурации для {}:", props.dir.clone())}</h5> - <div> - <label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white" for="common-name">{"Имя:"} - </label> - <input type="text" id="common-name" placeholder="введите название" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white" required=true oninput={Callback::from({ - let config_name = config_name.clone(); - move |e: InputEvent| { - let input = e.target_dyn_into::<web_sys::HtmlInputElement>().unwrap(); - config_name.set(input.value()); - } - })} /> - </div> - <button type="button" onclick={generate_config_request.reform(|_| ())} - class="text-white bg-gradient-to-r from-red-400 via-red-500 to-red-600 hover:bg-gradient-to-br focus:ring-4 focus:outline-none focus:ring-red-300 dark:focus:ring-red-800 shadow-lg shadow-red-500/50 dark:shadow-lg dark:shadow-red-800/80 font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 mb-2"> - { "Создать" } - </button> - </form> - </div> - </div> - <ErrorAlert error={error.as_str().to_owned()} /> - } else { - <div class="p-4 mb-4 text-sm text-green-800 rounded-lg bg-green-50 dark:bg-gray-800 dark:text-green-400" role="alert"> - <span class="font-medium">{"Успешно!"}</span>{" Конфигурация создана!"} - </div> - <Config dir={dir_name} file_name={format!("{}.ovpn", config_name.as_str())} contents={result.as_str().to_owned()} /> - } - </> - } -} +use crate::components::{ + navbar::Navbar, + pages::{ + config::Config, configs::Configs, create_config::CreateConfig, directories::Directories, + homepage::Homepage, not_found::NotFound, + }, +}; +use crate::routes::Route; fn switch(routes: Route) -> Html { match routes.clone() { Route::Home => html! { <> <Navbar currrent_route={routes} /> - - <section class="bg-white dark:bg-gray-900"> - <div class="py-8 px-4 mx-auto max-w-screen-xl text-center lg:py-16"> - <h1 class="mb-4 text-4xl font-extrabold tracking-tight leading-none text-gray-900 md:text-5xl lg:text-6xl dark:text-white">{"PeazyWeb"}</h1> - <p class="mb-8 text-lg font-normal text-gray-500 lg:text-xl sm:px-16 lg:px-48 dark:text-gray-400">{"Ваши файлы конфигурации OpenVPN у вас под рукой!"}</p> - </div> - </section> - <div class="mt-2 pt-1 ml-2 pl-2 mb-2 pb-2 grid place-items-center text-2xl inline-flex items-center font-medium text-blue-700 hover:underline"><Link<Route> to={Route::Directories}>{ "Начать" }</Link<Route>></div> - - <div class="mt-4 pt-4 ml-2 pl-2"><h2 class="mb-2 text-lg font-semibold">{"Исходники"}</h2> - <ul class="max-w-md space-y-1 list-disc list-inside"> - <li><a class="inline-flex items-center font-medium text-blue-700 hover:underline" target="_blank" href="https://gitea.b4tman.ru/b4tman/peazyweb">{"Интерфейс"}</a></li> - <li><a class="inline-flex items-center font-medium text-blue-700 hover:underline" target="_blank" href="https://gitea.b4tman.ru/b4tman/peazyrsa">{"Генератор конфигов"}</a></li> - </ul> - </div> + <Homepage /> </> }, Route::Directories => html! { - <div> + <> <Navbar currrent_route={routes} /> <Directories /> - </div> + </> }, Route::Configs { dir_name } => html! { - <div> + <> <Navbar currrent_route={routes} currrent_dir={dir_name.clone()} /> <Configs dir={dir_name} /> - </div> + </> }, Route::CreateConfig { dir_name } => html! { - <div> + <> <Navbar currrent_route={routes} currrent_dir={dir_name.clone()} /> <CreateConfig dir={dir_name} /> - </div> + </> }, Route::Config { dir_name, file_name, } => html! { - <div> + <> <Navbar currrent_route={routes} currrent_dir={dir_name.clone()} /> <Config dir={dir_name} file_name={file_name} /> - </div> + </> }, _ => html! { <> <Navbar /> - <div class="grid-cols-3 place-items-center"> - <br/> - <h1 class="font-extrabold text-9xl">{"404"}</h1> - <span class="text-2xl font-normal">{"Не найдено"}</span> - </div> + <NotFound /> </> }, } diff --git a/frontend/src/routes.rs b/frontend/src/routes.rs new file mode 100644 index 0000000..0d441d7 --- /dev/null +++ b/frontend/src/routes.rs @@ -0,0 +1,18 @@ +use yew_router::prelude::*; + +#[derive(Clone, Routable, PartialEq)] +pub enum Route { + #[at("/")] + Home, + #[at("/dir")] + Directories, + #[at("/dir/:dir_name")] + Configs { dir_name: String }, + #[at("/dir/:dir_name/:file_name")] + Config { dir_name: String, file_name: String }, + #[at("/gen/:dir_name")] + CreateConfig { dir_name: String }, + #[not_found] + #[at("/404")] + NotFound, +}