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:
@@ -31,6 +31,7 @@ paths:
|
||||
- The auth middleware protects only `/admin*` page requests. `/api/*` is not covered by it — a server fn without `perm_check!` is public. Only the public booking form endpoints should be.
|
||||
- Plain Actix handlers must check the session themselves.
|
||||
- Files are uploaded through a multipart server function (`upload_image` in `src/backend/appearance.rs`): `#[server(input = MultipartFormData)]`, called in the browser through `Action::new_local` with a `web_sys::FormData`. The server stores a file under a name it makes itself, checks size and content, and never trusts the name sent by the browser. The multipart codec panics on a request without a boundary; `is_broken_upload` in `src/main.rs` refuses those first — extend it when adding another multipart function.
|
||||
- HTML that comes from a user and is rendered with `inner_html` (the text under the banner) must go through `sanitize_text` in `src/backend/rich_text.rs` before it is stored. Extend its allow-list together with the editor toolbar, never the other way round.
|
||||
- The logged-in `User` is stored in the cookie session under `"user"`; read it with `logged_in_user()`.
|
||||
|
||||
## Database access
|
||||
|
||||
@@ -39,6 +39,7 @@ paths:
|
||||
|
||||
- Every user-visible string goes through `trl("English text")`; never hard-code Czech (or any text) in a view. The English text is the key and the fallback.
|
||||
- When you add or change a key, add the Czech translation to the `cs` map in `src/locales/catalogues.rs`. This includes validation `message`s and `ApiResponse::Error` strings from the backend.
|
||||
- `trl()` reads the language from context, so in an event handler it silently returns the English key. Translate in the component body or in the view; keep a key in a signal and translate it when rendering (see `ImageUpload` in `src/pages/appearance_settings.rs`).
|
||||
- `trl()` returns a closure — use it directly as `{trl("...")}` or attribute value; call it (`trl("...")()`) when a `String` is needed.
|
||||
- Dates shown to users go through `loc_date()`, weekday names through `show_day()`.
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ Unit tests are `#[cfg(test)] mod tests` at the bottom of the file they test (`#[
|
||||
- `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/banner.rs` — options of the public page banner (`BannerOptions`) and the checks of uploaded images; shared by server and browser.
|
||||
- `src/backend/rich_text.rs` — the text under the banner: `sanitize_text` reduces its HTML to a few allowed tags before it is stored (it is shown to every visitor), `normalize_link` prepares link addresses.
|
||||
- `src/backend/layout.rs` — layout options of the public booking form (`LayoutOptions`), which become classes on the page wrapper.
|
||||
- `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.
|
||||
|
||||
Generated
+171
-2
@@ -333,6 +333,18 @@ version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "ammonia"
|
||||
version = "4.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "061e83b03c2681c18a6787d956e355c74e0b98ba7ba3d69b0822ade1e6f1d716"
|
||||
dependencies = [
|
||||
"cssparser",
|
||||
"html5ever",
|
||||
"maplit",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android-tzdata"
|
||||
version = "0.1.1"
|
||||
@@ -1067,6 +1079,17 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssparser"
|
||||
version = "0.38.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11119743ad110e8c1bdccd930d7f5c30c99e5fc76a7b63ec9807e84eef0c5f59"
|
||||
dependencies = [
|
||||
"dtoa-short",
|
||||
"itoa",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctr"
|
||||
version = "0.9.2"
|
||||
@@ -1266,6 +1289,21 @@ version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "669a445ee724c5c69b1b06fe0b63e70a1c84bc9bb7d9696cd4f4e3ec45050408"
|
||||
|
||||
[[package]]
|
||||
name = "dtoa"
|
||||
version = "1.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
|
||||
|
||||
[[package]]
|
||||
name = "dtoa-short"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
|
||||
dependencies = [
|
||||
"dtoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.13.0"
|
||||
@@ -1817,6 +1855,17 @@ dependencies = [
|
||||
"utf8-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.40.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "456a1a377e608e555d22ddab27ac0114bc7a7b4199078108e34c2aeae6c9b130"
|
||||
dependencies = [
|
||||
"log",
|
||||
"markup5ever",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "0.2.12"
|
||||
@@ -2546,6 +2595,23 @@ dependencies = [
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maplit"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.40.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ab3dc68ac4a0f5719e560136778c1ee716e296030d75dbd4484e37e39e3a842"
|
||||
dependencies = [
|
||||
"log",
|
||||
"tendril",
|
||||
"web_atoms",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.9.1"
|
||||
@@ -2569,9 +2635,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.7.4"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
@@ -2650,6 +2716,12 @@ dependencies = [
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "new_debug_unreachable"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "next_tuple"
|
||||
version = "0.1.0"
|
||||
@@ -2866,6 +2938,45 @@ version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_codegen"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41b585a510fb76fdebead6897982ef2a03a21d8e6cbcca904999742a4afc6ffe"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.8"
|
||||
@@ -2952,6 +3063,12 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.3.0"
|
||||
@@ -3309,6 +3426,7 @@ dependencies = [
|
||||
"actix-files",
|
||||
"actix-session",
|
||||
"actix-web",
|
||||
"ammonia",
|
||||
"base64 0.22.1",
|
||||
"cfg-if",
|
||||
"charts-rs",
|
||||
@@ -3820,6 +3938,12 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.9"
|
||||
@@ -4159,6 +4283,30 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ffa8a5dbe8b3f0bbe29d4c3225daafaeead63afdc1b65fc4c01a1384166038e6"
|
||||
dependencies = [
|
||||
"new_debug_unreachable",
|
||||
"parking_lot",
|
||||
"phf_shared",
|
||||
"precomputed-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "string_cache_codegen"
|
||||
version = "0.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "928dcdf75e47626b3617a976ec205d9f057584c371c1f23b782129268d0e6edc"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stringprep"
|
||||
version = "0.1.5"
|
||||
@@ -4287,6 +4435,15 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tendril"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"
|
||||
dependencies = [
|
||||
"new_debug_unreachable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
@@ -4909,6 +5066,18 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web_atoms"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7572660c8890448ba236b7376f27e389c6a7e1c70195622faced601f855c0ada"
|
||||
dependencies = [
|
||||
"phf",
|
||||
"phf_codegen",
|
||||
"string_cache",
|
||||
"string_cache_codegen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.25.4"
|
||||
|
||||
+4
-1
@@ -13,6 +13,8 @@ actix-session = { version = "0.10.0", optional = true, features = ["cookie-sessi
|
||||
console_error_panic_hook = "0.1.7"
|
||||
# Used by the multipart codec of server_fn; needed directly to check a request before it gets there
|
||||
multer = { version = "3", optional = true }
|
||||
# Sanitizes the HTML of the text shown on the public page
|
||||
ammonia = { version = "4", optional = true }
|
||||
cfg-if = "1"
|
||||
leptos = { version = "0.8.22", features = ["multipart"] }
|
||||
leptos_meta = { version = "0.8" }
|
||||
@@ -22,7 +24,7 @@ leptos_router = { version = "0.8" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1.0.117"
|
||||
wasm-bindgen = "=0.2.129"
|
||||
web-sys = { version = "0.3.69", features = ["Navigator", "Blob", "DomRect", "Element", "File", "FileList", "FormData", "HtmlInputElement", "MouseEvent", "Url"] }
|
||||
web-sys = { version = "0.3.69", features = ["Navigator", "Blob", "ClipboardEvent", "DataTransfer", "DomRect", "Element", "File", "FileList", "FormData", "HtmlDocument", "HtmlInputElement", "MouseEvent", "Url"] }
|
||||
lazy_static = "1.4.0"
|
||||
chrono = { version = "0.4.38", features = ["serde"]}
|
||||
sqlx = { version = "0.8.0", optional = true, features = ["runtime-tokio-rustls", "postgres", "chrono", "rust_decimal", "uuid"] }
|
||||
@@ -55,6 +57,7 @@ ssr = [
|
||||
"dep:leptos_actix",
|
||||
"dep:actix-session",
|
||||
"dep:multer",
|
||||
"dep:ammonia",
|
||||
"dep:sqlx",
|
||||
"dep:lettre",
|
||||
"dep:charts-rs",
|
||||
|
||||
Vendored
+10
@@ -2,3 +2,13 @@ a.disabled {
|
||||
pointer-events: none;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
/* Editor of the text under the banner in the appearance settings */
|
||||
.text-editor {
|
||||
min-height: 8rem;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.text-editor > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
+26
-31
@@ -1,12 +1,10 @@
|
||||
use cfg_if::cfg_if;
|
||||
use leptos::prelude::*;
|
||||
use server_fn::codec::{MultipartData, MultipartFormData};
|
||||
use validator::Validate;
|
||||
use crate::backend::banner::BannerOptions;
|
||||
use crate::backend::color::Colors;
|
||||
use crate::backend::data::{ApiResponse, Appearance};
|
||||
use crate::backend::layout::LayoutOptions;
|
||||
use crate::components::data_form::ForValidation;
|
||||
|
||||
cfg_if! { if #[cfg(feature = "ssr")] {
|
||||
|
||||
@@ -106,38 +104,30 @@ pub async fn get_appearance() -> Result<Appearance, ServerFnError> {
|
||||
Ok(appearance)
|
||||
}
|
||||
|
||||
/// Saves everything that is edited on the appearance page except the images: title and text of
|
||||
/// the public page, its colours, the options of the banner and the layout of the form.
|
||||
/// Returns the text as it was stored, that is after sanitization.
|
||||
#[server]
|
||||
pub async fn update_appearance(appearance: Appearance) -> Result<ApiResponse<()>, ServerFnError> {
|
||||
pub async fn save_appearance(
|
||||
title: String,
|
||||
text: String,
|
||||
colors: Colors,
|
||||
banner: BannerOptions,
|
||||
layout: LayoutOptions) -> Result<ApiResponse<String>, ServerFnError> {
|
||||
use crate::perm_check;
|
||||
use crate::backend::rich_text::{sanitize_text, MAX_TEXT_LENGTH, MAX_TITLE_LENGTH};
|
||||
use validator::Validate;
|
||||
|
||||
perm_check!(is_admin);
|
||||
|
||||
let pool = get_pool().await?;
|
||||
let id = appearance.id();
|
||||
query("UPDATE appearance SET title = $1, text = $2 WHERE id = $3")
|
||||
.bind(appearance.title)
|
||||
.bind(appearance.text)
|
||||
.bind(id)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
Ok(ApiResponse::Data(()))
|
||||
let title = title.trim();
|
||||
if title.chars().count() > MAX_TITLE_LENGTH {
|
||||
return Ok(ApiResponse::Error("Title is too long".to_string()))
|
||||
}
|
||||
|
||||
impl ForValidation for UpdateAppearance {
|
||||
fn entity(&self) -> &dyn Validate {
|
||||
&self.appearance
|
||||
if text.len() > MAX_TEXT_LENGTH {
|
||||
return Ok(ApiResponse::Error("Text is too long".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the look of the public page: its colours, the options of the banner and the layout of the form
|
||||
#[server]
|
||||
pub async fn update_style(colors: Colors, banner: BannerOptions, layout: LayoutOptions) -> Result<ApiResponse<()>, ServerFnError> {
|
||||
use crate::perm_check;
|
||||
|
||||
perm_check!(is_admin);
|
||||
|
||||
// The values end up in style attributes of the public page
|
||||
// The values end up in style and class attributes of the public page
|
||||
if colors.validate().is_err() {
|
||||
return Ok(ApiResponse::Error("Invalid colour".to_string()))
|
||||
}
|
||||
@@ -149,12 +139,17 @@ pub async fn update_style(colors: Colors, banner: BannerOptions, layout: LayoutO
|
||||
}
|
||||
|
||||
let pool = get_pool().await?;
|
||||
// The text is shown to every visitor as HTML
|
||||
let text = sanitize_text(&text);
|
||||
let colors = colors.sanitized();
|
||||
let banner = banner.sanitized();
|
||||
let layout = layout.sanitized();
|
||||
query("UPDATE appearance SET color_primary = $1, color_background = $2, color_card = $3, color_text = $4, \
|
||||
banner_height = $5, banner_focus_x = $6, banner_focus_y = $7, banner_dim = $8, title_color = $9, title_align = $10, \
|
||||
layout_width = $11, layout_arrangement = $12, layout_slots = $13, layout_corners = $14")
|
||||
query("UPDATE appearance SET title = $1, text = $2, \
|
||||
color_primary = $3, color_background = $4, color_card = $5, color_text = $6, \
|
||||
banner_height = $7, banner_focus_x = $8, banner_focus_y = $9, banner_dim = $10, title_color = $11, title_align = $12, \
|
||||
layout_width = $13, layout_arrangement = $14, layout_slots = $15, layout_corners = $16")
|
||||
.bind(title)
|
||||
.bind(&text)
|
||||
.bind(colors.primary)
|
||||
.bind(colors.background)
|
||||
.bind(colors.card)
|
||||
@@ -172,7 +167,7 @@ pub async fn update_style(colors: Colors, banner: BannerOptions, layout: LayoutO
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
Ok(ApiResponse::Data(()))
|
||||
Ok(ApiResponse::Data(text.unwrap_or_default()))
|
||||
}
|
||||
|
||||
/// Upload of the banner image or the logo. The form has the fields `kind` (`banner` or `logo`,
|
||||
|
||||
@@ -745,7 +745,6 @@ pub struct Appearance {
|
||||
pub banner: Option<String>,
|
||||
pub text: Option<String>,
|
||||
pub title: Option<String>,
|
||||
// Forms that edit the title or the text don't send the colours
|
||||
#[serde(default)]
|
||||
#[cfg_attr(feature = "ssr", sqlx(flatten))]
|
||||
pub colors: Colors,
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod appearance;
|
||||
pub mod color;
|
||||
pub mod banner;
|
||||
pub mod layout;
|
||||
pub mod rich_text;
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod health;
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
//! Title and formatted text shown at the top of the public page.
|
||||
//!
|
||||
//! The text is written in a simple editor in the appearance settings and stored as HTML.
|
||||
//! It is shown to every visitor, so the server reduces it to a few harmless tags before storing it.
|
||||
|
||||
/// Longest title of the public page, in characters
|
||||
pub const MAX_TITLE_LENGTH: usize = 200;
|
||||
/// Longest text under the banner, in bytes of its HTML
|
||||
pub const MAX_TEXT_LENGTH: usize = 20_000;
|
||||
|
||||
/// Schemes a link in the text may have
|
||||
const LINK_SCHEMES: [&str; 4] = ["http", "https", "mailto", "tel"];
|
||||
|
||||
/// Turns what a user typed as a link address into a URL the sanitizer accepts: an address
|
||||
/// without a scheme becomes `https://` (or `mailto:` for an e-mail address).
|
||||
/// Returns `None` when it can't be a link.
|
||||
pub fn normalize_link(input: &str) -> Option<String> {
|
||||
let link = input.trim();
|
||||
|
||||
if link.is_empty() || link.chars().any(|c| c.is_whitespace() || c.is_control() || matches!(c, '"' | '<' | '>')) {
|
||||
return None
|
||||
}
|
||||
|
||||
let lower = link.to_lowercase();
|
||||
if LINK_SCHEMES.iter().any(|scheme| {
|
||||
lower.strip_prefix(scheme).and_then(|rest| rest.strip_prefix(':')).is_some_and(|rest| !rest.is_empty())
|
||||
}) {
|
||||
return Some(link.to_string())
|
||||
}
|
||||
|
||||
// Any other scheme (javascript:, data:, ...); a colon after a host name is a port
|
||||
if let Some((before, after)) = lower.split_once(':') {
|
||||
let is_port = after.split('/').next().is_some_and(|port| !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()));
|
||||
if !before.contains('/') && !is_port {
|
||||
return None
|
||||
}
|
||||
}
|
||||
|
||||
if link.contains('@') && !link.contains('/') {
|
||||
Some(format!("mailto:{}", link))
|
||||
} else {
|
||||
Some(format!("https://{}", link))
|
||||
}
|
||||
}
|
||||
|
||||
/// Reduces HTML to what the editor can produce: paragraphs, line breaks, bold, italic,
|
||||
/// headings, lists and links. Everything else is removed, scripts and styles with their content.
|
||||
/// Returns `None` when no text is left.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub fn sanitize_text(html: &str) -> Option<String> {
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use ammonia::{Builder, UrlRelative};
|
||||
|
||||
let clean = Builder::default()
|
||||
// `div` is what browsers put around lines typed into an editable element
|
||||
.tags(HashSet::from(["p", "div", "br", "strong", "b", "em", "i", "h2", "h3", "h4", "ul", "ol", "li", "a"]))
|
||||
.tag_attributes(HashMap::from([("a", HashSet::from(["href"]))]))
|
||||
.generic_attributes(HashSet::new())
|
||||
.url_schemes(HashSet::from(LINK_SCHEMES))
|
||||
.url_relative(UrlRelative::Deny)
|
||||
.link_rel(Some("noopener noreferrer"))
|
||||
.clean(html)
|
||||
.to_string();
|
||||
|
||||
// Markup without any text, like the "<p><br></p>" an emptied editor leaves behind
|
||||
let text = regex::Regex::new(r"<[^>]*>").unwrap().replace_all(&clean, "");
|
||||
if text.replace(" ", "").trim().is_empty() {
|
||||
return None
|
||||
}
|
||||
|
||||
Some(clean)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalize_link_keeps_allowed_schemes() {
|
||||
for link in ["https://example.com/a?b=c#d", "http://example.com", "mailto:info@example.com", "tel:+420123456789",
|
||||
"HTTPS://Example.com"] {
|
||||
assert_eq!(normalize_link(link), Some(link.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_link_adds_scheme() {
|
||||
assert_eq!(normalize_link("example.com"), Some("https://example.com".to_string()));
|
||||
assert_eq!(normalize_link(" www.example.com/path "), Some("https://www.example.com/path".to_string()));
|
||||
assert_eq!(normalize_link("example.com:8080/path"), Some("https://example.com:8080/path".to_string()));
|
||||
assert_eq!(normalize_link("example.com/a:b"), Some("https://example.com/a:b".to_string()));
|
||||
assert_eq!(normalize_link("info@example.com"), Some("mailto:info@example.com".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_link_rejects_other_input() {
|
||||
for link in ["", " ", "javascript:alert(1)", "JavaScript:alert(1)", "data:text/html,x", "vbscript:x", "file:///etc/passwd",
|
||||
"two words", "a\"b", "<script>", "https:", "ftp://example.com"] {
|
||||
assert_eq!(normalize_link(link), None, "link: {:?}", link);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
mod sanitize {
|
||||
use super::super::sanitize_text;
|
||||
|
||||
fn clean(html: &str) -> String {
|
||||
sanitize_text(html).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_what_the_editor_produces() {
|
||||
let html = "<h3>Opening hours</h3><p>Open <strong>daily</strong>, <em>also</em> on <b>holidays</b> <i>!</i><br>8–20</p>\
|
||||
<ul><li>one</li><li>two</li></ul><ol><li>first</li></ol><div>line</div><h2>a</h2><h4>b</h4>";
|
||||
assert_eq!(clean(html), html);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_keep_only_safe_addresses() {
|
||||
assert_eq!(clean("<a href=\"https://example.com\">web</a>"),
|
||||
"<a href=\"https://example.com\" rel=\"noopener noreferrer\">web</a>");
|
||||
assert_eq!(clean("<a href=\"mailto:info@example.com\">mail</a>"),
|
||||
"<a href=\"mailto:info@example.com\" rel=\"noopener noreferrer\">mail</a>");
|
||||
assert_eq!(clean("<a href=\"tel:+420123456789\">call</a>"),
|
||||
"<a href=\"tel:+420123456789\" rel=\"noopener noreferrer\">call</a>");
|
||||
|
||||
for href in ["javascript:alert(1)", "JaVaScRiPt:alert(1)", "data:text/html,<script>x</script>", "vbscript:x",
|
||||
"/relative", "relative.html", "ftp://example.com"] {
|
||||
let cleaned = clean(&format!("<a href=\"{}\">x</a>", href));
|
||||
assert!(!cleaned.contains("href"), "href: {:?} -> {:?}", href, cleaned);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removes_scripts_styles_and_handlers() {
|
||||
assert_eq!(clean("<p>a</p><script>alert(1)</script>"), "<p>a</p>");
|
||||
assert_eq!(clean("<style>p { color: red }</style><p>a</p>"), "<p>a</p>");
|
||||
assert_eq!(clean("<p onclick=\"alert(1)\" style=\"color: red\" class=\"x\" id=\"y\">a</p>"), "<p>a</p>");
|
||||
assert_eq!(clean("<a href=\"https://example.com\" onclick=\"x()\" target=\"_blank\" style=\"x\">a</a>"),
|
||||
"<a href=\"https://example.com\" rel=\"noopener noreferrer\">a</a>");
|
||||
assert_eq!(clean("<img src=\"x\" onerror=\"alert(1)\">text"), "text");
|
||||
assert_eq!(clean("<iframe src=\"https://example.com\"></iframe>text"), "text");
|
||||
assert_eq!(clean("<svg onload=\"alert(1)\"></svg><p>a</p>"), "<p>a</p>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removes_other_tags_but_keeps_their_text() {
|
||||
assert_eq!(clean("<h1>Title</h1><table><tr><td>cell</td></tr></table><span style=\"font-size: 40px\">big</span><u>u</u>"),
|
||||
"Titlecellbigu");
|
||||
// Formatting pasted from a word processor
|
||||
assert_eq!(clean("<p class=\"MsoNormal\" style=\"margin: 0\"><span lang=\"CS\"><font color=\"red\">Text</font></span></p>"),
|
||||
"<p>Text</p>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repairs_broken_markup() {
|
||||
assert_eq!(clean("<p>unclosed <strong>bold"), "<p>unclosed <strong>bold</strong></p>");
|
||||
assert_eq!(clean("plain text"), "plain text");
|
||||
assert_eq!(clean("a < b & c"), "a < b & c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_content_is_none() {
|
||||
for html in ["", " ", "<p><br></p>", "<div><br></div>", "<p> </p>", "<script>alert(1)</script>", "<p> </p><ul><li></li></ul>"] {
|
||||
assert_eq!(sanitize_text(html), None, "html: {:?}", html);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,19 @@ lazy_static! {
|
||||
("Save", "Uložit"),
|
||||
("Reset to default", "Obnovit výchozí"),
|
||||
("Settings saved", "Nastavení je uloženo"),
|
||||
("Text under the banner", "Text pod bannerem"),
|
||||
("Bold", "Tučně"),
|
||||
("Italic", "Kurzíva"),
|
||||
("Heading", "Nadpis"),
|
||||
("Paragraph", "Odstavec"),
|
||||
("Bullet list", "Seznam s odrážkami"),
|
||||
("Numbered list", "Číslovaný seznam"),
|
||||
("Link", "Odkaz"),
|
||||
("Remove link", "Odebrat odkaz"),
|
||||
("Link address", "Adresa odkazu"),
|
||||
("Title is too long", "Titulek je příliš dlouhý"),
|
||||
("Text is too long", "Text je příliš dlouhý"),
|
||||
("Resets colours, banner options and layout", "Vrátí barvy, nastavení banneru a rozložení"),
|
||||
("Layout", "Rozložení"),
|
||||
("Content width", "Šířka obsahu"),
|
||||
("Full width", "Celá šířka"),
|
||||
|
||||
@@ -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 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>
|
||||
<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>
|
||||
</DataForm>
|
||||
<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 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>
|
||||
<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>
|
||||
|
||||
<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