Added layout options for the public booking form. Fixes #16

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 22:27:29 +02:00
co-authored by Claude Opus 5.5
parent 5fcea0cf85
commit 0f48160fb4
11 changed files with 293 additions and 24 deletions
+1 -1
View File
@@ -48,6 +48,6 @@ paths:
- Custom styles go to a CSS file in `assets/css/` and must be linked with `<Link rel="stylesheet">` in `src/components/header.rs` (order matters: after the vendor files to override them). `style/main.scss` is not linked anywhere and has no effect. Never edit `assets/vendor/` (the one app-owned file there is `assets/vendor/css/control.css`). - Custom styles go to a CSS file in `assets/css/` and must be linked with `<Link rel="stylesheet">` in `src/components/header.rs` (order matters: after the vendor files to override them). `style/main.scss` is not linked anywhere and has no effect. Never edit `assets/vendor/` (the one app-owned file there is `assets/vendor/css/control.css`).
- The admin portal has a light and a dark theme (`src/components/theme.rs`, switch in the navbar: auto / light / dark, stored in the `theme` cookie). Dark colours are overrides in `assets/css/dark.css`, which `AdminPortal` links with a `media` attribute — the public page and the login page never load it. When you add or restyle something in the admin portal, check it in both themes and add the dark override there; don't hard-code light colours (`bg-light`, `bg-white`, inline `color`) in admin views. - The admin portal has a light and a dark theme (`src/components/theme.rs`, switch in the navbar: auto / light / dark, stored in the `theme` cookie). Dark colours are overrides in `assets/css/dark.css`, which `AdminPortal` links with a `media` attribute — the public page and the login page never load it. When you add or restyle something in the admin portal, check it in both themes and add the dark override there; don't hard-code light colours (`bg-light`, `bg-white`, inline `color`) in admin views.
- Charts are drawn on the server, so the chart server fns take a `dark` flag from `Theme::is_dark()`. - Charts are drawn on the server, so the chart server fns take a `dark` flag from `Theme::is_dark()`.
- Styles of the public page are in `assets/css/public.css`, scoped by its own classes (`.public-page` wraps the public route only, `.public-banner*`), so they don't leak into the admin portal. The customer's appearance settings reach it as CSS variables in a `style` attribute (`Colors::style()` in `src/backend/color.rs`, `BannerOptions::style()` in `src/backend/banner.rs`, `Appearance::banner_style()` in `src/backend/data.rs`) — never generate CSS files. Every value put into a `style` attribute must be validated first. - Styles of the public page are in `assets/css/public.css`, scoped by its own classes (`.public-page` wraps the public route only, `.public-banner*`), so they don't leak into the admin portal. The customer's appearance settings reach it as CSS variables in a `style` attribute (`Colors::style()` in `src/backend/color.rs`, `BannerOptions::style()` in `src/backend/banner.rs`, `Appearance::banner_style()` in `src/backend/data.rs`) — and as classes on the wrapper for the layout options (`LayoutOptions::classes()` in `src/backend/layout.rs`); never generate CSS files. A new look option goes the same way: a column with a default equal to the current look, a validated field, a variable or class, a rule in `public.css`, a control in the appearance settings — the markup of the public form stays the same for all values. Every value put into a `style` attribute must be validated first.
- The preview in the appearance settings is a `.public-page.public-preview` element inside the admin portal, styled by the same `public.css`. A rule there must set every property that `dark.css` sets for the same component, otherwise the preview changes with the admin theme. When the public form gets a new element, add it to the preview (`Preview` in `src/pages/appearance_settings.rs`) and check it with a dark palette. - The preview in the appearance settings is a `.public-page.public-preview` element inside the admin portal, styled by the same `public.css`. A rule there must set every property that `dark.css` sets for the same component, otherwise the preview changes with the admin theme. When the public form gets a new element, add it to the preview (`Preview` in `src/pages/appearance_settings.rs`) and check it with a dark palette.
- Stylesheets and meta tags are declared in `src/components/header.rs`. - Stylesheets and meta tags are declared in `src/components/header.rs`.
+1
View File
@@ -42,6 +42,7 @@ Unit tests are `#[cfg(test)] mod tests` at the bottom of the file they test (`#[
- `src/backend/` — server functions per domain, plus `data.rs` (all DTOs/entities shared by server and client) and `auth_middleware.rs` (redirects unauthenticated `/admin*` page requests to `/login`). - `src/backend/` — server functions per domain, plus `data.rs` (all DTOs/entities shared by server and client) and `auth_middleware.rs` (redirects unauthenticated `/admin*` page requests to `/login`).
- `src/pages/` — page and dialog components; `src/components/` — reusable pieces (`DataForm`, `QuestionDialog`, `ModalDialog`, `ServerErr`, `ValidationErr`, `AdminPortal`, `Theme` for the light/dark theme of the admin portal). - `src/pages/` — page and dialog components; `src/components/` — reusable pieces (`DataForm`, `QuestionDialog`, `ModalDialog`, `ServerErr`, `ValidationErr`, `AdminPortal`, `Theme` for the light/dark theme of the admin portal).
- `src/backend/banner.rs` — options of the public page banner (`BannerOptions`) and the checks of uploaded images; shared by server and browser. - `src/backend/banner.rs` — options of the public page banner (`BannerOptions`) and the checks of uploaded images; shared by server and browser.
- `src/backend/layout.rs` — layout options of the public booking form (`LayoutOptions`), which become classes on the page wrapper.
- `src/backend/color.rs` — colours of the public page set by the customer (`Colors`), contrast checks and the ready-made palettes; shared by server and browser. - `src/backend/color.rs` — colours of the public page set by the customer (`Colors`), contrast checks and the ready-made palettes; shared by server and browser.
- `src/locales/` — `trl()` and the translation catalogue. - `src/locales/` — `trl()` and the translation catalogue.
- `src/error.rs` — `AppError`; `src/config.rs` — `config.toml` structs (ssr only); `src/validator.rs` — client-side form validation helper. - `src/error.rs` — `AppError`; `src/config.rs` — `config.toml` structs (ssr only); `src/validator.rs` — client-side form validation helper.
+46 -2
View File
@@ -161,6 +161,49 @@
color: var(--rz-body); color: var(--rz-body);
} }
/* Layout options: classes on .public-page from LayoutOptions::classes in src/backend/layout.rs.
The markup is the same for all of them. */
.public-page.rz-width-centered .public-form,
.public-page.rz-width-centered .public-banner-text {
max-width: 1100px;
margin-left: auto;
margin-right: auto;
}
.public-page.rz-width-centered.rz-form-stacked .public-form,
.public-page.rz-width-centered.rz-form-stacked .public-banner-text {
max-width: 680px;
}
.public-page.rz-form-stacked .public-form-columns > * {
flex: 0 0 100%;
max-width: 100%;
}
.public-page.rz-slots-large .btn-check + .btn {
min-width: 5.5rem;
margin: 0 0.4rem 0.4rem 0;
padding: 0.8rem 1.1rem;
font-size: 1.0625rem;
}
.public-page.rz-corners-square,
.public-page.rz-corners-square * {
border-radius: 0 !important;
}
/* The preview is much narrower than a real window, so the centred column is shown in proportion */
.public-page.public-preview.rz-width-centered .public-form,
.public-page.public-preview.rz-width-centered .public-banner-text {
max-width: 75%;
}
.public-page.public-preview.rz-width-centered.rz-form-stacked .public-form,
.public-page.public-preview.rz-width-centered.rz-form-stacked .public-banner-text {
max-width: 55%;
}
/* Banner: the image (or a strip in the primary colour) with an optional logo and the title. /* Banner: the image (or a strip in the primary colour) with an optional logo and the title.
The fallbacks are for the form embedded in the admin portal, outside of .public-page. */ The fallbacks are for the form embedded in the admin portal, outside of .public-page. */
@@ -225,12 +268,13 @@
font-size: x-large; font-size: x-large;
} }
.public-banner-focus { /* Three classes: the mark stays round with square corners too */
.public-page .public-banner .public-banner-focus {
position: absolute; position: absolute;
width: 14px; width: 14px;
height: 14px; height: 14px;
border: 2px solid #ffffff; border: 2px solid #ffffff;
border-radius: 50%; border-radius: 50% !important;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6); box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6);
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
pointer-events: none; pointer-events: none;
+5
View File
@@ -0,0 +1,5 @@
-- Layout of the public booking form
ALTER TABLE appearance ADD layout_width VARCHAR(10) NOT NULL DEFAULT 'full';
ALTER TABLE appearance ADD layout_arrangement VARCHAR(10) NOT NULL DEFAULT 'columns';
ALTER TABLE appearance ADD layout_slots VARCHAR(10) NOT NULL DEFAULT 'normal';
ALTER TABLE appearance ADD layout_corners VARCHAR(10) NOT NULL DEFAULT 'rounded';
+13 -3
View File
@@ -5,6 +5,7 @@ use validator::Validate;
use crate::backend::banner::BannerOptions; use crate::backend::banner::BannerOptions;
use crate::backend::color::Colors; use crate::backend::color::Colors;
use crate::backend::data::{ApiResponse, Appearance}; use crate::backend::data::{ApiResponse, Appearance};
use crate::backend::layout::LayoutOptions;
use crate::components::data_form::ForValidation; use crate::components::data_form::ForValidation;
cfg_if! { if #[cfg(feature = "ssr")] { cfg_if! { if #[cfg(feature = "ssr")] {
@@ -129,9 +130,9 @@ impl ForValidation for UpdateAppearance {
} }
} }
/// Saves the look of the public page: its colours and the options of the banner /// Saves the look of the public page: its colours, the options of the banner and the layout of the form
#[server] #[server]
pub async fn update_style(colors: Colors, banner: BannerOptions) -> Result<ApiResponse<()>, ServerFnError> { pub async fn update_style(colors: Colors, banner: BannerOptions, layout: LayoutOptions) -> Result<ApiResponse<()>, ServerFnError> {
use crate::perm_check; use crate::perm_check;
perm_check!(is_admin); perm_check!(is_admin);
@@ -143,12 +144,17 @@ pub async fn update_style(colors: Colors, banner: BannerOptions) -> Result<ApiRe
if banner.validate().is_err() { if banner.validate().is_err() {
return Ok(ApiResponse::Error("Invalid banner settings".to_string())) return Ok(ApiResponse::Error("Invalid banner settings".to_string()))
} }
if layout.validate().is_err() {
return Ok(ApiResponse::Error("Invalid layout".to_string()))
}
let pool = get_pool().await?; let pool = get_pool().await?;
let colors = colors.sanitized(); let colors = colors.sanitized();
let banner = banner.sanitized(); let banner = banner.sanitized();
let layout = layout.sanitized();
query("UPDATE appearance SET color_primary = $1, color_background = $2, color_card = $3, color_text = $4, \ query("UPDATE appearance SET color_primary = $1, color_background = $2, color_card = $3, color_text = $4, \
banner_height = $5, banner_focus_x = $6, banner_focus_y = $7, banner_dim = $8, title_color = $9, title_align = $10") banner_height = $5, banner_focus_x = $6, banner_focus_y = $7, banner_dim = $8, title_color = $9, title_align = $10, \
layout_width = $11, layout_arrangement = $12, layout_slots = $13, layout_corners = $14")
.bind(colors.primary) .bind(colors.primary)
.bind(colors.background) .bind(colors.background)
.bind(colors.card) .bind(colors.card)
@@ -159,6 +165,10 @@ pub async fn update_style(colors: Colors, banner: BannerOptions) -> Result<ApiRe
.bind(banner.dim) .bind(banner.dim)
.bind(banner.title_color) .bind(banner.title_color)
.bind(banner.title_align) .bind(banner.title_align)
.bind(layout.width)
.bind(layout.arrangement)
.bind(layout.slots)
.bind(layout.corners)
.execute(&pool) .execute(&pool)
.await?; .await?;
+5 -1
View File
@@ -6,6 +6,7 @@ use chrono::{Local, NaiveDate, NaiveTime, Weekday};
use lazy_static::lazy_static; use lazy_static::lazy_static;
use crate::backend::banner::{is_stored_image_name, BannerOptions}; use crate::backend::banner::{is_stored_image_name, BannerOptions};
use crate::backend::color::Colors; use crate::backend::color::Colors;
use crate::backend::layout::LayoutOptions;
use regex::Regex; use regex::Regex;
use rust_decimal::Decimal; use rust_decimal::Decimal;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -751,7 +752,10 @@ pub struct Appearance {
pub logo: Option<String>, pub logo: Option<String>,
#[serde(default)] #[serde(default)]
#[cfg_attr(feature = "ssr", sqlx(flatten))] #[cfg_attr(feature = "ssr", sqlx(flatten))]
pub banner_options: BannerOptions pub banner_options: BannerOptions,
#[serde(default)]
#[cfg_attr(feature = "ssr", sqlx(flatten))]
pub layout: LayoutOptions
} }
impl Appearance { impl Appearance {
+161
View File
@@ -0,0 +1,161 @@
use serde::{Deserialize, Serialize};
use validator::{Validate, ValidationError};
/// Values of the layout options: the first one of each list is the default and adds no class,
/// the others are paired with the class they put on the wrapper of the public page
/// (styled in `assets/css/public.css`).
const WIDTHS: [(&str, &str); 2] = [("full", ""), ("centered", "rz-width-centered")];
const ARRANGEMENTS: [(&str, &str); 2] = [("columns", ""), ("stacked", "rz-form-stacked")];
const SLOTS: [(&str, &str); 2] = [("normal", ""), ("large", "rz-slots-large")];
const CORNERS: [(&str, &str); 2] = [("rounded", ""), ("square", "rz-corners-square")];
/// Layout of the public booking form, set by the customer in the appearance settings
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Validate)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct LayoutOptions {
/// Content over the full width of the window (`full`) or in a column in the middle (`centered`)
#[cfg_attr(feature = "ssr", sqlx(rename = "layout_width"))]
#[validate(custom(function = "validate_width", message = "Invalid layout"))]
pub width: String,
/// The two cards of the form side by side (`columns`) or below each other (`stacked`)
#[cfg_attr(feature = "ssr", sqlx(rename = "layout_arrangement"))]
#[validate(custom(function = "validate_arrangement", message = "Invalid layout"))]
pub arrangement: String,
/// Size of the time slot buttons: `normal` or `large`
#[cfg_attr(feature = "ssr", sqlx(rename = "layout_slots"))]
#[validate(custom(function = "validate_slots", message = "Invalid layout"))]
pub slots: String,
/// Corners of cards, buttons and inputs: `rounded` or `square`
#[cfg_attr(feature = "ssr", sqlx(rename = "layout_corners"))]
#[validate(custom(function = "validate_corners", message = "Invalid layout"))]
pub corners: String,
}
impl Default for LayoutOptions {
fn default() -> Self {
LayoutOptions {
width: WIDTHS[0].0.to_string(),
arrangement: ARRANGEMENTS[0].0.to_string(),
slots: SLOTS[0].0.to_string(),
corners: CORNERS[0].0.to_string(),
}
}
}
impl LayoutOptions {
/// Replaces values that are not valid with the default ones
pub fn sanitized(&self) -> Self {
let pick = |value: &str, options: &[(&str, &str)]| {
if is_option(value, options) { value.to_string() } else { options[0].0.to_string() }
};
LayoutOptions {
width: pick(&self.width, &WIDTHS),
arrangement: pick(&self.arrangement, &ARRANGEMENTS),
slots: pick(&self.slots, &SLOTS),
corners: pick(&self.corners, &CORNERS),
}
}
/// Classes for the wrapper of the public page, separated by spaces. Only known classes
/// get there, whatever the values are.
pub fn classes(&self) -> String {
[
class_of(&self.width, &WIDTHS),
class_of(&self.arrangement, &ARRANGEMENTS),
class_of(&self.slots, &SLOTS),
class_of(&self.corners, &CORNERS),
].into_iter().filter(|class| !class.is_empty()).collect::<Vec<_>>().join(" ")
}
}
fn is_option(value: &str, options: &[(&str, &str)]) -> bool {
options.iter().any(|option| option.0 == value)
}
fn class_of(value: &str, options: &[(&'static str, &'static str)]) -> &'static str {
options.iter().find(|option| option.0 == value).map(|option| option.1).unwrap_or_default()
}
fn validate_option(value: &str, options: &[(&str, &str)]) -> Result<(), ValidationError> {
if is_option(value, options) { Ok(()) } else { Err(ValidationError::new("invalid option")) }
}
fn validate_width(value: &str) -> Result<(), ValidationError> {
validate_option(value, &WIDTHS)
}
fn validate_arrangement(value: &str) -> Result<(), ValidationError> {
validate_option(value, &ARRANGEMENTS)
}
fn validate_slots(value: &str) -> Result<(), ValidationError> {
validate_option(value, &SLOTS)
}
fn validate_corners(value: &str) -> Result<(), ValidationError> {
validate_option(value, &CORNERS)
}
#[cfg(test)]
mod tests {
use super::*;
fn layout(width: &str, arrangement: &str, slots: &str, corners: &str) -> LayoutOptions {
LayoutOptions {
width: width.to_string(),
arrangement: arrangement.to_string(),
slots: slots.to_string(),
corners: corners.to_string(),
}
}
#[test]
fn default_layout_is_valid_and_adds_no_class() {
let default = LayoutOptions::default();
assert_eq!(default, layout("full", "columns", "normal", "rounded"));
assert!(default.validate().is_ok());
assert_eq!(default, default.sanitized());
assert_eq!(default.classes(), "");
}
#[test]
fn all_listed_values_are_valid() {
for width in ["full", "centered"] {
for arrangement in ["columns", "stacked"] {
for slots in ["normal", "large"] {
for corners in ["rounded", "square"] {
let options = layout(width, arrangement, slots, corners);
assert!(options.validate().is_ok(), "{:?}", options);
assert_eq!(options, options.sanitized());
}
}
}
}
}
#[test]
fn validation_rejects_other_values() {
assert!(layout("wide", "columns", "normal", "rounded").validate().is_err());
assert!(layout("full", "rows", "normal", "rounded").validate().is_err());
assert!(layout("full", "columns", "", "rounded").validate().is_err());
assert!(layout("full", "columns", "normal", "rz-corners-square").validate().is_err());
// A value of another option is not accepted either
assert!(layout("large", "columns", "normal", "rounded").validate().is_err());
}
#[test]
fn classes_of_changed_options() {
assert_eq!(layout("centered", "stacked", "large", "square").classes(),
"rz-width-centered rz-form-stacked rz-slots-large rz-corners-square");
assert_eq!(layout("full", "stacked", "normal", "square").classes(), "rz-form-stacked rz-corners-square");
assert_eq!(layout("centered", "columns", "normal", "rounded").classes(), "rz-width-centered");
}
#[test]
fn invalid_values_never_get_into_classes() {
let options = layout("\" onclick=\"x", "evil-class", "<script>", "a b c");
assert_eq!(options.classes(), "");
assert_eq!(options.sanitized(), LayoutOptions::default());
}
}
+1
View File
@@ -11,6 +11,7 @@ pub mod mail;
pub mod appearance; pub mod appearance;
pub mod color; pub mod color;
pub mod banner; pub mod banner;
pub mod layout;
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
pub mod health; pub mod health;
+14
View File
@@ -165,6 +165,20 @@ lazy_static! {
("Save", "Uložit"), ("Save", "Uložit"),
("Reset to default", "Obnovit výchozí"), ("Reset to default", "Obnovit výchozí"),
("Settings saved", "Nastavení je uloženo"), ("Settings saved", "Nastavení je uloženo"),
("Layout", "Rozložení"),
("Content width", "Šířka obsahu"),
("Full width", "Celá šířka"),
("Centred", "Na střed"),
("Form", "Formulář"),
("Side by side", "Vedle sebe"),
("Stacked", "Pod sebou"),
("Time slots", "Časy"),
("Normal", "Běžné"),
("Large", "Velké"),
("Corners", "Rohy"),
("Rounded", "Zaoblené"),
("Square", "Hranaté"),
("Invalid layout", "Neplatné rozložení"),
("Title", "Titulek"), ("Title", "Titulek"),
("Enter title", "Zadejte titulek"), ("Enter title", "Zadejte titulek"),
("Title and text", "Titulek a text"), ("Title and text", "Titulek a text"),
+41 -12
View File
@@ -6,6 +6,7 @@ use crate::backend::appearance::{DeleteBanner, DeleteLogo, get_appearance, Updat
use crate::backend::banner::{image_extension, BannerOptions, ImageKind, IMAGE_ACCEPT, MAX_DIM, MAX_IMAGE_SIZE}; use crate::backend::banner::{image_extension, BannerOptions, ImageKind, IMAGE_ACCEPT, MAX_DIM, MAX_IMAGE_SIZE};
use crate::backend::color::{Colors, PALETTES}; use crate::backend::color::{Colors, PALETTES};
use crate::backend::data::{banner_style, ApiResponse, Appearance}; use crate::backend::data::{banner_style, ApiResponse, Appearance};
use crate::backend::layout::LayoutOptions;
use crate::components::data_form::{DataForm, QuestionDialog}; use crate::components::data_form::{DataForm, QuestionDialog};
use crate::components::modal_box::DialogOpener; use crate::components::modal_box::DialogOpener;
use crate::locales::trl; use crate::locales::trl;
@@ -15,6 +16,7 @@ use crate::locales::trl;
struct Look { struct Look {
colors: RwSignal<Colors>, colors: RwSignal<Colors>,
banner: RwSignal<BannerOptions>, banner: RwSignal<BannerOptions>,
layout: RwSignal<LayoutOptions>,
/// Images selected in the file inputs and not uploaded yet, as object URLs for the preview /// Images selected in the file inputs and not uploaded yet, as object URLs for the preview
new_banner: RwSignal<Option<String>>, new_banner: RwSignal<Option<String>>,
new_logo: RwSignal<Option<String>>, new_logo: RwSignal<Option<String>>,
@@ -276,7 +278,8 @@ fn preview(look: Look, appearance: ReadSignal<Appearance>) -> impl IntoView {
}; };
view! { view! {
<div class="public-page public-preview" style=move || look.colors.get().style()> <div class=move || format!("public-page public-preview {}", look.layout.get().classes())
style=move || look.colors.get().style()>
<Show when=has_banner> <Show when=has_banner>
<div class="public-banner" style=move || banner_style(image(), &look.banner.get()) on:click=set_focus> <div class="public-banner" style=move || banner_style(image(), &look.banner.get()) on:click=set_focus>
{move || logo().map(|url| view! { {move || logo().map(|url| view! {
@@ -292,8 +295,8 @@ fn preview(look: Look, appearance: ReadSignal<Appearance>) -> impl IntoView {
</div> </div>
</Show> </Show>
<div class="public-banner-text" inner_html=move || appearance.get().text.unwrap_or_default()></div> <div class="public-banner-text" inner_html=move || appearance.get().text.unwrap_or_default()></div>
<div class="card-body"> <div class="card-body public-form">
<div class="row"> <div class="row public-form-columns">
<div class="col-sm"> <div class="col-sm">
<div class="card mb-3"> <div class="card mb-3">
<div class="card-body"> <div class="card-body">
@@ -423,12 +426,29 @@ fn banner_settings(
} }
} }
/// Saves colours and banner options together, they make one look
#[component] #[component]
fn save_bar(look: Look, saved: RwSignal<(Colors, BannerOptions)>) -> impl IntoView { fn layout_settings(layout: RwSignal<LayoutOptions>) -> impl IntoView {
view! {
<Choice label="Content width" options=vec![("full", "Full width"), ("centered", "Centred")]
value=move || layout.get().width on_change=move |v| layout.update(|l| l.width = v)/>
<Choice label="Form" options=vec![("columns", "Side by side"), ("stacked", "Stacked")]
value=move || layout.get().arrangement on_change=move |v| layout.update(|l| l.arrangement = v)/>
<Choice label="Time slots" options=vec![("normal", "Normal"), ("large", "Large")]
value=move || layout.get().slots on_change=move |v| layout.update(|l| l.slots = v)/>
<Choice label="Corners" options=vec![("rounded", "Rounded"), ("square", "Square")]
value=move || layout.get().corners on_change=move |v| layout.update(|l| l.corners = v)/>
}
}
/// Everything that is saved together as one look
type SavedLook = (Colors, BannerOptions, LayoutOptions);
/// Saves colours, banner options and layout together, they make one look
#[component]
fn save_bar(look: Look, saved: RwSignal<SavedLook>) -> impl IntoView {
let save = ServerAction::<UpdateStyle>::new(); let save = ServerAction::<UpdateStyle>::new();
let sent = StoredValue::new((Colors::default(), BannerOptions::default())); let sent = StoredValue::new(SavedLook::default());
let current = move || (look.colors.get(), look.banner.get()); let current = move || (look.colors.get(), look.banner.get(), look.layout.get());
let dirty = move || current() != saved.get(); let dirty = move || current() != saved.get();
Effect::new(move |_| { Effect::new(move |_| {
@@ -440,15 +460,16 @@ fn save_bar(look: Look, saved: RwSignal<(Colors, BannerOptions)>) -> impl IntoVi
view! { view! {
<div> <div>
<button type="button" class="btn btn-primary me-2" on:click=move |_| { <button type="button" class="btn btn-primary me-2" on:click=move |_| {
let (colors, banner) = current(); let (colors, banner, layout) = current();
sent.set_value((colors.clone(), banner.clone())); sent.set_value((colors.clone(), banner.clone(), layout.clone()));
save.dispatch(UpdateStyle { colors, banner }); save.dispatch(UpdateStyle { colors, banner, layout });
}> }>
{trl("Save")} {trl("Save")}
</button> </button>
<button type="button" class="btn btn-outline-secondary" on:click=move |_| { <button type="button" class="btn btn-outline-secondary" on:click=move |_| {
look.colors.set(Colors::default()); look.colors.set(Colors::default());
look.banner.set(BannerOptions::default()); look.banner.set(BannerOptions::default());
look.layout.set(LayoutOptions::default());
}> }>
{trl("Reset to default")} {trl("Reset to default")}
</button> </button>
@@ -493,10 +514,11 @@ pub fn appearance() -> impl IntoView {
let look = Look { let look = Look {
colors: RwSignal::new(Colors::default()), colors: RwSignal::new(Colors::default()),
banner: RwSignal::new(BannerOptions::default()), banner: RwSignal::new(BannerOptions::default()),
layout: RwSignal::new(LayoutOptions::default()),
new_banner: RwSignal::new(None), new_banner: RwSignal::new(None),
new_logo: RwSignal::new(None), new_logo: RwSignal::new(None),
}; };
let saved = RwSignal::new((Colors::default(), BannerOptions::default())); let saved = RwSignal::new(SavedLook::default());
let app_preview = RwSignal::new(Appearance::default()); let app_preview = RwSignal::new(Appearance::default());
let look_loaded = StoredValue::new(false); let look_loaded = StoredValue::new(false);
@@ -507,7 +529,8 @@ pub fn appearance() -> impl IntoView {
if !look_loaded.get_value() { if !look_loaded.get_value() {
look.colors.set(app.colors.clone()); look.colors.set(app.colors.clone());
look.banner.set(app.banner_options.clone()); look.banner.set(app.banner_options.clone());
saved.set((app.colors.clone(), app.banner_options.clone())); look.layout.set(app.layout.clone());
saved.set((app.colors.clone(), app.banner_options.clone(), app.layout.clone()));
look_loaded.set_value(true); look_loaded.set_value(true);
} }
app_preview.set(app); app_preview.set(app);
@@ -576,6 +599,12 @@ pub fn appearance() -> impl IntoView {
delete_banner=del_banner delete_logo=del_logo/> delete_banner=del_banner delete_logo=del_logo/>
</div> </div>
</div> </div>
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">{trl("Layout")}</h5>
<LayoutSettings layout=look.layout/>
</div>
</div>
<div class="card mb-3"> <div class="card mb-3">
<div class="card-body"> <div class="card-body">
<SaveBar look=look saved=saved/> <SaveBar look=look saved=saved/>
+5 -5
View File
@@ -120,10 +120,10 @@ pub fn PublicPage() -> impl IntoView {
<Transition fallback=move || view! {<p>{trl("Loading...")}</p> }> <Transition fallback=move || view! {<p>{trl("Loading...")}</p> }>
{move || { {move || {
appearance.get().map(|a| { appearance.get().map(|a| {
// Default colours when the settings can't be loaded, the form reports the error itself // Default look when the settings can't be loaded, the form reports the error itself
let style = a.map(|a| a.colors).unwrap_or_default().style(); let a = a.unwrap_or_default();
view! { view! {
<div class="public-page" style=style> <div class=format!("public-page {}", a.layout.classes()) style=a.colors.style()>
<Public/> <Public/>
</div> </div>
} }
@@ -193,7 +193,7 @@ pub fn Public() -> impl IntoView {
}} }}
</Transition> </Transition>
<div class="card-body"> <div class="card-body public-form">
<ActionForm <ActionForm
// Capture phase, so the form is validated and the captcha solved before it is dispatched // Capture phase, so the form is validated and the captcha solved before it is dispatched
on:submit:capture=move |ev: SubmitEvent| { on:submit:capture=move |ev: SubmitEvent| {
@@ -213,7 +213,7 @@ pub fn Public() -> impl IntoView {
} }
} }
action=cr_reservation> action=cr_reservation>
<div class="row mb-5"> <div class="row mb-5 public-form-columns">
<div class="col-md"> <div class="col-md">
<div class="card mb-3"> <div class="card mb-3">
<div class="card-body"> <div class="card-body">