Added customizable colours of the public page with palettes and live preview. Fixes #14
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -48,5 +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 (`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`, `Appearance::banner_style()` in `src/backend/data.rs`) — never generate CSS files. 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.
|
||||||
- Stylesheets and meta tags are declared in `src/components/header.rs`.
|
- Stylesheets and meta tags are declared in `src/components/header.rs`.
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ Unit tests are `#[cfg(test)] mod tests` at the bottom of the file they test (`#[
|
|||||||
- `src/app.rs` — `<App/>`, router, shared contexts (`MenuHelper`, `MenuOpener`, `DialogHelper`).
|
- `src/app.rs` — `<App/>`, router, shared contexts (`MenuHelper`, `MenuOpener`, `DialogHelper`).
|
||||||
- `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/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.
|
||||||
- `deploy/` — running one container per customer on a Docker host: `rezervator.sh` (add, import of an existing installation, upgrade with rollback, backup, restore, remove), `docker-compose.yml` (optional Traefik and PostgreSQL containers), `README.md`. Proxy and PostgreSQL are each either a container or the one on the host (`REZERVATOR_PROXY=traefik|nginx`, `REZERVATOR_POSTGRES=container|host`); a change to the script must work in both. The script relies on the `/health` endpoint, the `REZERVATOR_ADMIN_PASSWORD` variable, the `/app/config` and `/app/data` mounts and the `max_connections` config key — keep them working when changing startup or config code, and test script changes against a real Docker stack with another `REZERVATOR_PREFIX`.
|
- `deploy/` — running one container per customer on a Docker host: `rezervator.sh` (add, import of an existing installation, upgrade with rollback, backup, restore, remove), `docker-compose.yml` (optional Traefik and PostgreSQL containers), `README.md`. Proxy and PostgreSQL are each either a container or the one on the host (`REZERVATOR_PROXY=traefik|nginx`, `REZERVATOR_POSTGRES=container|host`); a change to the script must work in both. The script relies on the `/health` endpoint, the `REZERVATOR_ADMIN_PASSWORD` variable, the `/app/config` and `/app/data` mounts and the `max_connections` config key — keep them working when changing startup or config code, and test script changes against a real Docker stack with another `REZERVATOR_PREFIX`.
|
||||||
|
|||||||
+172
-4
@@ -1,10 +1,168 @@
|
|||||||
/*
|
/*
|
||||||
* Public booking page.
|
* Public booking page.
|
||||||
*
|
*
|
||||||
* Settings from the appearance table come as CSS variables in the style attribute
|
* The customer's appearance settings come as CSS variables in style attributes
|
||||||
* (Appearance::banner_style in src/backend/data.rs), so nothing here is generated.
|
* (Colors::style in src/backend/color.rs, Appearance::banner_style in src/backend/data.rs),
|
||||||
|
* so nothing here is generated. The rules are scoped by .public-page, which wraps the public
|
||||||
|
* route and the preview in the appearance settings; the admin portal keeps its own colours.
|
||||||
|
* They override the colours that the vendor template hard-codes, and they set everything
|
||||||
|
* that assets/css/dark.css sets, so the preview looks the same in both admin themes.
|
||||||
******************************************************************************/
|
******************************************************************************/
|
||||||
|
|
||||||
|
.public-page {
|
||||||
|
--rz-primary: #696cff;
|
||||||
|
--rz-bg: #f5f5f9;
|
||||||
|
--rz-card: #ffffff;
|
||||||
|
--rz-text: #566a7f;
|
||||||
|
--rz-on-primary: #ffffff;
|
||||||
|
--rz-scheme: light;
|
||||||
|
|
||||||
|
/* Shades derived from the settings */
|
||||||
|
--rz-body: color-mix(in srgb, var(--rz-text) 89%, var(--rz-card));
|
||||||
|
--rz-muted: color-mix(in srgb, var(--rz-text) 50%, var(--rz-card));
|
||||||
|
--rz-border: color-mix(in srgb, var(--rz-text) 22%, var(--rz-card));
|
||||||
|
--rz-primary-hover: color-mix(in srgb, var(--rz-primary) 90%, var(--rz-on-primary));
|
||||||
|
--rz-primary-shadow: color-mix(in srgb, var(--rz-primary) 40%, transparent);
|
||||||
|
|
||||||
|
min-height: 100vh;
|
||||||
|
color: var(--rz-body);
|
||||||
|
background-color: var(--rz-bg);
|
||||||
|
color-scheme: var(--rz-scheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preview in the appearance settings */
|
||||||
|
.public-page.public-preview {
|
||||||
|
min-height: 0;
|
||||||
|
border: 1px solid var(--rz-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
|
||||||
|
.public-page h1, .public-page h2, .public-page h3,
|
||||||
|
.public-page h4, .public-page h5, .public-page h6 {
|
||||||
|
color: var(--rz-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-page a {
|
||||||
|
color: var(--rz-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-page a:hover {
|
||||||
|
color: var(--rz-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards and dialogs */
|
||||||
|
|
||||||
|
.public-page .card {
|
||||||
|
background-color: var(--rz-card);
|
||||||
|
border-color: var(--rz-border);
|
||||||
|
box-shadow: 0 2px 6px 0 rgba(67, 89, 113, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-page .modal-content {
|
||||||
|
color: var(--rz-body);
|
||||||
|
background-color: var(--rz-card);
|
||||||
|
box-shadow: 0 2px 20px 0 rgba(67, 89, 113, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
|
||||||
|
.public-page .form-label,
|
||||||
|
.public-page .form-check-label {
|
||||||
|
color: var(--rz-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-page .form-control {
|
||||||
|
color: var(--rz-body);
|
||||||
|
background-color: var(--rz-card);
|
||||||
|
border-color: var(--rz-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-page .form-control::placeholder {
|
||||||
|
color: var(--rz-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-page .form-control:focus {
|
||||||
|
color: var(--rz-body);
|
||||||
|
background-color: var(--rz-card);
|
||||||
|
border-color: var(--rz-primary);
|
||||||
|
box-shadow: 0 0 0.25rem 0.05rem color-mix(in srgb, var(--rz-primary) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-page .form-check-input:not(:checked) {
|
||||||
|
background-color: var(--rz-card);
|
||||||
|
border-color: var(--rz-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-page .form-check-input:focus {
|
||||||
|
border-color: var(--rz-primary);
|
||||||
|
box-shadow: 0 2px 4px 0 var(--rz-primary-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-page .form-check-input:checked {
|
||||||
|
background-color: var(--rz-primary);
|
||||||
|
border-color: var(--rz-primary);
|
||||||
|
box-shadow: 0 2px 4px 0 var(--rz-primary-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
|
||||||
|
.public-page .btn-primary {
|
||||||
|
color: var(--rz-on-primary);
|
||||||
|
background-color: var(--rz-primary);
|
||||||
|
border-color: var(--rz-primary);
|
||||||
|
box-shadow: 0 0.125rem 0.25rem 0 var(--rz-primary-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-page .btn-primary:hover,
|
||||||
|
.public-page .btn-primary:focus {
|
||||||
|
color: var(--rz-on-primary);
|
||||||
|
background-color: var(--rz-primary-hover);
|
||||||
|
border-color: var(--rz-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-page .btn-outline-secondary {
|
||||||
|
color: var(--rz-body);
|
||||||
|
border-color: var(--rz-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-page .btn-outline-secondary:hover,
|
||||||
|
.public-page .btn-outline-secondary:focus {
|
||||||
|
color: var(--rz-card);
|
||||||
|
background-color: var(--rz-body);
|
||||||
|
border-color: var(--rz-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Time slots: a hidden checkbox followed by its label */
|
||||||
|
|
||||||
|
.public-page .btn-outline-primary {
|
||||||
|
color: var(--rz-primary);
|
||||||
|
border-color: var(--rz-primary);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-page .btn-outline-primary:hover,
|
||||||
|
.public-page .btn-check:checked + .btn-outline-primary {
|
||||||
|
color: var(--rz-on-primary);
|
||||||
|
background-color: var(--rz-primary);
|
||||||
|
border-color: var(--rz-primary);
|
||||||
|
box-shadow: 0 0.125rem 0.25rem 0 var(--rz-primary-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-page .btn-check:focus + .btn-outline-primary {
|
||||||
|
border-color: var(--rz-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Captcha */
|
||||||
|
|
||||||
|
.public-page .leptos-captcha {
|
||||||
|
color: var(--rz-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Banner */
|
||||||
|
|
||||||
.public-banner {
|
.public-banner {
|
||||||
height: 100px;
|
height: 100px;
|
||||||
clip-path: inset(0 0 0 0);
|
clip-path: inset(0 0 0 0);
|
||||||
@@ -14,7 +172,8 @@
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.public-banner-title {
|
/* Two classes, to win over the heading colour above */
|
||||||
|
.public-banner .public-banner-title {
|
||||||
font-size: x-large;
|
font-size: x-large;
|
||||||
color: white;
|
color: white;
|
||||||
text-shadow: -3px -3px 0 #000, 3px -3px 0 #000, -3px 3px 0 #000, 3px 3px 0 #000;
|
text-shadow: -3px -3px 0 #000, 3px -3px 0 #000, -3px 3px 0 #000, 3px 3px 0 #000;
|
||||||
@@ -29,7 +188,16 @@
|
|||||||
height: 200px;
|
height: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.public-banner-title {
|
.public-banner .public-banner-title {
|
||||||
font-size: xxx-large;
|
font-size: xxx-large;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The preview is narrow whatever the width of the window */
|
||||||
|
.public-preview .public-banner {
|
||||||
|
height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-preview .public-banner .public-banner-title {
|
||||||
|
font-size: x-large;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Colours of the public page, defaults are the colours of the original template
|
||||||
|
ALTER TABLE appearance ADD color_primary VARCHAR(7) NOT NULL DEFAULT '#696cff';
|
||||||
|
ALTER TABLE appearance ADD color_background VARCHAR(7) NOT NULL DEFAULT '#f5f5f9';
|
||||||
|
ALTER TABLE appearance ADD color_card VARCHAR(7) NOT NULL DEFAULT '#ffffff';
|
||||||
|
ALTER TABLE appearance ADD color_text VARCHAR(7) NOT NULL DEFAULT '#566a7f';
|
||||||
+2
-3
@@ -15,7 +15,7 @@ use crate::pages::appearance_settings::Appearance;
|
|||||||
use crate::pages::customers::Customers;
|
use crate::pages::customers::Customers;
|
||||||
use crate::pages::login::Login;
|
use crate::pages::login::Login;
|
||||||
use crate::pages::mail_settings::MailSettings;
|
use crate::pages::mail_settings::MailSettings;
|
||||||
use crate::pages::public::Public;
|
use crate::pages::public::PublicPage;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
pub struct MenuHelper {
|
pub struct MenuHelper {
|
||||||
@@ -96,8 +96,7 @@ pub fn App() -> impl IntoView {
|
|||||||
<Router>
|
<Router>
|
||||||
<main>
|
<main>
|
||||||
<Routes fallback=|| "Page not found.">
|
<Routes fallback=|| "Page not found.">
|
||||||
// The class scopes the customer's appearance to the public page (assets/css/public.css)
|
<Route path=path!("") view=|| view! { <PublicPage/> }/>
|
||||||
<Route path=path!("") view=|| view! { <div class="public-page"><Public/></div> }/>
|
|
||||||
<Route path=path!("login") view=|| view! { <Login/> }/>
|
<Route path=path!("login") view=|| view! { <Login/> }/>
|
||||||
<Route path=path!("admin") view=|| view! {
|
<Route path=path!("admin") view=|| view! {
|
||||||
<AdminPortal>
|
<AdminPortal>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use cfg_if::cfg_if;
|
use cfg_if::cfg_if;
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use validator::Validate;
|
use validator::Validate;
|
||||||
|
use crate::backend::color::Colors;
|
||||||
use crate::backend::data::{ApiResponse, Appearance};
|
use crate::backend::data::{ApiResponse, Appearance};
|
||||||
use crate::components::data_form::ForValidation;
|
use crate::components::data_form::ForValidation;
|
||||||
|
|
||||||
@@ -178,6 +179,30 @@ pub async fn update_appearance(appearance: Appearance) -> Result<ApiResponse<()>
|
|||||||
Ok(ApiResponse::Data(()))
|
Ok(ApiResponse::Data(()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn update_colors(colors: Colors) -> Result<ApiResponse<()>, ServerFnError> {
|
||||||
|
use crate::perm_check;
|
||||||
|
|
||||||
|
perm_check!(is_admin);
|
||||||
|
|
||||||
|
// The colours end up in a style attribute of the public page
|
||||||
|
if colors.validate().is_err() {
|
||||||
|
return Ok(ApiResponse::Error("Invalid colour".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
let pool = get_pool().await?;
|
||||||
|
let colors = colors.sanitized();
|
||||||
|
query("UPDATE appearance SET color_primary = $1, color_background = $2, color_card = $3, color_text = $4")
|
||||||
|
.bind(colors.primary)
|
||||||
|
.bind(colors.background)
|
||||||
|
.bind(colors.card)
|
||||||
|
.bind(colors.text)
|
||||||
|
.execute(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(ApiResponse::Data(()))
|
||||||
|
}
|
||||||
|
|
||||||
impl ForValidation for UpdateAppearance {
|
impl ForValidation for UpdateAppearance {
|
||||||
fn entity(&self) -> &dyn Validate {
|
fn entity(&self) -> &dyn Validate {
|
||||||
&self.appearance
|
&self.appearance
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use validator::{Validate, ValidationError};
|
||||||
|
|
||||||
|
/// Text is considered readable from this contrast ratio up (WCAG AA for normal text)
|
||||||
|
pub const MIN_TEXT_CONTRAST: f64 = 4.5;
|
||||||
|
/// Buttons and time slots drawn in the primary colour should have at least this contrast (WCAG AA for UI components)
|
||||||
|
pub const MIN_PRIMARY_CONTRAST: f64 = 3.0;
|
||||||
|
|
||||||
|
const DEFAULT_PRIMARY: &str = "#696cff";
|
||||||
|
const DEFAULT_BACKGROUND: &str = "#f5f5f9";
|
||||||
|
const DEFAULT_CARD: &str = "#ffffff";
|
||||||
|
const DEFAULT_TEXT: &str = "#566a7f";
|
||||||
|
|
||||||
|
/// Ready-made colour sets offered in the appearance settings: name, primary, background, card, text.
|
||||||
|
/// The name is a translation key.
|
||||||
|
pub const PALETTES: [(&str, &str, &str, &str, &str); 8] = [
|
||||||
|
("Violet", DEFAULT_PRIMARY, DEFAULT_BACKGROUND, DEFAULT_CARD, DEFAULT_TEXT),
|
||||||
|
("Green", "#1a8f6a", "#f1f7f4", "#ffffff", "#33443d"),
|
||||||
|
("Blue", "#1b66b1", "#eef3f8", "#ffffff", "#2c3e50"),
|
||||||
|
("Orange", "#c9501f", "#faf3ef", "#ffffff", "#4a3a33"),
|
||||||
|
("Pink", "#c2416f", "#fbf2f5", "#ffffff", "#4b3640"),
|
||||||
|
("Sand", "#8a6a2f", "#f4efe6", "#fffdf8", "#4a4235"),
|
||||||
|
("Graphite", "#ef9f27", "#1f2128", "#2b2e38", "#d8dae3"),
|
||||||
|
("Night", "#6ea8fe", "#0f1b2d", "#182a44", "#d3dcea"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Colours of the public booking page, 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 Colors {
|
||||||
|
/// Buttons, selected time slots, links
|
||||||
|
#[cfg_attr(feature = "ssr", sqlx(rename = "color_primary"))]
|
||||||
|
#[validate(custom(function = "validate_color", message = "Invalid colour"))]
|
||||||
|
pub primary: String,
|
||||||
|
/// Page background
|
||||||
|
#[cfg_attr(feature = "ssr", sqlx(rename = "color_background"))]
|
||||||
|
#[validate(custom(function = "validate_color", message = "Invalid colour"))]
|
||||||
|
pub background: String,
|
||||||
|
/// Background of cards, inputs and dialogs
|
||||||
|
#[cfg_attr(feature = "ssr", sqlx(rename = "color_card"))]
|
||||||
|
#[validate(custom(function = "validate_color", message = "Invalid colour"))]
|
||||||
|
pub card: String,
|
||||||
|
/// Headings and labels; body text is a slightly softer shade derived in CSS
|
||||||
|
#[cfg_attr(feature = "ssr", sqlx(rename = "color_text"))]
|
||||||
|
#[validate(custom(function = "validate_color", message = "Invalid colour"))]
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Colors {
|
||||||
|
fn default() -> Self {
|
||||||
|
Colors::new(DEFAULT_PRIMARY, DEFAULT_BACKGROUND, DEFAULT_CARD, DEFAULT_TEXT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What is hard to see with a set of colours
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum ContrastIssue {
|
||||||
|
TextOnCard,
|
||||||
|
TextOnBackground,
|
||||||
|
PrimaryOnCard,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ContrastIssue {
|
||||||
|
/// Translation key of the warning
|
||||||
|
pub fn message(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ContrastIssue::TextOnCard => "Text is hard to read on the card background",
|
||||||
|
ContrastIssue::TextOnBackground => "Text is hard to read on the page background",
|
||||||
|
ContrastIssue::PrimaryOnCard => "Primary colour is hard to see on the card background",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Colors {
|
||||||
|
pub fn new(primary: &str, background: &str, card: &str, text: &str) -> Self {
|
||||||
|
Colors {
|
||||||
|
primary: primary.to_string(),
|
||||||
|
background: background.to_string(),
|
||||||
|
card: card.to_string(),
|
||||||
|
text: text.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn palette(index: usize) -> Option<Self> {
|
||||||
|
PALETTES.get(index).map(|(_, primary, background, card, text)| Colors::new(primary, background, card, text))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces values that are not colours with the default ones, so the result is safe to put into a style
|
||||||
|
pub fn sanitized(&self) -> Self {
|
||||||
|
let pick = |value: &str, default: &str| {
|
||||||
|
if is_color(value) { value.to_lowercase() } else { default.to_string() }
|
||||||
|
};
|
||||||
|
|
||||||
|
Colors {
|
||||||
|
primary: pick(&self.primary, DEFAULT_PRIMARY),
|
||||||
|
background: pick(&self.background, DEFAULT_BACKGROUND),
|
||||||
|
card: pick(&self.card, DEFAULT_CARD),
|
||||||
|
text: pick(&self.text, DEFAULT_TEXT),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CSS variables for the wrapper of the public page, used as its `style` attribute.
|
||||||
|
/// The styles themselves are in `assets/css/public.css`.
|
||||||
|
pub fn style(&self) -> String {
|
||||||
|
let colors = self.sanitized();
|
||||||
|
|
||||||
|
format!("--rz-primary: {}; --rz-bg: {}; --rz-card: {}; --rz-text: {}; --rz-on-primary: {}; --rz-scheme: {}",
|
||||||
|
colors.primary,
|
||||||
|
colors.background,
|
||||||
|
colors.card,
|
||||||
|
colors.text,
|
||||||
|
text_color_on(&colors.primary),
|
||||||
|
if is_dark(&colors.card) { "dark" } else { "light" })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn contrast_issues(&self) -> Vec<ContrastIssue> {
|
||||||
|
let colors = self.sanitized();
|
||||||
|
let mut issues = vec![];
|
||||||
|
|
||||||
|
if contrast(&colors.text, &colors.card) < MIN_TEXT_CONTRAST {
|
||||||
|
issues.push(ContrastIssue::TextOnCard);
|
||||||
|
}
|
||||||
|
if contrast(&colors.text, &colors.background) < MIN_TEXT_CONTRAST {
|
||||||
|
issues.push(ContrastIssue::TextOnBackground);
|
||||||
|
}
|
||||||
|
if contrast(&colors.primary, &colors.card) < MIN_PRIMARY_CONTRAST {
|
||||||
|
issues.push(ContrastIssue::PrimaryOnCard);
|
||||||
|
}
|
||||||
|
|
||||||
|
issues
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_color(color: &str) -> Result<(), ValidationError> {
|
||||||
|
if is_color(color) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(ValidationError::new("invalid color"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only the `#rrggbb` form is a colour here, it is what `<input type="color">` produces
|
||||||
|
pub fn is_color(color: &str) -> bool {
|
||||||
|
parse_color(color).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_color(color: &str) -> Option<(u8, u8, u8)> {
|
||||||
|
let hex = color.strip_prefix('#')?;
|
||||||
|
|
||||||
|
if hex.len() != 6 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||||
|
return None
|
||||||
|
}
|
||||||
|
|
||||||
|
let channel = |from: usize| u8::from_str_radix(&hex[from..from + 2], 16).ok();
|
||||||
|
Some((channel(0)?, channel(2)?, channel(4)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Relative luminance by WCAG 2: 0 for black, 1 for white. An invalid colour counts as black.
|
||||||
|
pub fn luminance(color: &str) -> f64 {
|
||||||
|
let (r, g, b) = parse_color(color).unwrap_or((0, 0, 0));
|
||||||
|
let linear = |channel: u8| {
|
||||||
|
let c = channel as f64 / 255.0;
|
||||||
|
if c <= 0.03928 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
|
||||||
|
};
|
||||||
|
|
||||||
|
0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Contrast ratio by WCAG 2, from 1 (same colours) to 21 (black and white)
|
||||||
|
pub fn contrast(first: &str, second: &str) -> f64 {
|
||||||
|
let (first, second) = (luminance(first), luminance(second));
|
||||||
|
|
||||||
|
(first.max(second) + 0.05) / (first.min(second) + 0.05)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text colour for a button of the given colour. White is preferred while it is readable enough:
|
||||||
|
/// on saturated mid-tones (like the default violet) the WCAG formula favours black, which looks worse.
|
||||||
|
pub fn text_color_on(color: &str) -> &'static str {
|
||||||
|
if contrast(color, "#ffffff") >= MIN_PRIMARY_CONTRAST { "#ffffff" } else { "#000000" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closer to black than to white by contrast
|
||||||
|
fn is_dark(color: &str) -> bool {
|
||||||
|
contrast(color, "#ffffff") > contrast(color, "#000000")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_color_accepts_only_six_digit_hex() {
|
||||||
|
for color in ["#000000", "#ffffff", "#696cff", "#ABCDEF", "#aBc123"] {
|
||||||
|
assert!(is_color(color), "color: {:?}", color);
|
||||||
|
}
|
||||||
|
|
||||||
|
for color in ["", "#", "#fff", "#fffff", "#fffffff", "696cff", "#gggggg", "red", "rgb(0,0,0)", " #ffffff",
|
||||||
|
"#ffffff ", "#fff;ff", "#ffffff; color: red", "#ééé", "#ffffé"] {
|
||||||
|
assert!(!is_color(color), "color: {:?}", color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn luminance_of_black_and_white() {
|
||||||
|
assert_eq!(luminance("#000000"), 0.0);
|
||||||
|
assert!((luminance("#ffffff") - 1.0).abs() < 1e-9);
|
||||||
|
assert_eq!(luminance("nonsense"), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contrast_is_symmetric_and_in_range() {
|
||||||
|
assert!((contrast("#000000", "#ffffff") - 21.0).abs() < 1e-9);
|
||||||
|
assert!((contrast("#ffffff", "#000000") - 21.0).abs() < 1e-9);
|
||||||
|
assert!((contrast("#696cff", "#696cff") - 1.0).abs() < 1e-9);
|
||||||
|
// Reference value of a common pair
|
||||||
|
assert!((contrast("#777777", "#ffffff") - 4.48).abs() < 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_color_on_picks_readable_one() {
|
||||||
|
assert_eq!(text_color_on("#000000"), "#ffffff");
|
||||||
|
assert_eq!(text_color_on("#1b66b1"), "#ffffff");
|
||||||
|
// White stays on the default violet, although black has a slightly higher contrast there
|
||||||
|
assert_eq!(text_color_on("#696cff"), "#ffffff");
|
||||||
|
assert_eq!(text_color_on("#ffffff"), "#000000");
|
||||||
|
assert_eq!(text_color_on("#ef9f27"), "#000000");
|
||||||
|
assert_eq!(text_color_on("#ffff00"), "#000000");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_colors_are_valid() {
|
||||||
|
let colors = Colors::default();
|
||||||
|
assert!(colors.validate().is_ok());
|
||||||
|
assert_eq!(colors, colors.sanitized());
|
||||||
|
assert_eq!(Colors::palette(0), Some(colors));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validation_rejects_other_values() {
|
||||||
|
let mut colors = Colors::default();
|
||||||
|
colors.card = "white".to_string();
|
||||||
|
assert!(colors.validate().is_err());
|
||||||
|
|
||||||
|
let mut colors = Colors::default();
|
||||||
|
colors.primary = "#fff; background: url(x)".to_string();
|
||||||
|
assert!(colors.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitized_replaces_invalid_values() {
|
||||||
|
let colors = Colors::new("#ABCDEF", "", "red; --x: 1", "#12345").sanitized();
|
||||||
|
assert_eq!(colors, Colors::new("#abcdef", DEFAULT_BACKGROUND, DEFAULT_CARD, DEFAULT_TEXT));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn style_of_default_colors() {
|
||||||
|
assert_eq!(Colors::default().style(),
|
||||||
|
"--rz-primary: #696cff; --rz-bg: #f5f5f9; --rz-card: #ffffff; --rz-text: #566a7f; --rz-on-primary: #ffffff; --rz-scheme: light");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn style_of_dark_colors() {
|
||||||
|
assert_eq!(Colors::new("#ef9f27", "#1f2128", "#2b2e38", "#d8dae3").style(),
|
||||||
|
"--rz-primary: #ef9f27; --rz-bg: #1f2128; --rz-card: #2b2e38; --rz-text: #d8dae3; --rz-on-primary: #000000; --rz-scheme: dark");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn style_never_contains_invalid_values() {
|
||||||
|
let style = Colors::new("red; background: url(x)", "</style>", "\"", "#fff").style();
|
||||||
|
assert_eq!(style, Colors::default().style());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contrast_issues_are_reported() {
|
||||||
|
assert_eq!(Colors::default().contrast_issues(), vec![]);
|
||||||
|
assert_eq!(Colors::new("#696cff", "#f5f5f9", "#ffffff", "#bbbbbb").contrast_issues(),
|
||||||
|
vec![ContrastIssue::TextOnCard, ContrastIssue::TextOnBackground]);
|
||||||
|
assert_eq!(Colors::new("#f0f0f0", "#333333", "#ffffff", "#444444").contrast_issues(),
|
||||||
|
vec![ContrastIssue::TextOnBackground, ContrastIssue::PrimaryOnCard]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn palettes_are_valid_and_readable() {
|
||||||
|
for (index, palette) in PALETTES.iter().enumerate() {
|
||||||
|
let colors = Colors::palette(index).unwrap();
|
||||||
|
assert!(colors.validate().is_ok(), "palette: {}", palette.0);
|
||||||
|
assert_eq!(colors.contrast_issues(), vec![], "palette: {}", palette.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(Colors::palette(PALETTES.len()), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-1
@@ -4,6 +4,7 @@ use std::fmt::{Display, Formatter};
|
|||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use chrono::{Local, NaiveDate, NaiveTime, Weekday};
|
use chrono::{Local, NaiveDate, NaiveTime, Weekday};
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
|
use crate::backend::color::Colors;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -741,7 +742,11 @@ pub struct Appearance {
|
|||||||
id: i32,
|
id: i32,
|
||||||
pub banner: Option<String>,
|
pub banner: Option<String>,
|
||||||
pub text: Option<String>,
|
pub text: Option<String>,
|
||||||
pub title: Option<String>
|
pub title: Option<String>,
|
||||||
|
// Forms that edit the title or the text don't send the colours
|
||||||
|
#[serde(default)]
|
||||||
|
#[cfg_attr(feature = "ssr", sqlx(flatten))]
|
||||||
|
pub colors: Colors
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Appearance {
|
impl Appearance {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ pub mod reservation;
|
|||||||
pub mod customer;
|
pub mod customer;
|
||||||
pub mod mail;
|
pub mod mail;
|
||||||
pub mod appearance;
|
pub mod appearance;
|
||||||
|
pub mod color;
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub mod health;
|
pub mod health;
|
||||||
|
|
||||||
|
|||||||
@@ -148,6 +148,29 @@ lazy_static! {
|
|||||||
("Are you sure you want to delete banner?", "Opravdu chcete smazat banner?"),
|
("Are you sure you want to delete banner?", "Opravdu chcete smazat banner?"),
|
||||||
("Delete banner", "Smazat banner"),
|
("Delete banner", "Smazat banner"),
|
||||||
("Appearance", "Vzhled"),
|
("Appearance", "Vzhled"),
|
||||||
|
("Colours", "Barvy"),
|
||||||
|
("Palettes", "Palety"),
|
||||||
|
("Violet", "Fialová"),
|
||||||
|
("Green", "Zelená"),
|
||||||
|
("Blue", "Modrá"),
|
||||||
|
("Orange", "Oranžová"),
|
||||||
|
("Pink", "Růžová"),
|
||||||
|
("Sand", "Písková"),
|
||||||
|
("Graphite", "Grafitová"),
|
||||||
|
("Night", "Noční"),
|
||||||
|
("Primary colour", "Hlavní barva"),
|
||||||
|
("Page background", "Pozadí stránky"),
|
||||||
|
("Card background", "Pozadí karet"),
|
||||||
|
("Text colour", "Barva textu"),
|
||||||
|
("Save", "Uložit"),
|
||||||
|
("Reset to default", "Obnovit výchozí"),
|
||||||
|
("Colours saved", "Barvy jsou uloženy"),
|
||||||
|
("Unsaved changes", "Neuložené změny"),
|
||||||
|
("Invalid colour", "Neplatná barva"),
|
||||||
|
("Text is hard to read on the card background", "Text je na pozadí karet špatně čitelný"),
|
||||||
|
("Text is hard to read on the page background", "Text je na pozadí stránky špatně čitelný"),
|
||||||
|
("Primary colour is hard to see on the card background", "Hlavní barva je na pozadí karet špatně vidět"),
|
||||||
|
("Preview of the public page", "Náhled veřejné stránky"),
|
||||||
("Month bookings", "Rezrvace po měsících"),
|
("Month bookings", "Rezrvace po měsících"),
|
||||||
("Year bookings", "Rezervace po letech"),
|
("Year bookings", "Rezervace po letech"),
|
||||||
("January", "Leden"),
|
("January", "Leden"),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use crate::backend::appearance::{DeleteBanner, get_appearance, UpdateAppearance};
|
use crate::backend::appearance::{DeleteBanner, get_appearance, UpdateAppearance, UpdateColors};
|
||||||
use crate::backend::data::Appearance;
|
use crate::backend::color::{Colors, PALETTES};
|
||||||
|
use crate::backend::data::{ApiResponse, Appearance};
|
||||||
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;
|
||||||
@@ -61,6 +62,174 @@ fn delete_banner(opener: DialogOpener) -> impl IntoView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn color_input(
|
||||||
|
id: &'static str,
|
||||||
|
label: &'static str,
|
||||||
|
colors: RwSignal<Colors>,
|
||||||
|
get: fn(&Colors) -> String,
|
||||||
|
set: fn(&mut Colors, String)) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||||
|
<label for=id class="form-label mb-0">{trl(label)}</label>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
id=id
|
||||||
|
class="form-control form-control-color"
|
||||||
|
prop:value=move || get(&colors.get())
|
||||||
|
on:input=move |ev| colors.update(|c| set(c, event_target_value(&ev)))
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sample of the public page in the colours being edited. It uses the same classes and the same
|
||||||
|
/// stylesheet (assets/css/public.css) as the page itself.
|
||||||
|
#[component]
|
||||||
|
fn preview(colors: RwSignal<Colors>, appearance: ReadSignal<Appearance>) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<div class="public-page public-preview" style=move || colors.get().style()>
|
||||||
|
{move || {
|
||||||
|
let app = appearance.get();
|
||||||
|
app.banner.is_some().then(|| view! {
|
||||||
|
<div class="public-banner" style=app.banner_style()>
|
||||||
|
<h1 class="public-banner-title">{app.title.clone().unwrap_or_default()}</h1>
|
||||||
|
</div>
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
<div class="public-banner-text" inner_html=move || appearance.get().text.unwrap_or_default()></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm">
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title"><i class="bx bx-basket"></i>" "{trl("Booking")}</h5>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="preview_date" class="form-label">{trl("Date")}</label>
|
||||||
|
<input type="date" id="preview_date" class="form-control"/>
|
||||||
|
</div>
|
||||||
|
<div class="form-label">{trl("Time slot")}</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<input type="checkbox" class="btn-check" id="preview_slot1" autocomplete="off"/>
|
||||||
|
<label class="btn btn-outline-primary" for="preview_slot1">"17:00"</label>
|
||||||
|
<input type="checkbox" class="btn-check" id="preview_slot2" autocomplete="off" checked=true/>
|
||||||
|
<label class="btn btn-outline-primary" for="preview_slot2">"18:00"</label>
|
||||||
|
<input type="checkbox" class="btn-check" id="preview_slot3" autocomplete="off" disabled=true/>
|
||||||
|
<label class="btn btn-outline-primary" for="preview_slot3">"19:00"</label>
|
||||||
|
<input type="checkbox" class="btn-check" id="preview_slot4" autocomplete="off"/>
|
||||||
|
<label class="btn btn-outline-primary" for="preview_slot4">"20:00"</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-label">{trl("Total price of booking")}</div>
|
||||||
|
<div>"250 Kč"</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm">
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title"><i class="bx bxs-contact"></i>" "{trl("Who is booking")}</h5>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="preview_name" class="form-label">{trl("Full name")}</label>
|
||||||
|
<input type="text" id="preview_name" class="form-control" placeholder=trl("Enter full name")/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<input type="checkbox" id="preview_remember" class="form-check-input" checked=true/>
|
||||||
|
" "
|
||||||
|
<label for="preview_remember" class="form-label">{trl("Remember for next time")}</label>
|
||||||
|
</div>
|
||||||
|
<div class="text-end">
|
||||||
|
<button type="button" class="btn btn-primary">{trl("Book")}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn color_settings(colors: RwSignal<Colors>, saved: RwSignal<Colors>) -> impl IntoView {
|
||||||
|
let save = ServerAction::<UpdateColors>::new();
|
||||||
|
let sent = StoredValue::new(Colors::default());
|
||||||
|
let dirty = move || colors.get() != saved.get();
|
||||||
|
|
||||||
|
Effect::new(move |_| {
|
||||||
|
if let Some(Ok(ApiResponse::Data(_))) = save.value().get() {
|
||||||
|
saved.set(sent.get_value());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="form-label">{trl("Palettes")}</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||||
|
{PALETTES.iter().enumerate().map(|(index, (name, primary, background, _, text))| view! {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn p-0 border"
|
||||||
|
style="width: 3rem; height: 2rem; overflow: hidden; display: inline-flex"
|
||||||
|
title=trl(name)
|
||||||
|
aria-label=trl(name)
|
||||||
|
on:click=move |_| {
|
||||||
|
if let Some(palette) = Colors::palette(index) {
|
||||||
|
colors.set(palette);
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
<span style=format!("flex: 1; height: 100%; background: {}", primary)></span>
|
||||||
|
<span style=format!("flex: 1; height: 100%; background: {}", background)></span>
|
||||||
|
<span style=format!("flex: 1; height: 100%; background: {}", text)></span>
|
||||||
|
</button>
|
||||||
|
}).collect_view()}
|
||||||
|
</div>
|
||||||
|
<ColorInput id="color_primary" label="Primary colour" colors=colors
|
||||||
|
get=|c| c.primary.clone() set=|c, v| c.primary = v/>
|
||||||
|
<ColorInput id="color_background" label="Page background" colors=colors
|
||||||
|
get=|c| c.background.clone() set=|c, v| c.background = v/>
|
||||||
|
<ColorInput id="color_card" label="Card background" colors=colors
|
||||||
|
get=|c| c.card.clone() set=|c, v| c.card = v/>
|
||||||
|
<ColorInput id="color_text" label="Text colour" colors=colors
|
||||||
|
get=|c| c.text.clone() set=|c, v| c.text = v/>
|
||||||
|
{move || colors.get().contrast_issues().into_iter().map(|issue| view! {
|
||||||
|
<div class="text-warning mb-1"><i class="bx bx-error"></i>" "{trl(issue.message())}</div>
|
||||||
|
}).collect_view()}
|
||||||
|
<div class="mt-3">
|
||||||
|
<button type="button" class="btn btn-primary me-2" on:click=move |_| {
|
||||||
|
sent.set_value(colors.get());
|
||||||
|
save.dispatch(UpdateColors { colors: colors.get() });
|
||||||
|
}>
|
||||||
|
{trl("Save")}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" on:click=move |_| colors.set(Colors::default())>
|
||||||
|
{trl("Reset to default")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
{move || {
|
||||||
|
if dirty() {
|
||||||
|
return view! {<span class="text-muted">{trl("Unsaved changes")}</span>}.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
|
match save.value().get() {
|
||||||
|
Some(Ok(ApiResponse::Data(_))) => view! {
|
||||||
|
<span class="text-success">{trl("Colours saved")}</span>
|
||||||
|
}.into_any(),
|
||||||
|
_ => view! {<span></span>}.into_any()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
{move || match save.value().get() {
|
||||||
|
Some(Ok(ApiResponse::Error(e))) => view! {
|
||||||
|
<div class="alert alert-danger mt-2">{trl(&e)}</div>
|
||||||
|
}.into_any(),
|
||||||
|
Some(Err(e)) => view! {
|
||||||
|
<div class="alert alert-danger mt-2">"Server error: " {e.to_string()}</div>
|
||||||
|
}.into_any(),
|
||||||
|
_ => view! {<span></span>}.into_any()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn appearance() -> impl IntoView {
|
pub fn appearance() -> impl IntoView {
|
||||||
let title_edit = DialogOpener::new();
|
let title_edit = DialogOpener::new();
|
||||||
@@ -68,6 +237,24 @@ pub fn appearance() -> impl IntoView {
|
|||||||
let del_dialog = DialogOpener::new();
|
let del_dialog = DialogOpener::new();
|
||||||
let appearance = Resource::new(move || title_edit.visible() || text_edit.visible() || del_dialog.visible(), |_| get_appearance());
|
let appearance = Resource::new(move || title_edit.visible() || text_edit.visible() || del_dialog.visible(), |_| get_appearance());
|
||||||
let app_edit = RwSignal::new(Appearance::default());
|
let app_edit = RwSignal::new(Appearance::default());
|
||||||
|
let colors = RwSignal::new(Colors::default());
|
||||||
|
let saved_colors = RwSignal::new(Colors::default());
|
||||||
|
let app_preview = RwSignal::new(Appearance::default());
|
||||||
|
let colors_loaded = StoredValue::new(false);
|
||||||
|
|
||||||
|
// Runs in the browser only, so the server and the browser render the same markup first.
|
||||||
|
// The data are loaded again after every dialog; that must not throw away colours being edited.
|
||||||
|
Effect::new(move |_| {
|
||||||
|
if let Some(Ok(app)) = appearance.get() {
|
||||||
|
if !colors_loaded.get_value() {
|
||||||
|
colors.set(app.colors.clone());
|
||||||
|
saved_colors.set(app.colors.clone());
|
||||||
|
colors_loaded.set_value(true);
|
||||||
|
}
|
||||||
|
app_preview.set(app);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<EditTitle opener=title_edit appearance=app_edit.read_only()/>
|
<EditTitle opener=title_edit appearance=app_edit.read_only()/>
|
||||||
<EditText opener=text_edit appearance=app_edit.read_only()/>
|
<EditText opener=text_edit appearance=app_edit.read_only()/>
|
||||||
@@ -125,5 +312,23 @@ pub fn appearance() -> impl IntoView {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-lg-8 order-lg-2 mb-3">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">{trl("Preview of the public page")}</h5>
|
||||||
|
<Preview colors=colors appearance=app_preview.read_only()/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4 order-lg-1 mb-3">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">{trl("Colours")}</h5>
|
||||||
|
<ColorSettings colors=colors saved=saved_colors/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,6 +109,30 @@ fn time_selector(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The public route: the booking form in the colours the customer has set.
|
||||||
|
/// Styles in `assets/css/public.css` are scoped by the wrapper, so the form embedded
|
||||||
|
/// in the admin portal is not affected.
|
||||||
|
#[component]
|
||||||
|
pub fn PublicPage() -> impl IntoView {
|
||||||
|
let appearance = Resource::new_blocking(||(), move |_| get_appearance());
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Transition fallback=move || view! {<p>{trl("Loading...")}</p> }>
|
||||||
|
{move || {
|
||||||
|
appearance.get().map(|a| {
|
||||||
|
// Default colours when the settings can't be loaded, the form reports the error itself
|
||||||
|
let style = a.map(|a| a.colors).unwrap_or_default().style();
|
||||||
|
view! {
|
||||||
|
<div class="public-page" style=style>
|
||||||
|
<Public/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</Transition>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Public() -> impl IntoView {
|
pub fn Public() -> impl IntoView {
|
||||||
let day = RwSignal::new(NaiveDate::default());
|
let day = RwSignal::new(NaiveDate::default());
|
||||||
|
|||||||
Reference in New Issue
Block a user