Implemented public form appearance settings.

main
Josef Rokos 1 year ago
parent 653249287d
commit 2119c2e56b

74
Cargo.lock generated

@ -91,6 +91,44 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "actix-multipart"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b960e2aea75f49c8f069108063d12a48d329fc8b60b786dfc7552a9d5918d2d"
dependencies = [
"actix-multipart-derive",
"actix-utils",
"actix-web",
"bytes",
"derive_more",
"futures-core",
"futures-util",
"httparse",
"local-waker",
"log",
"memchr",
"mime",
"serde",
"serde_json",
"serde_plain",
"tempfile",
"tokio",
]
[[package]]
name = "actix-multipart-derive"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a0a77f836d869f700e5b47ac7c3c8b9c8bc82e4aec861954c6198abee3ebd4d"
dependencies = [
"darling 0.20.3",
"parse-size",
"proc-macro2",
"quote",
"syn 2.0.48",
]
[[package]]
name = "actix-router"
version = "0.5.1"
@ -2383,6 +2421,24 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "multer"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15d522be0a9c3e46fd2632e272d178f56387bdb5c9fbb3a36c649062e9b5219"
dependencies = [
"bytes",
"encoding_rs",
"futures-util",
"http 1.0.0",
"httparse",
"log",
"memchr",
"mime",
"spin 0.9.8",
"version_check",
]
[[package]]
name = "native-tls"
version = "0.2.11"
@ -2565,6 +2621,12 @@ dependencies = [
"windows-targets 0.48.0",
]
[[package]]
name = "parse-size"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "944553dd59c802559559161f9816429058b869003836120e262e8caec061b7ae"
[[package]]
name = "paste"
version = "1.0.12"
@ -2904,6 +2966,7 @@ name = "rezervator"
version = "0.1.0"
dependencies = [
"actix-files",
"actix-multipart",
"actix-session",
"actix-web",
"base64 0.21.7",
@ -2927,6 +2990,7 @@ dependencies = [
"regex",
"rust_decimal",
"serde",
"server_fn",
"sqlx",
"toml 0.8.8",
"uuid",
@ -3231,6 +3295,15 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_plain"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50"
dependencies = [
"serde",
]
[[package]]
name = "serde_qs"
version = "0.12.0"
@ -3288,6 +3361,7 @@ dependencies = [
"http 1.0.0",
"inventory",
"js-sys",
"multer",
"once_cell",
"send_wrapper",
"serde",

@ -10,11 +10,13 @@ crate-type = ["cdylib", "rlib"]
actix-files = { version = "0.6.2", optional = true }
actix-web = { version = "4.4.0", optional = true, features = ["macros"] }
actix-session = { version = "0.8.0", optional = true, features = ["cookie-session"] }
actix-multipart = { version = "0.6.1", optional = true }
console_error_panic_hook = "0.1"
cfg-if = "1"
leptos = { version = "0.6.5" }
leptos_meta = { version = "0.6.5" }
leptos_actix = { version = "0.6.5", optional = true }
server_fn = { version = "0.6.5", features = ["multipart"] }
leptos_router = { version = "0.6.5" }
serde = { version = "1", features = ["derive"] }
wasm-bindgen = "=0.2.90"
@ -47,6 +49,7 @@ ssr = [
"dep:actix-web",
"dep:leptos_actix",
"dep:actix-session",
"dep:actix-multipart",
"dep:sqlx",
"dep:lettre",
"dep:charts-rs",

@ -0,0 +1,20 @@
div.header_banner {
height:100px;
clip-path: inset(0 0 0 0);
/*bg-img*/
background-size:cover;
padding:30px;
color: white;
}
h1.header_banner {
font-size: xxx-large;
color: white;
text-shadow: -3px -3px 0 #000, 3px -3px 0 #000, -3px 3px 0 #000, 3px 3px 0 #000;
}
@media (min-width: 1200px) {
div.header_banner {
height: 200px;
}
}

@ -8,6 +8,7 @@ use crate::components::admin_portal::AdminPortal;
use crate::components::header::Header;
use crate::components::user_menu::MenuOpener;
use crate::pages::all_reservations::Bookings;
use crate::pages::appearance_settings::Appearance;
use crate::pages::customers::Customers;
use crate::pages::login::Login;
use crate::pages::mail_settings::MailSettings;
@ -103,6 +104,11 @@ pub fn App() -> impl IntoView {
<Customers/>
</AdminPortal>
}/>
<Route path="admin/appearance" view=|| view! {
<AdminPortal>
<Appearance/>
</AdminPortal>
}/>
</Routes>
</main>
</Router>

@ -0,0 +1,156 @@
use cfg_if::cfg_if;
use leptos::*;
use validator::Validate;
use crate::backend::data::{ApiResponse, Appearance};
use crate::components::data_form::ForValidation;
cfg_if! { if #[cfg(feature = "ssr")] {
use actix_web::{post, Responder};
use actix_multipart::Multipart;
use actix_session::Session;
use actix_web::web::Redirect;
use std::fs::File;
use std::io::{Write, Read};
use futures_util::{StreamExt, TryStreamExt};
use crate::backend::data::User;
use crate::error::AppError;
use sqlx::{query, query_as, PgPool};
use crate::backend::get_pool;
use actix_web::web::Data;
use crate::backend::AppData;
use regex::Regex;
pub async fn check_appearance(pool: &PgPool) -> Result<(), AppError> {
let count: (i64,) = query_as("SELECT COUNT(id) FROM appearance")
.fetch_one(pool)
.await?;
if count.0 == 0 {
query("INSERT INTO appearance(title) VALUES('Rezervovator')")
.execute(pool)
.await?;
}
Ok(())
}
async fn set_banner_name(file_name: &str, pool: &PgPool) -> Result<(), AppError> {
query("UPDATE appearance SET banner = $1")
.bind(file_name)
.execute(pool)
.await?;
Ok(())
}
async fn modify_style(file_name: &str) -> Result<(), AppError> {
let mut css_file = File::open("target/site/banner.css")?;
let mut css_str= String::new();
css_file.read_to_string(&mut css_str)?;
if css_str.contains("/*bg-img*/") {
css_str = css_str.replace("/*bg-img*/", &format!("background-image: url('{}');", file_name));
} else {
let re = Regex::new(r#"background-image: url\('[aA-zZ._0-9\-]+'\)"#).unwrap();
css_str = re.replace(&css_str, &format!("background-image: url('{}')", file_name)).to_string();
}
let mut css_file = File::create("target/site/banner.css")?;
css_file.write_all(css_str.as_bytes())?;
Ok(())
}
#[post("/upload")]
pub async fn upload(mut data: Multipart, session: Session, app_data: Data<AppData>) -> impl Responder {
let user = session.get::<User>("user").unwrap_or(None);
if user.is_none() {
return Redirect::to("/login").see_other();
}
if let Some(u) = user {
if !u.admin {
return Redirect::to("/admin/appearance").see_other();
}
}
if let Ok(Some(mut field)) = data.try_next().await {
let content_disp = field.content_disposition().clone();
let file_name = content_disp.get_filename().unwrap();
if file_name.is_empty() {
return Redirect::to("/admin/appearance").see_other();
}
let mut file = File::create(format!("target/site/{}", file_name)).unwrap();
let _name = field.name();
while let Some(chunk) = field.next().await {
let c = chunk.unwrap();
let _ = file.write_all(&c);
}
let _ = set_banner_name(file_name, &app_data.db_pool).await;
let _ = modify_style(file_name).await;
}
Redirect::to("/admin/appearance").see_other()
}
}}
#[server]
pub async fn get_appearance() -> Result<Appearance, ServerFnError> {
let pool = get_pool().await?;
let appearance = query_as::<_, Appearance>("SELECT * FROM appearance")
.fetch_one(&pool)
.await?;
Ok(appearance)
}
#[server]
pub async fn update_appearance(appearance: Appearance) -> Result<ApiResponse<()>, ServerFnError> {
use crate::perm_check;
perm_check!(is_admin);
let pool = get_pool().await?;
let id = appearance.id();
query("UPDATE appearance SET title = $1, text = $2 WHERE id = $3")
.bind(appearance.title)
.bind(appearance.text)
.bind(id)
.execute(&pool)
.await?;
Ok(ApiResponse::Data(()))
}
impl ForValidation for UpdateAppearance {
fn entity(&self) -> &dyn Validate {
&self.appearance
}
}
#[server]
pub async fn delete_banner() -> Result<ApiResponse<()>, ServerFnError> {
use std::fs;
use crate::perm_check;
perm_check!(is_admin);
let appearance = get_appearance().await?;
let pool = get_pool().await?;
query("UPDATE appearance SET banner = $1 WHERE id = $2")
.bind(None::<String>)
.bind(appearance.id())
.execute(&pool)
.await?;
if let Some(f) = appearance.banner {
fs::remove_file(format!("target/site/{}", f))?;
}
Ok(ApiResponse::Data(()))
}

@ -622,3 +622,18 @@ pub struct ChartData {
pub count: i64,
pub period: f64
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, Validate)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct Appearance {
id: i32,
pub banner: Option<String>,
pub text: Option<String>,
pub title: Option<String>
}
impl Appearance {
pub fn id(&self) -> i32 {
self.id
}
}

@ -8,6 +8,7 @@ pub mod property;
pub mod reservation;
pub mod customer;
pub mod mail;
pub mod appearance;
#[macro_export]
macro_rules! perm_check {

@ -51,6 +51,12 @@ fn settings_menu(opener: MenuOpener) -> impl IntoView {
<span class="align-middle">{trl("Mail settings")}</span>
</a>
</li>
<li>
<a class="dropdown-item" href="/admin/appearance">
<i class="bx bx-envelope me-2"></i>
<span class="align-middle">{trl("Appearance")}</span>
</a>
</li>
</ul>
}
}
@ -133,11 +139,12 @@ pub fn AdminPortal(children: Children) -> impl IntoView {
//<!-- /Settings -->
<ul class="navbar-nav flex-row align-items-center ms-auto">
<SettingsMenu opener=settings_menu/>
<li class="nav-item navbar-dropdown dropdown-user dropdown">
<a class="nav-link dropdown-toggle hide-arrow" href="#" on:click=move |_| settings_menu.toggle()>
<i class="bx bx-cog fs-3 lh-0"></i>
</a>
<SettingsMenu opener=settings_menu/>
</li>
//<!-- User -->
<li class="nav-item navbar-dropdown dropdown-user dropdown">

@ -38,6 +38,7 @@ pub fn Header() -> impl IntoView {
<Link rel="stylesheet" href="/vendor/css/core.css" />
<Link rel="stylesheet" href="/vendor/css/theme-default.css" />
<Link rel="stylesheet" href="/css/demo.css" />
<Link rel="stylesheet" href="/banner.css" />
//<!-- Vendors CSS -->
<Link rel="stylesheet" href="/vendor/libs/perfect-scrollbar/perfect-scrollbar.css" />

@ -10,7 +10,8 @@ pub enum AppError {
FatalError(String),
SlotParseError,
MailAddrParseErr(String),
MailSendError(String)
MailSendError(String),
FileIOError(String)
}
impl AppError {
@ -21,7 +22,8 @@ impl AppError {
AppError::FatalError(e) => {format!("Fatal error: {}", e)},
AppError::SlotParseError => {"Book slot parse error".to_string()},
AppError::MailAddrParseErr(e) => {format!("Cannot parse email address: {}", e)},
AppError::MailSendError(e) => {format!("Cannot send email: {}", e)}
AppError::MailSendError(e) => {format!("Cannot send email: {}", e)},
AppError::FileIOError(e) => {format!("IO error: {}", e)}
}
}
}
@ -80,3 +82,10 @@ impl From<lettre::transport::file::Error> for AppError {
AppError::MailSendError(value.to_string())
}
}
#[cfg(feature = "ssr")]
impl From<std::io::Error> for AppError {
fn from(value: std::io::Error) -> Self {
AppError::FileIOError(value.to_string())
}
}

@ -132,7 +132,17 @@ lazy_static! {
("Are you sure you want to delete property ", "Opravdu chcete smazat předmět "),
("Delete property", "Smazat předmět"),
("Are you sure you want to delete user ", "Opravdu chcete smazat uživatele "),
("Remember for next time", "Zapamatovat pro příště")
("Remember for next time", "Zapamatovat pro příště"),
("Appearance settings", "Nastavení vzhledu"),
("Top banner", "Titulní banner"),
("Banner file", "Soubor banneru"),
("Upload", "Nahrát"),
("Edit title", "Upravit titulek"),
("Edit text", "Upravit text"),
("Update text", "Upravit text"),
("Update title", "Upravit titulek"),
("Are you sure you want to delete banner?", "Opravdu chcete smazat banner?"),
("Delete banner", "Smazat banner")
])),
("sk", HashMap::from( [
("Dashboard", "Prehlad"),

@ -1,5 +1,6 @@
use leptos_captcha::spow::pow::Pow;
use log::error;
use rezervator::backend::appearance::check_appearance;
use rezervator::backend::company::check_company;
use rezervator::backend::mail::check_messages;
use rezervator::backend::user::create_admin;
@ -67,6 +68,9 @@ async fn main() -> std::io::Result<()> {
if let Err(e) = check_messages(&pool).await {
error!("Error while checking messages: {:?}", e);
}
if let Err(e) = check_appearance(&pool).await {
error!("Error while checking messages: {:?}", e);
}
HttpServer::new(move || {
let leptos_options = &conf.leptos_options;
@ -85,6 +89,7 @@ async fn main() -> std::io::Result<()> {
routes.to_owned(),
|| view! { <App/> },
)
.service(appearance::upload)
.service(Files::new("/", site_root))
//.wrap(middleware::Compress::default())
})

@ -0,0 +1,127 @@
use leptos::*;
use crate::backend::appearance::{DeleteBanner, get_appearance, UpdateAppearance};
use crate::backend::data::Appearance;
use crate::components::data_form::{DataForm, QuestionDialog};
use crate::components::modal_box::DialogOpener;
use crate::locales::trl;
#[component]
fn edit_title(opener: DialogOpener, appearance: ReadSignal<Appearance>) -> impl IntoView {
let update = create_server_action::<UpdateAppearance>();
view! {
<DataForm opener=opener action=update title="Update title">
<input type="hidden" prop:value={move || appearance.get().text.unwrap_or_default()} name="appearance[text]" />
<input type="hidden" prop:value={move || appearance.get().id()} name="appearance[id]"/>
<div class="row">
<div class="col mb-3">
<label for="title" class="form-label">{trl("Title")}</label>
<input
type="text"
id="title"
class="form-control"
placeholder={trl("Enter title")}
prop:value={move || appearance.get().title.unwrap_or_default()}
name="appearance[title]"
/>
</div>
</div>
</DataForm>
}
}
#[component]
fn edit_text(opener: DialogOpener, appearance: ReadSignal<Appearance>) -> impl IntoView {
let update = create_server_action::<UpdateAppearance>();
view! {
<DataForm opener=opener action=update title="Update text">
<input type="hidden" prop:value={move || appearance.get().title.unwrap_or_default()} name="appearance[title]" />
<input type="hidden" prop:value={move || appearance.get().id()} name="appearance[id]"/>
<div class="row">
<div class="col mb-3">
<label for="text" class="form-label">{trl("Text")}</label>
<textarea
id="title"
class="form-control"
prop:value={move || appearance.get().text.unwrap_or_default()}
name="appearance[text]"
/>
</div>
</div>
</DataForm>
}
}
#[component]
fn delete_banner(opener: DialogOpener) -> impl IntoView {
let delete = create_server_action::<DeleteBanner>();
view! {
<QuestionDialog opener=opener action=delete title="Delete banner">
<div>{trl("Are you sure you want to delete banner?")}</div>
</QuestionDialog>
}
}
#[component]
pub fn appearance() -> impl IntoView {
let title_edit = DialogOpener::new();
let text_edit = DialogOpener::new();
let del_dialog = DialogOpener::new();
let appearance = create_resource(move || title_edit.visible() || text_edit.visible() || del_dialog.visible(), |_| get_appearance());
let app_edit = create_rw_signal(Appearance::default());
view! {
<EditTitle opener=title_edit appearance=app_edit.read_only()/>
<EditText opener=text_edit appearance=app_edit.read_only()/>
<DeleteBanner opener=del_dialog/>
<h1>{trl("Appearance settings")}</h1>
<div class="row mb-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">{trl("Top banner")}</h5>
<form method="post" action="/upload" enctype="multipart/form-data">
<label for="banner_file">{trl("Banner file")}</label>
<input id="banner_file" type="file" class="form-control" name="file_to_upload"/>
<button type="submit" class="btn btn-primary">
{trl("Upload")}
</button>
</form><br/>
<Transition fallback=move || view! {<p>{trl("Loading...")}</p> }>
{
appearance.get().map(|a| match a {
Ok(a) => {
app_edit.set(a.clone());
let app = a.clone();
view! {
<div>
<Show when=move || a.clone().banner.is_some()>
<div class="header_banner">
<h1 class="header_banner">{app.title.clone().unwrap_or_default()}</h1>
</div>
</Show>
<div>
<button class="btn btn-info" on:click=move |_| title_edit.show()>
<i class="bx bx-edit-alt me-1"></i> {trl("Edit title")}</button>
<button class="btn btn-danger" on:click=move |_| del_dialog.show()>
{trl("Delete")}
</button>
<br/><br/>
<div>
{app.text.unwrap_or("<< TEXT >>".to_string())}
</div>
<a class="dropdown-item" href="javascript:void(0);" on:click=move |_| text_edit.show()>
<i class="bx bx-edit-alt me-1"></i> {trl("Edit text")}</a>
</div>
</div>
}
}
Err(e) => {view! {<div><p>{trl("Error loading data")}</p>
<p>{e.to_string()}</p></div>
}}
})
}
</Transition>
</div>
</div>
</div>
}
}

@ -21,4 +21,5 @@ pub mod mail_settings;
mod mail_view;
pub mod all_reservations;
pub mod customers;
pub mod appearance_settings;

@ -3,6 +3,7 @@ use leptos::*;
use leptos_captcha::{Captcha, pow_dispatch};
use leptos_router::*;
use rust_decimal::Decimal;
use crate::backend::appearance::get_appearance;
use crate::backend::customer::get_remembered;
use crate::backend::data::{ApiResponse, Customer, DayHour, Reservation, ResProperty, SlotType, TmCheck};
use crate::backend::reservation::{CreateReservation, get_public_form_data, is_reserved};
@ -103,6 +104,7 @@ pub fn Public() -> impl IntoView {
let is_pending = create_rw_signal(None);
let active_str = create_rw_signal("true".to_string());
let get_customer = create_blocking_resource(||(), move |_| get_remembered());
let appearance = create_blocking_resource(||(), move |_| get_appearance());
let customer = create_rw_signal(Customer::default());
create_effect(move |_| {
@ -117,6 +119,34 @@ pub fn Public() -> impl IntoView {
price=price.write_only()
slots=slots.write_only()
captcha_state=is_pending.read_only()/>
<Transition fallback=move || view! {<p>{trl("Loading...")}</p> }>
{
appearance.get().map(|a| match a {
Ok(a) => {
let app = a.clone();
view! {
<div>
<Show when=move || a.clone().banner.is_some()>
<div class="header_banner">
<h1 class="header_banner">{app.title.clone().unwrap_or_default()}</h1>
</div>
</Show>
<div>
<div>
{app.text.unwrap_or("".to_string())}
</div>
</div>
</div>
}
},
Err(e) => {view! {<div><p>{trl("Error loading data")}</p>
<p>{e.to_string()}</p></div>
}}
})
}
</Transition>
<div class="card-body">
<ActionForm
on:submit=move |ev| {

Loading…
Cancel
Save