Added simple editor for the text under the banner, stored HTML is sanitized. Fixes #17

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 14:51:50 +02:00
co-authored by Claude Opus 5.5
parent 0f48160fb4
commit d610479c52
12 changed files with 558 additions and 136 deletions
+160 -100
View File
@@ -1,19 +1,23 @@
use leptos::prelude::*;
use leptos::ev::MouseEvent;
use leptos::ev::{ClipboardEvent, 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 web_sys::{Element, File, FormData, HtmlDocument, HtmlInputElement, Url};
use crate::backend::appearance::{DeleteBanner, DeleteLogo, get_appearance, SaveAppearance, 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::{banner_style, ApiResponse, Appearance};
use crate::backend::layout::LayoutOptions;
use crate::components::data_form::{DataForm, QuestionDialog};
use crate::backend::rich_text::{normalize_link, MAX_TITLE_LENGTH};
use crate::components::data_form::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.
/// The public page as it is being edited. Nothing is stored until it is saved.
#[derive(Clone, Copy)]
struct Look {
title: RwSignal<String>,
/// HTML of the text under the banner, as it is in the editor
text: RwSignal<String>,
colors: RwSignal<Colors>,
banner: RwSignal<BannerOptions>,
layout: RwSignal<LayoutOptions>,
@@ -22,49 +26,86 @@ struct Look {
new_logo: RwSignal<Option<String>>,
}
/// Button of the editor toolbar. It must not take the focus, the selection in the text would be lost.
#[component]
fn edit_title(opener: DialogOpener, appearance: ReadSignal<Appearance>) -> impl IntoView {
let update = ServerAction::<UpdateAppearance>::new();
fn tool_button(icon: &'static str, title: &'static str, #[prop(into)] on_press: Callback<()>) -> impl IntoView {
view! {
<DataForm opener=opener action=update title="Update title">
<input type="hidden" prop:value={move || appearance.get().text.unwrap_or_default()} name="appearance[text]" />
<input type="hidden" prop:value={move || appearance.get().id()} name="appearance[id]"/>
<div class="row">
<div class="col mb-3">
<label for="title" class="form-label">{trl("Title")}</label>
<input
type="text"
id="title"
class="form-control"
placeholder={trl("Enter title")}
prop:value={move || appearance.get().title.unwrap_or_default()}
name="appearance[title]"
/>
</div>
</div>
</DataForm>
<button
type="button"
class="btn btn-sm btn-outline-secondary"
title=trl(title)
aria-label=trl(title)
on:mousedown=move |ev| ev.prevent_default()
on:click=move |_| on_press.run(())>
<i class=format!("bx {}", icon)></i>
</button>
}
}
/// Simple editor of the text under the banner: bold, italic, heading, lists and links.
/// The browser does the editing (`contenteditable` and `execCommand`); whatever comes out of it,
/// the server reduces to the allowed tags when the text is saved (`sanitize_text`).
/// The content is put into the element by its owner, through the node reference.
#[component]
fn edit_text(opener: DialogOpener, appearance: ReadSignal<Appearance>) -> impl IntoView {
let update = ServerAction::<UpdateAppearance>::new();
fn text_editor(text: RwSignal<String>, editor: NodeRef<leptos::html::Div>) -> impl IntoView {
// Translations are not available in event handlers
let link_prompt = trl("Link address")();
let sync = move || {
if let Some(element) = editor.get_untracked() {
text.set(element.inner_html());
}
};
let exec = move |command: &str, value: &str| {
if let Ok(document) = document().dyn_into::<HtmlDocument>() {
let _ = document.exec_command_with_show_ui_and_value(command, false, value);
}
sync();
};
let add_link = move || {
if let Ok(Some(input)) = window().prompt_with_message_and_default(&link_prompt, "https://") {
if let Some(link) = normalize_link(&input) {
exec("createLink", &link);
}
}
};
view! {
<DataForm opener=opener action=update title="Update text">
<input type="hidden" prop:value={move || appearance.get().title.unwrap_or_default()} name="appearance[title]" />
<input type="hidden" prop:value={move || appearance.get().id()} name="appearance[id]"/>
<div class="row">
<div class="col mb-3">
<label for="text" class="form-label">{trl("Text")}</label>
<textarea
id="title"
class="form-control"
prop:value={move || appearance.get().text.unwrap_or_default()}
name="appearance[text]"
/>
</div>
<div class="btn-toolbar mb-2" role="toolbar">
<div class="btn-group me-2" role="group">
<ToolButton icon="bx-bold" title="Bold" on_press=move |_| exec("bold", "")/>
<ToolButton icon="bx-italic" title="Italic" on_press=move |_| exec("italic", "")/>
</div>
</DataForm>
<div class="btn-group me-2" role="group">
<ToolButton icon="bx-heading" title="Heading" on_press=move |_| exec("formatBlock", "h3")/>
<ToolButton icon="bx-paragraph" title="Paragraph" on_press=move |_| exec("formatBlock", "p")/>
</div>
<div class="btn-group me-2" role="group">
<ToolButton icon="bx-list-ul" title="Bullet list" on_press=move |_| exec("insertUnorderedList", "")/>
<ToolButton icon="bx-list-ol" title="Numbered list" on_press=move |_| exec("insertOrderedList", "")/>
</div>
<div class="btn-group" role="group">
<ToolButton icon="bx-link" title="Link" on_press=move |_| add_link()/>
<ToolButton icon="bx-unlink" title="Remove link" on_press=move |_| exec("unlink", "")/>
</div>
</div>
<div
id="text_editor"
class="form-control text-editor"
contenteditable="true"
role="textbox"
aria-multiline="true"
aria-labelledby="text_editor_label"
node_ref=editor
on:input=move |_| sync()
// Only the text of what is pasted, not the formatting it had elsewhere
on:paste=move |ev: ClipboardEvent| {
if let Some(pasted) = ev.clipboard_data().and_then(|data| data.get_data("text/plain").ok()) {
ev.prevent_default();
exec("insertText", &pasted);
}
}
></div>
}
}
@@ -152,6 +193,7 @@ fn image_upload(
uploads: RwSignal<u32>,
delete: DialogOpener) -> impl IntoView {
let file = RwSignal::new_local(None::<File>);
// A translation key; translations are not available in event handlers
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()));
@@ -174,7 +216,7 @@ fn image_upload(
clear();
uploads.update(|count| *count += 1);
}
Some(Ok(ApiResponse::Error(e))) => error.set(Some(trl(&e)())),
Some(Ok(ApiResponse::Error(e))) => error.set(Some(e)),
Some(Err(e)) => error.set(Some(format!("Server error: {}", e))),
None => {}
}
@@ -203,9 +245,9 @@ fn image_upload(
// 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")()));
error.set(Some("Unsupported image type, use JPG, PNG, GIF or WebP".to_string()));
} else if chosen.size() > MAX_IMAGE_SIZE as f64 {
error.set(Some(trl("Image is too large, 5 MB at most")()));
error.set(Some("Image is too large, 5 MB at most".to_string()));
} else {
selected.set(Url::create_object_url_with_blob(&chosen).ok().filter(|url| is_object_url(url)));
file.set(Some(chosen));
@@ -214,7 +256,7 @@ fn image_upload(
}
/>
{move || error.get().map(|e| view! {
<div class="text-danger mt-1">{e}</div>
<div class="text-danger mt-1">{trl(&e)}</div>
})}
<div class="mt-2">
{move || if file.get().is_some() {
@@ -255,7 +297,7 @@ fn image_upload(
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 title = move || look.title.get();
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
@@ -294,7 +336,7 @@ fn preview(look: Look, appearance: ReadSignal<Appearance>) -> impl IntoView {
</Show>
</div>
</Show>
<div class="public-banner-text" inner_html=move || appearance.get().text.unwrap_or_default()></div>
<div class="public-banner-text" inner_html=move || look.text.get()></div>
<div class="card-body public-form">
<div class="row public-form-columns">
<div class="col-sm">
@@ -440,33 +482,49 @@ fn layout_settings(layout: RwSignal<LayoutOptions>) -> impl IntoView {
}
}
/// Everything that is saved together as one look
type SavedLook = (Colors, BannerOptions, LayoutOptions);
/// Everything that is saved together: title, text, colours, banner options and layout
type Saved = (String, String, Colors, BannerOptions, LayoutOptions);
/// Saves colours, banner options and layout together, they make one look
#[component]
fn save_bar(look: Look, saved: RwSignal<SavedLook>) -> impl IntoView {
let save = ServerAction::<UpdateStyle>::new();
let sent = StoredValue::new(SavedLook::default());
let current = move || (look.colors.get(), look.banner.get(), look.layout.get());
fn save_bar(look: Look, saved: RwSignal<Saved>, editor: NodeRef<leptos::html::Div>) -> impl IntoView {
let save = ServerAction::<SaveAppearance>::new();
let sent = StoredValue::new(Saved::default());
let current = move || (look.title.get(), look.text.get(), look.colors.get(), look.banner.get(), look.layout.get());
let dirty = move || current() != saved.get();
Effect::new(move |_| {
if let Some(Ok(ApiResponse::Data(_))) = save.value().get() {
saved.set(sent.get_value());
if let Some(Ok(ApiResponse::Data(stored_text))) = save.value().get() {
let (title, text, colors, banner, layout) = sent.get_value();
let title = title.trim().to_string();
// The server may have removed something from the text; show what is really stored
if stored_text != text {
if let Some(element) = editor.get_untracked() {
element.set_inner_html(&stored_text);
}
}
saved.set((title.clone(), stored_text.clone(), colors, banner, layout));
// Only if nothing was changed while the request was running
if look.title.get_untracked() == sent.get_value().0 {
look.title.set(title);
}
if look.text.get_untracked() == text {
look.text.set(stored_text);
}
}
});
view! {
<div>
<button type="button" class="btn btn-primary me-2" on:click=move |_| {
let (colors, banner, layout) = current();
sent.set_value((colors.clone(), banner.clone(), layout.clone()));
save.dispatch(UpdateStyle { colors, banner, layout });
let (title, text, colors, banner, layout) = current();
sent.set_value((title.clone(), text.clone(), colors.clone(), banner.clone(), layout.clone()));
save.dispatch(SaveAppearance { title, text, colors, banner, layout });
}>
{trl("Save")}
</button>
<button type="button" class="btn btn-outline-secondary" on:click=move |_| {
<button type="button" class="btn btn-outline-secondary" title=trl("Resets colours, banner options and layout")
on:click=move |_| {
look.colors.set(Colors::default());
look.banner.set(BannerOptions::default());
look.layout.set(LayoutOptions::default());
@@ -502,77 +560,79 @@ fn save_bar(look: Look, saved: RwSignal<SavedLook>) -> impl IntoView {
#[component]
pub fn appearance() -> impl IntoView {
let title_edit = DialogOpener::new();
let text_edit = DialogOpener::new();
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()),
move || (del_banner.visible() || del_logo.visible(), uploads.get()),
|_| get_appearance());
let app_edit = RwSignal::new(Appearance::default());
let look = Look {
title: RwSignal::new(String::new()),
text: RwSignal::new(String::new()),
colors: RwSignal::new(Colors::default()),
banner: RwSignal::new(BannerOptions::default()),
layout: RwSignal::new(LayoutOptions::default()),
new_banner: RwSignal::new(None),
new_logo: RwSignal::new(None),
};
let saved = RwSignal::new(SavedLook::default());
let saved = RwSignal::new(Saved::default());
let app_preview = RwSignal::new(Appearance::default());
let look_loaded = StoredValue::new(false);
let loaded = StoredValue::new(false);
let editor = NodeRef::<leptos::html::Div>::new();
// Runs in the browser only, so the server and the browser render the same markup first.
// The data are loaded again after every dialog and upload; that must not throw away a look being edited.
// The data are loaded again after every upload and deleted image; that must not throw away what is being edited.
Effect::new(move |_| {
if let Some(Ok(app)) = appearance.get() {
if !look_loaded.get_value() {
if let (Some(element), Some(Ok(app))) = (editor.get(), appearance.get()) {
if !loaded.get_value() {
let title = app.title.clone().unwrap_or_default();
let text = app.text.clone().unwrap_or_default();
element.set_inner_html(&text);
look.title.set(title.clone());
look.text.set(text.clone());
look.colors.set(app.colors.clone());
look.banner.set(app.banner_options.clone());
look.layout.set(app.layout.clone());
saved.set((app.colors.clone(), app.banner_options.clone(), app.layout.clone()));
look_loaded.set_value(true);
saved.set((title, text, app.colors.clone(), app.banner_options.clone(), app.layout.clone()));
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()/>
<DeleteBanner opener=del_banner/>
<DeleteLogo opener=del_logo/>
<h1>{trl("Appearance settings")}</h1>
<Transition fallback=move || view! {<p>{trl("Loading...")}</p> }>
{move || {
appearance.get().map(|a| match a {
Ok(_) => view! {<div></div>}.into_any(),
Err(e) => view! {
<div class="alert alert-danger">{trl("Error loading data")}": "{e.to_string()}</div>
}.into_any()
})
}}
</Transition>
<div class="row mb-3">
<div class="card">
<div class="card-body">
<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());
view! {
<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>
<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>
}.into_any()
}
Err(e) => {view! {<div><p>{trl("Error loading data")}</p>
<p>{e.to_string()}</p></div>
}.into_any()}
})
}}
</Transition>
<h5 class="card-title">{trl("Title and text")}</h5>
<div class="mb-3">
<label for="title" class="form-label">{trl("Title")}</label>
<input
type="text"
id="title"
class="form-control"
maxlength=MAX_TITLE_LENGTH.to_string()
placeholder={trl("Enter title")}
prop:value=move || look.title.get()
on:input=move |ev| look.title.set(event_target_value(&ev))
/>
</div>
<div class="form-label" id="text_editor_label">{trl("Text under the banner")}</div>
<TextEditor text=look.text editor=editor/>
</div>
</div>
</div>
@@ -607,7 +667,7 @@ pub fn appearance() -> impl IntoView {
</div>
<div class="card mb-3">
<div class="card-body">
<SaveBar look=look saved=saved/>
<SaveBar look=look saved=saved editor=editor/>
</div>
</div>
</div>