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:
+160
-100
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user