Added banner options, logo and image upload with validation to appearance settings. Fixes #15

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 22:14:07 +02:00
co-authored by Claude Opus 5.5
parent 9da18a67ac
commit 5fcea0cf85
19 changed files with 1005 additions and 331 deletions
+333 -80
View File
@@ -1,11 +1,25 @@
use leptos::prelude::*;
use crate::backend::appearance::{DeleteBanner, get_appearance, UpdateAppearance, UpdateColors};
use leptos::ev::MouseEvent;
use leptos::wasm_bindgen::JsCast;
use web_sys::{Element, File, FormData, HtmlInputElement, Url};
use crate::backend::appearance::{DeleteBanner, DeleteLogo, get_appearance, UpdateAppearance, UpdateStyle, upload_image};
use crate::backend::banner::{image_extension, BannerOptions, ImageKind, IMAGE_ACCEPT, MAX_DIM, MAX_IMAGE_SIZE};
use crate::backend::color::{Colors, PALETTES};
use crate::backend::data::{ApiResponse, Appearance};
use crate::backend::data::{banner_style, ApiResponse, Appearance};
use crate::components::data_form::{DataForm, QuestionDialog};
use crate::components::modal_box::DialogOpener;
use crate::locales::trl;
/// Look of the public page as it is being edited. Nothing is stored until it is saved.
#[derive(Clone, Copy)]
struct Look {
colors: RwSignal<Colors>,
banner: RwSignal<BannerOptions>,
/// Images selected in the file inputs and not uploaded yet, as object URLs for the preview
new_banner: RwSignal<Option<String>>,
new_logo: RwSignal<Option<String>>,
}
#[component]
fn edit_title(opener: DialogOpener, appearance: ReadSignal<Appearance>) -> impl IntoView {
let update = ServerAction::<UpdateAppearance>::new();
@@ -62,13 +76,22 @@ fn delete_banner(opener: DialogOpener) -> impl IntoView {
}
}
#[component]
fn delete_logo(opener: DialogOpener) -> impl IntoView {
let delete = ServerAction::<DeleteLogo>::new();
view! {
<QuestionDialog opener=opener action=delete title="Delete logo">
<div>{trl("Are you sure you want to delete logo?")}</div>
</QuestionDialog>
}
}
#[component]
fn color_input(
id: &'static str,
label: &'static str,
colors: RwSignal<Colors>,
get: fn(&Colors) -> String,
set: fn(&mut Colors, String)) -> impl IntoView {
#[prop(into)] value: Signal<String>,
#[prop(into)] on_change: Callback<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>
@@ -76,27 +99,198 @@ fn color_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)))
prop:value=move || value.get()
on:input=move |ev| on_change.run(event_target_value(&ev))
/>
</div>
}
}
/// Sample of the public page in the colours being edited. It uses the same classes and the same
/// One of a few values, as a row of buttons. Options are pairs of a value and its label.
#[component]
fn choice(
label: &'static str,
options: Vec<(&'static str, &'static str)>,
#[prop(into)] value: Signal<String>,
#[prop(into)] on_change: Callback<String>) -> impl IntoView {
view! {
<div class="d-flex align-items-center justify-content-between mb-2">
<div class="form-label mb-0">{trl(label)}</div>
<div class="btn-group" role="group" aria-label=trl(label)>
{options.into_iter().map(|(option, option_label)| view! {
<button
type="button"
class=move || if value.get() == option { "btn btn-sm btn-primary" } else { "btn btn-sm btn-outline-secondary" }
on:click=move |_| on_change.run(option.to_string())>
{trl(option_label)}
</button>
}).collect_view()}
</div>
</div>
}
}
/// Object URLs are made by the browser; still, only a plain one is let into a style
fn is_object_url(url: &str) -> bool {
url.starts_with("blob:")
&& url.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, ':' | '/' | '.' | '-' | '_' | '[' | ']'))
}
/// Selecting, uploading and deleting of the banner image or the logo. A selected image is shown
/// in the preview first and is uploaded on request.
#[component]
fn image_upload(
kind: ImageKind,
label: &'static str,
/// The stored image exists
#[prop(into)] exists: Signal<bool>,
/// Object URL of the selected image, for the preview
selected: RwSignal<Option<String>>,
/// Counter of finished uploads, the page reloads its data when it changes
uploads: RwSignal<u32>,
delete: DialogOpener) -> impl IntoView {
let file = RwSignal::new_local(None::<File>);
let error = RwSignal::new(None::<String>);
let input_ref = NodeRef::<leptos::html::Input>::new();
let upload = Action::new_local(|data: &FormData| upload_image(data.clone().into()));
let id = format!("{}_file", kind.name());
let clear = move || {
if let Some(url) = selected.get_untracked() {
let _ = Url::revoke_object_url(&url);
}
selected.set(None);
file.set(None);
if let Some(input) = input_ref.get_untracked() {
input.set_value("");
}
};
Effect::new(move |_| {
match upload.value().get() {
Some(Ok(ApiResponse::Data(_))) => {
clear();
uploads.update(|count| *count += 1);
}
Some(Ok(ApiResponse::Error(e))) => error.set(Some(trl(&e)())),
Some(Err(e)) => error.set(Some(format!("Server error: {}", e))),
None => {}
}
});
view! {
<div class="mb-3">
<label for=id.clone() class="form-label">{trl(label)}</label>
<input
type="file"
id=id
class="form-control"
accept=IMAGE_ACCEPT
node_ref=input_ref
on:change=move |ev| {
let input: HtmlInputElement = event_target(&ev);
let chosen = input.files().and_then(|files| files.get(0));
// Forget the previous selection, but keep the file in the input
if let Some(url) = selected.get_untracked() {
let _ = Url::revoke_object_url(&url);
}
selected.set(None);
file.set(None);
error.set(None);
// The server checks the same again
if let Some(chosen) = chosen {
if image_extension(&chosen.name()).is_none() {
error.set(Some(trl("Unsupported image type, use JPG, PNG, GIF or WebP")()));
} else if chosen.size() > MAX_IMAGE_SIZE as f64 {
error.set(Some(trl("Image is too large, 5 MB at most")()));
} else {
selected.set(Url::create_object_url_with_blob(&chosen).ok().filter(|url| is_object_url(url)));
file.set(Some(chosen));
}
}
}
/>
{move || error.get().map(|e| view! {
<div class="text-danger mt-1">{e}</div>
})}
<div class="mt-2">
{move || if file.get().is_some() {
view! {
<button type="button" class="btn btn-sm btn-primary me-2" disabled=move || upload.pending().get()
on:click=move |_| {
if let (Some(chosen), Ok(data)) = (file.get_untracked(), FormData::new()) {
// The kind has to be sent before the file
let _ = data.append_with_str("kind", kind.name());
let _ = data.append_with_blob_and_filename("file", &chosen, &chosen.name());
error.set(None);
upload.dispatch_local(data);
}
}>
{trl("Upload")}
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" on:click=move |_| clear()>
{trl("Cancel")}
</button>
}.into_any()
} else if exists.get() {
view! {
<button type="button" class="btn btn-sm btn-outline-secondary" on:click=move |_| delete.show()>
{trl("Delete")}
</button>
}.into_any()
} else {
view! {<span></span>}.into_any()
}}
</div>
</div>
}
}
/// Sample of the public page in the look 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 {
fn preview(look: Look, appearance: ReadSignal<Appearance>) -> impl IntoView {
let image = move || look.new_banner.get().or_else(|| appearance.get().banner_url());
let logo = move || look.new_logo.get().or_else(|| appearance.get().logo_url());
let title = move || appearance.get().title.unwrap_or_default();
let has_banner = move || image().is_some() || logo().is_some() || !title().trim().is_empty();
// Clicking into the banner chooses the part of the image that stays visible
let set_focus = move |ev: MouseEvent| {
let Some(banner) = ev.current_target().and_then(|target| target.dyn_into::<Element>().ok()) else {
return
};
let rect = banner.get_bounding_client_rect();
if rect.width() <= 0.0 || rect.height() <= 0.0 {
return
}
let percent = |position: f64, size: f64| ((position / size) * 100.0).round().clamp(0.0, 100.0) as i16;
let x = percent(ev.client_x() as f64 - rect.left(), rect.width());
let y = percent(ev.client_y() as f64 - rect.top(), rect.height());
look.banner.update(|banner| {
banner.focus_x = x;
banner.focus_y = y;
});
};
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-page public-preview" style=move || look.colors.get().style()>
<Show when=has_banner>
<div class="public-banner" style=move || banner_style(image(), &look.banner.get()) on:click=set_focus>
{move || logo().map(|url| view! {
<img class="public-banner-logo" src=url alt=""/>
})}
<h1 class="public-banner-title">{title}</h1>
<Show when=move || image().is_some()>
<span class="public-banner-focus" style=move || {
let banner = look.banner.get().sanitized();
format!("left: {}%; top: {}%", banner.focus_x, banner.focus_y)
}></span>
</Show>
</div>
</Show>
<div class="public-banner-text" inner_html=move || appearance.get().text.unwrap_or_default()></div>
<div class="card-body">
<div class="row">
@@ -150,17 +344,7 @@ fn preview(colors: RwSignal<Colors>, appearance: ReadSignal<Appearance>) -> impl
}
#[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());
}
});
fn color_settings(colors: RwSignal<Colors>) -> impl IntoView {
view! {
<div class="form-label">{trl("Palettes")}</div>
<div class="d-flex flex-wrap gap-2 mb-3">
@@ -182,25 +366,90 @@ fn color_settings(colors: RwSignal<Colors>, saved: RwSignal<Colors>) -> impl Int
</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/>
<ColorInput id="color_primary" label="Primary colour"
value=move || colors.get().primary on_change=move |v| colors.update(|c| c.primary = v)/>
<ColorInput id="color_background" label="Page background"
value=move || colors.get().background on_change=move |v| colors.update(|c| c.background = v)/>
<ColorInput id="color_card" label="Card background"
value=move || colors.get().card on_change=move |v| colors.update(|c| c.card = v)/>
<ColorInput id="color_text" label="Text colour"
value=move || colors.get().text on_change=move |v| colors.update(|c| 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">
}
}
#[component]
fn banner_settings(
look: Look,
appearance: ReadSignal<Appearance>,
uploads: RwSignal<u32>,
delete_banner: DialogOpener,
delete_logo: DialogOpener) -> impl IntoView {
let banner = look.banner;
view! {
<ImageUpload kind=ImageKind::Banner label="Banner image" selected=look.new_banner uploads=uploads
exists=move || appearance.get().banner_url().is_some() delete=delete_banner/>
<ImageUpload kind=ImageKind::Logo label="Logo" selected=look.new_logo uploads=uploads
exists=move || appearance.get().logo_url().is_some() delete=delete_logo/>
<Choice label="Height" options=vec![("low", "Low"), ("medium", "Medium"), ("high", "High")]
value=move || banner.get().height on_change=move |v| banner.update(|b| b.height = v)/>
<div class="d-flex align-items-center justify-content-between mb-2">
<label for="banner_dim" class="form-label mb-0">{trl("Darkening")}</label>
<input
type="range"
id="banner_dim"
class="form-range w-50"
min="0"
max=MAX_DIM.to_string()
step="5"
prop:value=move || banner.get().dim.to_string()
on:input=move |ev| {
if let Ok(dim) = event_target_value(&ev).parse::<i16>() {
banner.update(|b| b.dim = dim);
}
}
/>
</div>
<ColorInput id="title_color" label="Title colour"
value=move || banner.get().title_color on_change=move |v| banner.update(|b| b.title_color = v)/>
<Choice label="Title alignment" options=vec![("left", "Left"), ("center", "Centre")]
value=move || banner.get().title_align on_change=move |v| banner.update(|b| b.title_align = v)/>
<div class="text-muted mt-3">
{trl("Click into the banner in the preview to choose the part of the image that stays visible.")}
</div>
}
}
/// Saves colours and banner options together, they make one look
#[component]
fn save_bar(look: Look, saved: RwSignal<(Colors, BannerOptions)>) -> impl IntoView {
let save = ServerAction::<UpdateStyle>::new();
let sent = StoredValue::new((Colors::default(), BannerOptions::default()));
let current = move || (look.colors.get(), look.banner.get());
let dirty = move || current() != saved.get();
Effect::new(move |_| {
if let Some(Ok(ApiResponse::Data(_))) = save.value().get() {
saved.set(sent.get_value());
}
});
view! {
<div>
<button type="button" class="btn btn-primary me-2" on:click=move |_| {
sent.set_value(colors.get());
save.dispatch(UpdateColors { colors: colors.get() });
let (colors, banner) = current();
sent.set_value((colors.clone(), banner.clone()));
save.dispatch(UpdateStyle { colors, banner });
}>
{trl("Save")}
</button>
<button type="button" class="btn btn-outline-secondary" on:click=move |_| colors.set(Colors::default())>
<button type="button" class="btn btn-outline-secondary" on:click=move |_| {
look.colors.set(Colors::default());
look.banner.set(BannerOptions::default());
}>
{trl("Reset to default")}
</button>
</div>
@@ -212,7 +461,7 @@ fn color_settings(colors: RwSignal<Colors>, saved: RwSignal<Colors>) -> impl Int
match save.value().get() {
Some(Ok(ApiResponse::Data(_))) => view! {
<span class="text-success">{trl("Colours saved")}</span>
<span class="text-success">{trl("Settings saved")}</span>
}.into_any(),
_ => view! {<span></span>}.into_any()
}
@@ -234,22 +483,32 @@ fn color_settings(colors: RwSignal<Colors>, saved: RwSignal<Colors>) -> impl Int
pub fn appearance() -> impl IntoView {
let title_edit = DialogOpener::new();
let text_edit = DialogOpener::new();
let del_dialog = DialogOpener::new();
let appearance = Resource::new(move || title_edit.visible() || text_edit.visible() || del_dialog.visible(), |_| get_appearance());
let del_banner = DialogOpener::new();
let del_logo = DialogOpener::new();
let uploads = RwSignal::new(0u32);
let appearance = Resource::new(
move || (title_edit.visible() || text_edit.visible() || del_banner.visible() || del_logo.visible(), uploads.get()),
|_| get_appearance());
let app_edit = RwSignal::new(Appearance::default());
let colors = RwSignal::new(Colors::default());
let saved_colors = RwSignal::new(Colors::default());
let look = Look {
colors: RwSignal::new(Colors::default()),
banner: RwSignal::new(BannerOptions::default()),
new_banner: RwSignal::new(None),
new_logo: RwSignal::new(None),
};
let saved = RwSignal::new((Colors::default(), BannerOptions::default()));
let app_preview = RwSignal::new(Appearance::default());
let colors_loaded = StoredValue::new(false);
let look_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.
// The data are loaded again after every dialog and upload; that must not throw away a look 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);
if !look_loaded.get_value() {
look.colors.set(app.colors.clone());
look.banner.set(app.banner_options.clone());
saved.set((app.colors.clone(), app.banner_options.clone()));
look_loaded.set_value(true);
}
app_preview.set(app);
}
@@ -258,47 +517,29 @@ pub fn appearance() -> impl IntoView {
view! {
<EditTitle opener=title_edit appearance=app_edit.read_only()/>
<EditText opener=text_edit appearance=app_edit.read_only()/>
<DeleteBanner opener=del_dialog/>
<DeleteBanner opener=del_banner/>
<DeleteLogo opener=del_logo/>
<h1>{trl("Appearance settings")}</h1>
<div class="row mb-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">{trl("Top banner")}</h5>
<form method="post" action="/upload" enctype="multipart/form-data">
<label for="banner_file">{trl("Banner file")}</label>
<input id="banner_file" type="file" class="form-control" name="file_to_upload"/>
<button type="submit" class="btn btn-primary">
{trl("Upload")}
</button>
</form><br/>
<h5 class="card-title">{trl("Title and text")}</h5>
<Transition fallback=move || view! {<p>{trl("Loading...")}</p> }>
{move || {
appearance.get().map(|a| match a {
Ok(app) => {
app_edit.set(app.clone());
let has_banner = app.banner.is_some();
let title = app.title.clone().unwrap_or_default();
let style = app.banner_style();
view! {
<div>
<Show when=move || has_banner>
<div class="public-banner" style=style.clone()>
<h1 class="public-banner-title">{title.clone()}</h1>
</div>
</Show>
<div>
<div class="fs-4 mb-2">{app.title.clone().unwrap_or_default()}</div>
<button class="btn btn-info" on:click=move |_| title_edit.show()>
<i class="bx bx-edit-alt me-1"></i> {trl("Edit title")}</button>
<button class="btn btn-danger" on:click=move |_| del_dialog.show()>
{trl("Delete")}
</button>
<br/><br/>
<div inner_html={app.text.unwrap_or("<< TEXT >>".to_string())}>
</div>
<a class="dropdown-item" href="javascript:void(0);" on:click=move |_| text_edit.show()>
<i class="bx bx-edit-alt me-1"></i> {trl("Edit text")}</a>
</div>
</div>
}.into_any()
}
@@ -314,21 +555,33 @@ pub fn appearance() -> impl IntoView {
</div>
<div class="row mb-3">
<div class="col-lg-8 order-lg-2 mb-3">
<div class="card h-100">
<div class="card">
<div class="card-body">
<h5 class="card-title">{trl("Preview of the public page")}</h5>
<Preview colors=colors appearance=app_preview.read_only()/>
<Preview look=look 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="col-lg-4 order-lg-1">
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">{trl("Colours")}</h5>
<ColorSettings colors=colors saved=saved_colors/>
<ColorSettings colors=look.colors/>
</div>
</div>
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">{trl("Banner")}</h5>
<BannerSettings look=look appearance=app_preview.read_only() uploads=uploads
delete_banner=del_banner delete_logo=del_logo/>
</div>
</div>
<div class="card mb-3">
<div class="card-body">
<SaveBar look=look saved=saved/>
</div>
</div>
</div>
</div>
}
}
}
+7 -6
View File
@@ -167,16 +167,17 @@ pub fn Public() -> impl IntoView {
{move || {
appearance.get().map(|a| match a {
Ok(a) => {
let has_banner = a.banner.is_some();
let title = a.title.clone().unwrap_or_default();
let style = a.banner_style();
view! {
<div>
<Show when=move || has_banner>
<div class="public-banner" style=style.clone()>
<h1 class="public-banner-title">{title.clone()}</h1>
{a.has_banner().then(|| view! {
<div class="public-banner" style=a.banner_style()>
{a.logo_url().map(|url| view! {
<img class="public-banner-logo" src=url alt=""/>
})}
<h1 class="public-banner-title">{title}</h1>
</div>
</Show>
})}
<div>
<div class="public-banner-text" inner_html={a.text.unwrap_or("".to_string())}>