From 9da18a67ac88c15a95ffbfc550aaadfdb73d9fd0 Mon Sep 17 00:00:00 2001
From: Josef Rokos
Date: Mon, 12 Oct 2026 21:54:24 +0200
Subject: [PATCH] Added customizable colours of the public page with palettes
and live preview. Fixes #14
Co-Authored-By: Claude Opus 5.5
---
.claude/rules/frontend.md | 3 +-
CLAUDE.md | 1 +
assets/css/public.css | 176 ++++++++++++++++-
migrations/07_appearance_colors.sql | 5 +
src/app.rs | 5 +-
src/backend/appearance.rs | 25 +++
src/backend/color.rs | 292 ++++++++++++++++++++++++++++
src/backend/data.rs | 7 +-
src/backend/mod.rs | 1 +
src/locales/catalogues.rs | 23 +++
src/pages/appearance_settings.rs | 209 +++++++++++++++++++-
src/pages/public.rs | 24 +++
12 files changed, 760 insertions(+), 11 deletions(-)
create mode 100644 migrations/07_appearance_colors.sql
create mode 100644 src/backend/color.rs
diff --git a/.claude/rules/frontend.md b/.claude/rules/frontend.md
index 48048de..4158c2e 100644
--- a/.claude/rules/frontend.md
+++ b/.claude/rules/frontend.md
@@ -48,5 +48,6 @@ paths:
- Custom styles go to a CSS file in `assets/css/` and must be linked with `` 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.
- 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`.
diff --git a/CLAUDE.md b/CLAUDE.md
index 2e06256..521008a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -41,6 +41,7 @@ Unit tests are `#[cfg(test)] mod tests` at the bottom of the file they test (`#[
- `src/app.rs` — ``, 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/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/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`.
diff --git a/assets/css/public.css b/assets/css/public.css
index cce8df9..c6d775d 100644
--- a/assets/css/public.css
+++ b/assets/css/public.css
@@ -1,10 +1,168 @@
/*
* Public booking page.
*
-* Settings from the appearance table come as CSS variables in the style attribute
-* (Appearance::banner_style in src/backend/data.rs), so nothing here is generated.
+* The customer's appearance settings come as CSS variables in style attributes
+* (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 {
height: 100px;
clip-path: inset(0 0 0 0);
@@ -14,7 +172,8 @@
color: white;
}
-.public-banner-title {
+/* Two classes, to win over the heading colour above */
+.public-banner .public-banner-title {
font-size: x-large;
color: white;
text-shadow: -3px -3px 0 #000, 3px -3px 0 #000, -3px 3px 0 #000, 3px 3px 0 #000;
@@ -29,7 +188,16 @@
height: 200px;
}
- .public-banner-title {
+ .public-banner .public-banner-title {
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;
+}
diff --git a/migrations/07_appearance_colors.sql b/migrations/07_appearance_colors.sql
new file mode 100644
index 0000000..20aaa4c
--- /dev/null
+++ b/migrations/07_appearance_colors.sql
@@ -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';
diff --git a/src/app.rs b/src/app.rs
index 15f0d7c..1738cf6 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -15,7 +15,7 @@ use crate::pages::appearance_settings::Appearance;
use crate::pages::customers::Customers;
use crate::pages::login::Login;
use crate::pages::mail_settings::MailSettings;
-use crate::pages::public::Public;
+use crate::pages::public::PublicPage;
#[derive(Clone, Copy, Debug)]
pub struct MenuHelper {
@@ -96,8 +96,7 @@ pub fn App() -> impl IntoView {
- // The class scopes the customer's appearance to the public page (assets/css/public.css)
- }/>
+ }/>
}/>
diff --git a/src/backend/appearance.rs b/src/backend/appearance.rs
index bf291ca..8128144 100644
--- a/src/backend/appearance.rs
+++ b/src/backend/appearance.rs
@@ -1,6 +1,7 @@
use cfg_if::cfg_if;
use leptos::prelude::*;
use validator::Validate;
+use crate::backend::color::Colors;
use crate::backend::data::{ApiResponse, Appearance};
use crate::components::data_form::ForValidation;
@@ -178,6 +179,30 @@ pub async fn update_appearance(appearance: Appearance) -> Result
Ok(ApiResponse::Data(()))
}
+#[server]
+pub async fn update_colors(colors: Colors) -> Result, 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 {
fn entity(&self) -> &dyn Validate {
&self.appearance
diff --git a/src/backend/color.rs b/src/backend/color.rs
new file mode 100644
index 0000000..cc34254
--- /dev/null
+++ b/src/backend/color.rs
@@ -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 {
+ 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 {
+ 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 `` 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)", "", "\"", "#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);
+ }
+}
diff --git a/src/backend/data.rs b/src/backend/data.rs
index d38deeb..60a3d8c 100644
--- a/src/backend/data.rs
+++ b/src/backend/data.rs
@@ -4,6 +4,7 @@ use std::fmt::{Display, Formatter};
use std::str::FromStr;
use chrono::{Local, NaiveDate, NaiveTime, Weekday};
use lazy_static::lazy_static;
+use crate::backend::color::Colors;
use regex::Regex;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
@@ -741,7 +742,11 @@ pub struct Appearance {
id: i32,
pub banner: Option,
pub text: Option,
- pub title: Option
+ pub title: Option,
+ // 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 {
diff --git a/src/backend/mod.rs b/src/backend/mod.rs
index 64f4579..17af413 100644
--- a/src/backend/mod.rs
+++ b/src/backend/mod.rs
@@ -9,6 +9,7 @@ pub mod reservation;
pub mod customer;
pub mod mail;
pub mod appearance;
+pub mod color;
#[cfg(feature = "ssr")]
pub mod health;
diff --git a/src/locales/catalogues.rs b/src/locales/catalogues.rs
index 6e52674..55a493d 100644
--- a/src/locales/catalogues.rs
+++ b/src/locales/catalogues.rs
@@ -148,6 +148,29 @@ lazy_static! {
("Are you sure you want to delete banner?", "Opravdu chcete smazat banner?"),
("Delete banner", "Smazat banner"),
("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"),
("Year bookings", "Rezervace po letech"),
("January", "Leden"),
diff --git a/src/pages/appearance_settings.rs b/src/pages/appearance_settings.rs
index 3565642..b76beac 100644
--- a/src/pages/appearance_settings.rs
+++ b/src/pages/appearance_settings.rs
@@ -1,6 +1,7 @@
use leptos::prelude::*;
-use crate::backend::appearance::{DeleteBanner, get_appearance, UpdateAppearance};
-use crate::backend::data::Appearance;
+use crate::backend::appearance::{DeleteBanner, get_appearance, UpdateAppearance, UpdateColors};
+use crate::backend::color::{Colors, PALETTES};
+use crate::backend::data::{ApiResponse, Appearance};
use crate::components::data_form::{DataForm, QuestionDialog};
use crate::components::modal_box::DialogOpener;
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,
+ get: fn(&Colors) -> String,
+ set: fn(&mut Colors, String)) -> impl IntoView {
+ view! {
+
+
+
+
+ }
+}
+
+/// 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, appearance: ReadSignal) -> impl IntoView {
+ view! {
+
+ }
+}
+
#[component]
pub fn appearance() -> impl IntoView {
let title_edit = DialogOpener::new();
@@ -68,6 +237,24 @@ pub fn appearance() -> impl IntoView {
let del_dialog = DialogOpener::new();
let appearance = Resource::new(move || title_edit.visible() || text_edit.visible() || del_dialog.visible(), |_| get_appearance());
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! {
@@ -125,5 +312,23 @@ pub fn appearance() -> impl IntoView {
+
+
+
+
+
{trl("Preview of the public page")}
+
+
+
+
+
+
+
+
{trl("Colours")}
+
+
+
+
+
}
}
\ No newline at end of file
diff --git a/src/pages/public.rs b/src/pages/public.rs
index e0e059f..9b08748 100644
--- a/src/pages/public.rs
+++ b/src/pages/public.rs
@@ -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! {
+ {trl("Loading...")}
}>
+ {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! {
+