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:
2026-10-12 21:54:24 +02:00
co-authored by Claude Opus 5.5
parent 1a1688d322
commit 9da18a67ac
12 changed files with 760 additions and 11 deletions
+207 -2
View File
@@ -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<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]
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! {
<EditTitle opener=title_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 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>
}
}
+24
View File
@@ -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]
pub fn Public() -> impl IntoView {
let day = RwSignal::new(NaiveDate::default());