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.
|
- 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.
|
- 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.
|
- 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()`.
|
- The logged-in `User` is stored in the cookie session under `"user"`; read it with `logged_in_user()`.
|
||||||
|
|
||||||
## Database access
|
## 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.
|
- 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.
|
- 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.
|
- `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()`.
|
- 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/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/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/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/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/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.
|
- `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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
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]]
|
[[package]]
|
||||||
name = "android-tzdata"
|
name = "android-tzdata"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -1067,6 +1079,17 @@ dependencies = [
|
|||||||
"subtle",
|
"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]]
|
[[package]]
|
||||||
name = "ctr"
|
name = "ctr"
|
||||||
version = "0.9.2"
|
version = "0.9.2"
|
||||||
@@ -1266,6 +1289,21 @@ version = "0.1.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "669a445ee724c5c69b1b06fe0b63e70a1c84bc9bb7d9696cd4f4e3ec45050408"
|
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]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.13.0"
|
version = "1.13.0"
|
||||||
@@ -1817,6 +1855,17 @@ dependencies = [
|
|||||||
"utf8-width",
|
"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]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "0.2.12"
|
version = "0.2.12"
|
||||||
@@ -2546,6 +2595,23 @@ dependencies = [
|
|||||||
"quote",
|
"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]]
|
[[package]]
|
||||||
name = "md-5"
|
name = "md-5"
|
||||||
version = "0.9.1"
|
version = "0.9.1"
|
||||||
@@ -2569,9 +2635,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.7.4"
|
version = "2.8.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mime"
|
name = "mime"
|
||||||
@@ -2650,6 +2716,12 @@ dependencies = [
|
|||||||
"tempfile",
|
"tempfile",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "new_debug_unreachable"
|
||||||
|
version = "1.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "next_tuple"
|
name = "next_tuple"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -2866,6 +2938,45 @@ version = "2.3.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
|
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]]
|
[[package]]
|
||||||
name = "pin-project"
|
name = "pin-project"
|
||||||
version = "1.1.8"
|
version = "1.1.8"
|
||||||
@@ -2952,6 +3063,12 @@ dependencies = [
|
|||||||
"zerocopy",
|
"zerocopy",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "precomputed-hash"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "prettyplease"
|
name = "prettyplease"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -3309,6 +3426,7 @@ dependencies = [
|
|||||||
"actix-files",
|
"actix-files",
|
||||||
"actix-session",
|
"actix-session",
|
||||||
"actix-web",
|
"actix-web",
|
||||||
|
"ammonia",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"charts-rs",
|
"charts-rs",
|
||||||
@@ -3820,6 +3938,12 @@ version = "0.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "siphasher"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.9"
|
version = "0.4.9"
|
||||||
@@ -4159,6 +4283,30 @@ dependencies = [
|
|||||||
"windows-sys 0.59.0",
|
"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]]
|
[[package]]
|
||||||
name = "stringprep"
|
name = "stringprep"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -4287,6 +4435,15 @@ dependencies = [
|
|||||||
"windows-sys 0.59.0",
|
"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]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "1.0.69"
|
version = "1.0.69"
|
||||||
@@ -4909,6 +5066,18 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "webpki-roots"
|
name = "webpki-roots"
|
||||||
version = "0.25.4"
|
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"
|
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
|
# Used by the multipart codec of server_fn; needed directly to check a request before it gets there
|
||||||
multer = { version = "3", optional = true }
|
multer = { version = "3", optional = true }
|
||||||
|
# Sanitizes the HTML of the text shown on the public page
|
||||||
|
ammonia = { version = "4", optional = true }
|
||||||
cfg-if = "1"
|
cfg-if = "1"
|
||||||
leptos = { version = "0.8.22", features = ["multipart"] }
|
leptos = { version = "0.8.22", features = ["multipart"] }
|
||||||
leptos_meta = { version = "0.8" }
|
leptos_meta = { version = "0.8" }
|
||||||
@@ -22,7 +24,7 @@ leptos_router = { version = "0.8" }
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1.0.117"
|
serde_json = "1.0.117"
|
||||||
wasm-bindgen = "=0.2.129"
|
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"
|
lazy_static = "1.4.0"
|
||||||
chrono = { version = "0.4.38", features = ["serde"]}
|
chrono = { version = "0.4.38", features = ["serde"]}
|
||||||
sqlx = { version = "0.8.0", optional = true, features = ["runtime-tokio-rustls", "postgres", "chrono", "rust_decimal", "uuid"] }
|
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:leptos_actix",
|
||||||
"dep:actix-session",
|
"dep:actix-session",
|
||||||
"dep:multer",
|
"dep:multer",
|
||||||
|
"dep:ammonia",
|
||||||
"dep:sqlx",
|
"dep:sqlx",
|
||||||
"dep:lettre",
|
"dep:lettre",
|
||||||
"dep:charts-rs",
|
"dep:charts-rs",
|
||||||
|
|||||||
Vendored
+10
@@ -2,3 +2,13 @@ a.disabled {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
color: #ccc;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
+27
-32
@@ -1,12 +1,10 @@
|
|||||||
use cfg_if::cfg_if;
|
use cfg_if::cfg_if;
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use server_fn::codec::{MultipartData, MultipartFormData};
|
use server_fn::codec::{MultipartData, MultipartFormData};
|
||||||
use validator::Validate;
|
|
||||||
use crate::backend::banner::BannerOptions;
|
use crate::backend::banner::BannerOptions;
|
||||||
use crate::backend::color::Colors;
|
use crate::backend::color::Colors;
|
||||||
use crate::backend::data::{ApiResponse, Appearance};
|
use crate::backend::data::{ApiResponse, Appearance};
|
||||||
use crate::backend::layout::LayoutOptions;
|
use crate::backend::layout::LayoutOptions;
|
||||||
use crate::components::data_form::ForValidation;
|
|
||||||
|
|
||||||
cfg_if! { if #[cfg(feature = "ssr")] {
|
cfg_if! { if #[cfg(feature = "ssr")] {
|
||||||
|
|
||||||
@@ -106,38 +104,30 @@ pub async fn get_appearance() -> Result<Appearance, ServerFnError> {
|
|||||||
Ok(appearance)
|
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]
|
#[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::perm_check;
|
||||||
|
use crate::backend::rich_text::{sanitize_text, MAX_TEXT_LENGTH, MAX_TITLE_LENGTH};
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
perm_check!(is_admin);
|
perm_check!(is_admin);
|
||||||
|
|
||||||
let pool = get_pool().await?;
|
let title = title.trim();
|
||||||
let id = appearance.id();
|
if title.chars().count() > MAX_TITLE_LENGTH {
|
||||||
query("UPDATE appearance SET title = $1, text = $2 WHERE id = $3")
|
return Ok(ApiResponse::Error("Title is too long".to_string()))
|
||||||
.bind(appearance.title)
|
|
||||||
.bind(appearance.text)
|
|
||||||
.bind(id)
|
|
||||||
.execute(&pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(ApiResponse::Data(()))
|
|
||||||
}
|
|
||||||
|
|
||||||
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]
|
// The values end up in style and class attributes of the public page
|
||||||
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
|
|
||||||
if colors.validate().is_err() {
|
if colors.validate().is_err() {
|
||||||
return Ok(ApiResponse::Error("Invalid colour".to_string()))
|
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?;
|
let pool = get_pool().await?;
|
||||||
|
// The text is shown to every visitor as HTML
|
||||||
|
let text = sanitize_text(&text);
|
||||||
let colors = colors.sanitized();
|
let colors = colors.sanitized();
|
||||||
let banner = banner.sanitized();
|
let banner = banner.sanitized();
|
||||||
let layout = layout.sanitized();
|
let layout = layout.sanitized();
|
||||||
query("UPDATE appearance SET color_primary = $1, color_background = $2, color_card = $3, color_text = $4, \
|
query("UPDATE appearance SET title = $1, text = $2, \
|
||||||
banner_height = $5, banner_focus_x = $6, banner_focus_y = $7, banner_dim = $8, title_color = $9, title_align = $10, \
|
color_primary = $3, color_background = $4, color_card = $5, color_text = $6, \
|
||||||
layout_width = $11, layout_arrangement = $12, layout_slots = $13, layout_corners = $14")
|
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.primary)
|
||||||
.bind(colors.background)
|
.bind(colors.background)
|
||||||
.bind(colors.card)
|
.bind(colors.card)
|
||||||
@@ -172,7 +167,7 @@ pub async fn update_style(colors: Colors, banner: BannerOptions, layout: LayoutO
|
|||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await?;
|
.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`,
|
/// 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 banner: Option<String>,
|
||||||
pub text: Option<String>,
|
pub text: Option<String>,
|
||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
// Forms that edit the title or the text don't send the colours
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
#[cfg_attr(feature = "ssr", sqlx(flatten))]
|
#[cfg_attr(feature = "ssr", sqlx(flatten))]
|
||||||
pub colors: Colors,
|
pub colors: Colors,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub mod appearance;
|
|||||||
pub mod color;
|
pub mod color;
|
||||||
pub mod banner;
|
pub mod banner;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
|
pub mod rich_text;
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub mod health;
|
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"),
|
("Save", "Uložit"),
|
||||||
("Reset to default", "Obnovit výchozí"),
|
("Reset to default", "Obnovit výchozí"),
|
||||||
("Settings saved", "Nastavení je uloženo"),
|
("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í"),
|
("Layout", "Rozložení"),
|
||||||
("Content width", "Šířka obsahu"),
|
("Content width", "Šířka obsahu"),
|
||||||
("Full width", "Celá šířka"),
|
("Full width", "Celá šířka"),
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::ev::MouseEvent;
|
use leptos::ev::{ClipboardEvent, MouseEvent};
|
||||||
use leptos::wasm_bindgen::JsCast;
|
use leptos::wasm_bindgen::JsCast;
|
||||||
use web_sys::{Element, File, FormData, HtmlInputElement, Url};
|
use web_sys::{Element, File, FormData, HtmlDocument, HtmlInputElement, Url};
|
||||||
use crate::backend::appearance::{DeleteBanner, DeleteLogo, get_appearance, UpdateAppearance, UpdateStyle, upload_image};
|
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::banner::{image_extension, BannerOptions, ImageKind, IMAGE_ACCEPT, MAX_DIM, MAX_IMAGE_SIZE};
|
||||||
use crate::backend::color::{Colors, PALETTES};
|
use crate::backend::color::{Colors, PALETTES};
|
||||||
use crate::backend::data::{banner_style, ApiResponse, Appearance};
|
use crate::backend::data::{banner_style, ApiResponse, Appearance};
|
||||||
use crate::backend::layout::LayoutOptions;
|
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::components::modal_box::DialogOpener;
|
||||||
use crate::locales::trl;
|
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)]
|
#[derive(Clone, Copy)]
|
||||||
struct Look {
|
struct Look {
|
||||||
|
title: RwSignal<String>,
|
||||||
|
/// HTML of the text under the banner, as it is in the editor
|
||||||
|
text: RwSignal<String>,
|
||||||
colors: RwSignal<Colors>,
|
colors: RwSignal<Colors>,
|
||||||
banner: RwSignal<BannerOptions>,
|
banner: RwSignal<BannerOptions>,
|
||||||
layout: RwSignal<LayoutOptions>,
|
layout: RwSignal<LayoutOptions>,
|
||||||
@@ -22,49 +26,86 @@ struct Look {
|
|||||||
new_logo: RwSignal<Option<String>>,
|
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]
|
#[component]
|
||||||
fn edit_title(opener: DialogOpener, appearance: ReadSignal<Appearance>) -> impl IntoView {
|
fn tool_button(icon: &'static str, title: &'static str, #[prop(into)] on_press: Callback<()>) -> impl IntoView {
|
||||||
let update = ServerAction::<UpdateAppearance>::new();
|
|
||||||
view! {
|
view! {
|
||||||
<DataForm opener=opener action=update title="Update title">
|
<button
|
||||||
<input type="hidden" prop:value={move || appearance.get().text.unwrap_or_default()} name="appearance[text]" />
|
type="button"
|
||||||
<input type="hidden" prop:value={move || appearance.get().id()} name="appearance[id]"/>
|
class="btn btn-sm btn-outline-secondary"
|
||||||
<div class="row">
|
title=trl(title)
|
||||||
<div class="col mb-3">
|
aria-label=trl(title)
|
||||||
<label for="title" class="form-label">{trl("Title")}</label>
|
on:mousedown=move |ev| ev.prevent_default()
|
||||||
<input
|
on:click=move |_| on_press.run(())>
|
||||||
type="text"
|
<i class=format!("bx {}", icon)></i>
|
||||||
id="title"
|
</button>
|
||||||
class="form-control"
|
|
||||||
placeholder={trl("Enter title")}
|
|
||||||
prop:value={move || appearance.get().title.unwrap_or_default()}
|
|
||||||
name="appearance[title]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DataForm>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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]
|
#[component]
|
||||||
fn edit_text(opener: DialogOpener, appearance: ReadSignal<Appearance>) -> impl IntoView {
|
fn text_editor(text: RwSignal<String>, editor: NodeRef<leptos::html::Div>) -> impl IntoView {
|
||||||
let update = ServerAction::<UpdateAppearance>::new();
|
// 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! {
|
view! {
|
||||||
<DataForm opener=opener action=update title="Update text">
|
<div class="btn-toolbar mb-2" role="toolbar">
|
||||||
<input type="hidden" prop:value={move || appearance.get().title.unwrap_or_default()} name="appearance[title]" />
|
<div class="btn-group me-2" role="group">
|
||||||
<input type="hidden" prop:value={move || appearance.get().id()} name="appearance[id]"/>
|
<ToolButton icon="bx-bold" title="Bold" on_press=move |_| exec("bold", "")/>
|
||||||
<div class="row">
|
<ToolButton icon="bx-italic" title="Italic" on_press=move |_| exec("italic", "")/>
|
||||||
<div class="col mb-3">
|
</div>
|
||||||
<label for="text" class="form-label">{trl("Text")}</label>
|
<div class="btn-group me-2" role="group">
|
||||||
<textarea
|
<ToolButton icon="bx-heading" title="Heading" on_press=move |_| exec("formatBlock", "h3")/>
|
||||||
id="title"
|
<ToolButton icon="bx-paragraph" title="Paragraph" on_press=move |_| exec("formatBlock", "p")/>
|
||||||
class="form-control"
|
</div>
|
||||||
prop:value={move || appearance.get().text.unwrap_or_default()}
|
<div class="btn-group me-2" role="group">
|
||||||
name="appearance[text]"
|
<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>
|
</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>,
|
uploads: RwSignal<u32>,
|
||||||
delete: DialogOpener) -> impl IntoView {
|
delete: DialogOpener) -> impl IntoView {
|
||||||
let file = RwSignal::new_local(None::<File>);
|
let file = RwSignal::new_local(None::<File>);
|
||||||
|
// A translation key; translations are not available in event handlers
|
||||||
let error = RwSignal::new(None::<String>);
|
let error = RwSignal::new(None::<String>);
|
||||||
let input_ref = NodeRef::<leptos::html::Input>::new();
|
let input_ref = NodeRef::<leptos::html::Input>::new();
|
||||||
let upload = Action::new_local(|data: &FormData| upload_image(data.clone().into()));
|
let upload = Action::new_local(|data: &FormData| upload_image(data.clone().into()));
|
||||||
@@ -174,7 +216,7 @@ fn image_upload(
|
|||||||
clear();
|
clear();
|
||||||
uploads.update(|count| *count += 1);
|
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))),
|
Some(Err(e)) => error.set(Some(format!("Server error: {}", e))),
|
||||||
None => {}
|
None => {}
|
||||||
}
|
}
|
||||||
@@ -203,9 +245,9 @@ fn image_upload(
|
|||||||
// The server checks the same again
|
// The server checks the same again
|
||||||
if let Some(chosen) = chosen {
|
if let Some(chosen) = chosen {
|
||||||
if image_extension(&chosen.name()).is_none() {
|
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 {
|
} 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 {
|
} else {
|
||||||
selected.set(Url::create_object_url_with_blob(&chosen).ok().filter(|url| is_object_url(url)));
|
selected.set(Url::create_object_url_with_blob(&chosen).ok().filter(|url| is_object_url(url)));
|
||||||
file.set(Some(chosen));
|
file.set(Some(chosen));
|
||||||
@@ -214,7 +256,7 @@ fn image_upload(
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{move || error.get().map(|e| view! {
|
{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">
|
<div class="mt-2">
|
||||||
{move || if file.get().is_some() {
|
{move || if file.get().is_some() {
|
||||||
@@ -255,7 +297,7 @@ fn image_upload(
|
|||||||
fn preview(look: Look, 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 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 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();
|
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
|
// 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>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</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="card-body public-form">
|
||||||
<div class="row public-form-columns">
|
<div class="row public-form-columns">
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
@@ -440,33 +482,49 @@ fn layout_settings(layout: RwSignal<LayoutOptions>) -> impl IntoView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Everything that is saved together as one look
|
/// Everything that is saved together: title, text, colours, banner options and layout
|
||||||
type SavedLook = (Colors, BannerOptions, LayoutOptions);
|
type Saved = (String, String, Colors, BannerOptions, LayoutOptions);
|
||||||
|
|
||||||
/// Saves colours, banner options and layout together, they make one look
|
|
||||||
#[component]
|
#[component]
|
||||||
fn save_bar(look: Look, saved: RwSignal<SavedLook>) -> impl IntoView {
|
fn save_bar(look: Look, saved: RwSignal<Saved>, editor: NodeRef<leptos::html::Div>) -> impl IntoView {
|
||||||
let save = ServerAction::<UpdateStyle>::new();
|
let save = ServerAction::<SaveAppearance>::new();
|
||||||
let sent = StoredValue::new(SavedLook::default());
|
let sent = StoredValue::new(Saved::default());
|
||||||
let current = move || (look.colors.get(), look.banner.get(), look.layout.get());
|
let current = move || (look.title.get(), look.text.get(), look.colors.get(), look.banner.get(), look.layout.get());
|
||||||
let dirty = move || current() != saved.get();
|
let dirty = move || current() != saved.get();
|
||||||
|
|
||||||
Effect::new(move |_| {
|
Effect::new(move |_| {
|
||||||
if let Some(Ok(ApiResponse::Data(_))) = save.value().get() {
|
if let Some(Ok(ApiResponse::Data(stored_text))) = save.value().get() {
|
||||||
saved.set(sent.get_value());
|
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! {
|
view! {
|
||||||
<div>
|
<div>
|
||||||
<button type="button" class="btn btn-primary me-2" on:click=move |_| {
|
<button type="button" class="btn btn-primary me-2" on:click=move |_| {
|
||||||
let (colors, banner, layout) = current();
|
let (title, text, colors, banner, layout) = current();
|
||||||
sent.set_value((colors.clone(), banner.clone(), layout.clone()));
|
sent.set_value((title.clone(), text.clone(), colors.clone(), banner.clone(), layout.clone()));
|
||||||
save.dispatch(UpdateStyle { colors, banner, layout });
|
save.dispatch(SaveAppearance { title, text, colors, banner, layout });
|
||||||
}>
|
}>
|
||||||
{trl("Save")}
|
{trl("Save")}
|
||||||
</button>
|
</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.colors.set(Colors::default());
|
||||||
look.banner.set(BannerOptions::default());
|
look.banner.set(BannerOptions::default());
|
||||||
look.layout.set(LayoutOptions::default());
|
look.layout.set(LayoutOptions::default());
|
||||||
@@ -502,77 +560,79 @@ fn save_bar(look: Look, saved: RwSignal<SavedLook>) -> impl IntoView {
|
|||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn appearance() -> impl IntoView {
|
pub fn appearance() -> impl IntoView {
|
||||||
let title_edit = DialogOpener::new();
|
|
||||||
let text_edit = DialogOpener::new();
|
|
||||||
let del_banner = DialogOpener::new();
|
let del_banner = DialogOpener::new();
|
||||||
let del_logo = DialogOpener::new();
|
let del_logo = DialogOpener::new();
|
||||||
let uploads = RwSignal::new(0u32);
|
let uploads = RwSignal::new(0u32);
|
||||||
let appearance = Resource::new(
|
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());
|
|_| get_appearance());
|
||||||
let app_edit = RwSignal::new(Appearance::default());
|
|
||||||
let look = Look {
|
let look = Look {
|
||||||
|
title: RwSignal::new(String::new()),
|
||||||
|
text: RwSignal::new(String::new()),
|
||||||
colors: RwSignal::new(Colors::default()),
|
colors: RwSignal::new(Colors::default()),
|
||||||
banner: RwSignal::new(BannerOptions::default()),
|
banner: RwSignal::new(BannerOptions::default()),
|
||||||
layout: RwSignal::new(LayoutOptions::default()),
|
layout: RwSignal::new(LayoutOptions::default()),
|
||||||
new_banner: RwSignal::new(None),
|
new_banner: RwSignal::new(None),
|
||||||
new_logo: 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 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.
|
// 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 |_| {
|
Effect::new(move |_| {
|
||||||
if let Some(Ok(app)) = appearance.get() {
|
if let (Some(element), Some(Ok(app))) = (editor.get(), appearance.get()) {
|
||||||
if !look_loaded.get_value() {
|
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.colors.set(app.colors.clone());
|
||||||
look.banner.set(app.banner_options.clone());
|
look.banner.set(app.banner_options.clone());
|
||||||
look.layout.set(app.layout.clone());
|
look.layout.set(app.layout.clone());
|
||||||
saved.set((app.colors.clone(), app.banner_options.clone(), app.layout.clone()));
|
saved.set((title, text, app.colors.clone(), app.banner_options.clone(), app.layout.clone()));
|
||||||
look_loaded.set_value(true);
|
loaded.set_value(true);
|
||||||
}
|
}
|
||||||
app_preview.set(app);
|
app_preview.set(app);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<EditTitle opener=title_edit appearance=app_edit.read_only()/>
|
|
||||||
<EditText opener=text_edit appearance=app_edit.read_only()/>
|
|
||||||
<DeleteBanner opener=del_banner/>
|
<DeleteBanner opener=del_banner/>
|
||||||
<DeleteLogo opener=del_logo/>
|
<DeleteLogo opener=del_logo/>
|
||||||
<h1>{trl("Appearance settings")}</h1>
|
<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="row mb-3">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title">{trl("Title and text")}</h5>
|
<h5 class="card-title">{trl("Title and text")}</h5>
|
||||||
<Transition fallback=move || view! {<p>{trl("Loading...")}</p> }>
|
<div class="mb-3">
|
||||||
{move || {
|
<label for="title" class="form-label">{trl("Title")}</label>
|
||||||
appearance.get().map(|a| match a {
|
<input
|
||||||
Ok(app) => {
|
type="text"
|
||||||
app_edit.set(app.clone());
|
id="title"
|
||||||
view! {
|
class="form-control"
|
||||||
<div>
|
maxlength=MAX_TITLE_LENGTH.to_string()
|
||||||
<div class="fs-4 mb-2">{app.title.clone().unwrap_or_default()}</div>
|
placeholder={trl("Enter title")}
|
||||||
<button class="btn btn-info" on:click=move |_| title_edit.show()>
|
prop:value=move || look.title.get()
|
||||||
<i class="bx bx-edit-alt me-1"></i> {trl("Edit title")}</button>
|
on:input=move |ev| look.title.set(event_target_value(&ev))
|
||||||
<br/><br/>
|
/>
|
||||||
<div inner_html={app.text.unwrap_or("<< TEXT >>".to_string())}>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<a class="dropdown-item" href="javascript:void(0);" on:click=move |_| text_edit.show()>
|
<div class="form-label" id="text_editor_label">{trl("Text under the banner")}</div>
|
||||||
<i class="bx bx-edit-alt me-1"></i> {trl("Edit text")}</a>
|
<TextEditor text=look.text editor=editor/>
|
||||||
</div>
|
|
||||||
}.into_any()
|
|
||||||
}
|
|
||||||
Err(e) => {view! {<div><p>{trl("Error loading data")}</p>
|
|
||||||
<p>{e.to_string()}</p></div>
|
|
||||||
}.into_any()}
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
</Transition>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -607,7 +667,7 @@ pub fn appearance() -> impl IntoView {
|
|||||||
</div>
|
</div>
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<SaveBar look=look saved=saved/>
|
<SaveBar look=look saved=saved editor=editor/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user