mirror of
https://github.com/adoyle0/leptos-use.git
synced 2025-01-22 16:49:22 -05:00
Merge branch 'main' into mouse-coord-type
This commit is contained in:
commit
ee7934fd7d
10 changed files with 146 additions and 137 deletions
|
@ -11,7 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- `use_clipboard` doesn't need the unstable flags anymore.
|
||||
- `use_locale` now uses `unic_langid::LanguageIdentifier` and proper locale matching (thanks to @mondeja).
|
||||
- Removed `UseMouseEventExtractorDefault` and reworked `UseMouseCoordType` (thanks to @carloskiki)
|
||||
- `use_preferred_dark` and `use_color_mode` now try to read the `Sec-CH-Prefers-Color-Scheme` header in SSR.
|
||||
|
||||
### Fixes 🍕
|
||||
|
||||
- Fixed the codec chapter in the book to refer to crate `codee`.
|
||||
|
||||
## [0.11.4] - 2024-08-12
|
||||
|
||||
|
|
|
@ -34,11 +34,11 @@ num = { version = "0.4", optional = true }
|
|||
paste = "1"
|
||||
thiserror = "1"
|
||||
unic-langid = "0.9"
|
||||
wasm-bindgen = ">=0.2.93"
|
||||
wasm-bindgen = "=0.2.93"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
|
||||
[dependencies.web-sys]
|
||||
version = ">=0.3.70"
|
||||
version = "=0.3.70"
|
||||
features = [
|
||||
"AddEventListenerOptions",
|
||||
"BinaryType",
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
# Encoding and Decoding Data
|
||||
|
||||
Several functions encode and decode data for storing it and/or sending it over the network. To do this, codecs
|
||||
located at [`src/utils/codecs`](https://github.com/Synphonyte/leptos-use/tree/main/src/utils/codecs) are used. They
|
||||
implement the traits [`Encoder`](https://github.com/Synphonyte/leptos-use/blob/main/src/utils/codecs/mod.rs#L9) with the
|
||||
method `encode` and [`Decoder`](https://github.com/Synphonyte/leptos-use/blob/main/src/utils/codecs/mod.rs#L17) with the
|
||||
from the crate [`codee`](https://docs.rs/codee/latest/codee/) are used. They
|
||||
implement the traits [`Encoder`](https://docs.rs/codee/latest/codee/trait.Encoder.html) with the
|
||||
method `encode` and [`Decoder`](https://docs.rs/codee/latest/codee/trait.Decoder.html) with the
|
||||
method `decode`.
|
||||
|
||||
There are two types of codecs: One that encodes as binary data (`Vec[u8]`) and another type that encodes as
|
||||
|
@ -11,26 +11,8 @@ strings (`String`). There is also an adapter
|
|||
[`Base64`](https://github.com/Synphonyte/leptos-use/blob/main/src/utils/codecs/string/base64.rs) that can be used to
|
||||
wrap a binary codec and make it a string codec by representing the binary data as a base64 string.
|
||||
|
||||
## Available Codecs
|
||||
|
||||
### String Codecs
|
||||
|
||||
- [**`FromToStringCodec`
|
||||
**](https://github.com/Synphonyte/leptos-use/blob/main/src/utils/codecs/string/from_to_string.rs)
|
||||
- [**`JsonSerdeCodec`**](https://github.com/Synphonyte/leptos-use/blob/main/src/utils/codecs/string/json_serde.rs)**
|
||||
|
||||
### Binary Codecs
|
||||
|
||||
- [**`FromToBytesCodec`**](https://github.com/Synphonyte/leptos-use/blob/main/src/utils/codecs/binary/from_to_bytes.rs)
|
||||
- [**`BincodeSerdeCodec`**](https://github.com/Synphonyte/leptos-use/blob/main/src/utils/codecs/binary/bincode_serde.rs)
|
||||
- [**`MsgpackSerdeCodec`**](https://github.com/Synphonyte/leptos-use/blob/main/src/utils/codecs/binary/msgpack_serde.rs)
|
||||
|
||||
### Adapters
|
||||
|
||||
- [**`Base64`**](https://github.com/Synphonyte/leptos-use/blob/main/src/utils/codecs/string/base64.rs) —
|
||||
Wraps a binary codec and make it a string codec by representing the binary data as a base64 string.
|
||||
- [**`OptionCodec`**](https://github.com/Synphonyte/leptos-use/blob/main/src/utils/codecs/option.rs) —
|
||||
Wraps a string codec that encodes `T` to create a codec that encodes `Option<T>`.
|
||||
Please check the documentation of [`codee`](https://docs.rs/codee/latest/codee/) for more details and a list of all
|
||||
available codecs.
|
||||
|
||||
## Example
|
||||
|
||||
|
@ -41,6 +23,7 @@ format. Since cookies can only store strings, we have to use string codecs here.
|
|||
# use leptos::*;
|
||||
# use leptos_use::use_cookie;
|
||||
# use serde::{Deserialize, Serialize};
|
||||
# use codee::string::JsonCodec;
|
||||
|
||||
# #[component]
|
||||
# pub fn App(cx: Scope) -> impl IntoView {
|
||||
|
@ -57,100 +40,13 @@ let (cookie, set_cookie) = use_cookie::<MyState, JsonCodec>("my-state-cookie");
|
|||
|
||||
## Custom Codecs
|
||||
|
||||
If you don't find a suitable codecs for your needs, you can implement your own; it's straightforward! If you want to
|
||||
create a string codec, you can look
|
||||
at [`JsonSerdeCodec`](https://github.com/Synphonyte/leptos-use/blob/main/src/utils/codecs/string/json_serde.rs).
|
||||
In case it's a binary codec, have a look
|
||||
at [`BincodeSerdeCodec`](https://github.com/Synphonyte/leptos-use/blob/main/src/utils/codecs/binary/bincode_serde.rs).
|
||||
If you don't find a suitable codec for your needs, you can implement your own; it's straightforward!
|
||||
If you want to create a string codec, you can look at
|
||||
[`JsonSerdeCodec`](https://docs.rs/codee/latest/src/codee/string/json_serde.rs.html).
|
||||
In case it's a binary codec, have a look at
|
||||
[`BincodeSerdeCodec`](https://docs.rs/codee/latest/src/codee/binary/bincode_serde.rs.html).
|
||||
|
||||
## Versioning
|
||||
|
||||
Versioning is the process of handling long-term data that can outlive our code.
|
||||
|
||||
For example, we could have a settings struct whose members change over time. We might eventually
|
||||
add timezone support, and we might then remove support for a thousands separator for numbers.
|
||||
Each change results in a new possible version of the stored data. If we stored these settings
|
||||
in browser storage, we would need to handle all possible versions of the data format that can
|
||||
occur. If we don't offer versioning, then all settings could revert to the default every time we
|
||||
encounter an old format.
|
||||
|
||||
How best to handle versioning depends on the codec involved:
|
||||
|
||||
- The `FromToStringCodec` can avoid versioning entirely by keeping
|
||||
to primitive types. In our example above, we could have decomposed the settings struct into
|
||||
separate timezone and number separator fields. These would be encoded as strings and stored as
|
||||
two separate key-value fields in the browser rather than a single field. If a field is missing,
|
||||
then the value intentionally would fall back to the default without interfering with the other
|
||||
field.
|
||||
|
||||
- The `ProstCodec` uses [Protocol buffers](https://protobuf.dev/overview/)
|
||||
designed to solve the problem of long-term storage. It provides semantics for versioning that
|
||||
are not present in JSON or other formats.
|
||||
|
||||
- The codecs that use serde under the hood can rely on serde or by
|
||||
providing their own manual version handling. See the next sections for more details.
|
||||
|
||||
### Rely on `serde`
|
||||
|
||||
A simple way to avoid complex versioning is to rely on serde's [field attributes](https://serde.rs/field-attrs.html)
|
||||
such as [`serde(default)`](https://serde.rs/field-attrs.html#default)
|
||||
and [`serde(rename = "...")`](https://serde.rs/field-attrs.html#rename).
|
||||
|
||||
### Manual Version Handling
|
||||
|
||||
We look at the example of the `JsonSerdeCodec` in this section.
|
||||
|
||||
To implement version handling, we parse the JSON generically then transform the
|
||||
resulting `JsValue` before decoding it into our struct again.
|
||||
|
||||
Let's look at an example.
|
||||
|
||||
```rust,noplayground
|
||||
# use leptos::*;
|
||||
# use leptos_use::storage::{StorageType, use_local_storage, use_session_storage, use_storage, UseStorageOptions};
|
||||
# use serde::{Deserialize, Serialize};
|
||||
# use serde_json::json;
|
||||
# use leptos_use::utils::{Encoder, Decoder};
|
||||
#
|
||||
# pub fn Demo() -> impl IntoView {
|
||||
#[derive(Serialize, Deserialize, Clone, Default, PartialEq)]
|
||||
pub struct MyState {
|
||||
pub hello: String,
|
||||
// This field was added in a later version
|
||||
pub greeting: String,
|
||||
}
|
||||
|
||||
pub struct MyStateCodec;
|
||||
|
||||
impl Encoder<MyState> for MyStateCodec {
|
||||
type Error = serde_json::Error;
|
||||
type Encoded = String;
|
||||
|
||||
fn encode(val: &MyState) -> Result<Self::Encoded, Self::Error> {
|
||||
serde_json::to_string(val)
|
||||
}
|
||||
}
|
||||
|
||||
impl Decoder<MyState> for MyStateCodec {
|
||||
type Error = serde_json::Error;
|
||||
type Encoded = str;
|
||||
|
||||
fn decode(stored_value: &Self::Encoded) -> Result<MyState, Self::Error> {
|
||||
let mut val: serde_json::Value = serde_json::from_str(stored_value)?;
|
||||
// add "greeting": "Hello" to the object if it's missing
|
||||
if let Some(obj) = val.as_object_mut() {
|
||||
if !obj.contains_key("greeting") {
|
||||
obj.insert("greeting".to_string(), json!("Hello"));
|
||||
}
|
||||
serde_json::from_value(val)
|
||||
} else {
|
||||
Ok(MyState::default())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Then use it like the following just as any other codec.
|
||||
let (get, set, remove) = use_local_storage::<MyState, MyStateCodec>("my-struct-key");
|
||||
# view! { }
|
||||
# }
|
||||
```
|
||||
For a discussion on how to implement versioning please refer to the
|
||||
[relevant section in the docs for `codee`](https://docs.rs/codee/latest/codee/index.html#versioning).
|
|
@ -21,8 +21,9 @@ log = "0.4"
|
|||
simple_logger = "4"
|
||||
tokio = { version = "1", features = ["full"], optional = true }
|
||||
tower = { version = "0.4", optional = true }
|
||||
tower-default-headers = { git = "https://github.com/banool/tower-default-headers-rs" }
|
||||
tower-http = { version = "0.5", features = ["fs"], optional = true }
|
||||
wasm-bindgen = "0.2.92"
|
||||
wasm-bindgen = "=0.2.93"
|
||||
thiserror = "1.0.38"
|
||||
tracing = { version = "0.1.37", optional = true }
|
||||
http = "1"
|
||||
|
|
|
@ -2,11 +2,13 @@
|
|||
#[tokio::main]
|
||||
async fn main() {
|
||||
use axum::{routing::post, Router};
|
||||
use http::{HeaderMap, HeaderName, HeaderValue};
|
||||
use leptos::logging::log;
|
||||
use leptos::*;
|
||||
use leptos_axum::{generate_route_list, LeptosRoutes};
|
||||
use leptos_use_ssr::app::*;
|
||||
use leptos_use_ssr::fileserv::file_and_error_handler;
|
||||
use tower_default_headers::DefaultHeadersLayer;
|
||||
|
||||
simple_logger::init_with_level(log::Level::Info).expect("couldn't initialize logging");
|
||||
|
||||
|
@ -20,12 +22,19 @@ async fn main() {
|
|||
let addr = leptos_options.site_addr;
|
||||
let routes = generate_route_list(|| view! { <App/> });
|
||||
|
||||
let mut default_headers = HeaderMap::new();
|
||||
let color_header = HeaderValue::from_static("Sec-CH-Prefers-Color-Scheme");
|
||||
default_headers.insert(HeaderName::from_static("accept-ch"), color_header.clone());
|
||||
default_headers.insert(HeaderName::from_static("vary"), color_header.clone());
|
||||
default_headers.insert(HeaderName::from_static("critical-ch"), color_header);
|
||||
|
||||
// build our application with a route
|
||||
let app = Router::new()
|
||||
.route("/api/*fn_name", post(leptos_axum::handle_server_fns))
|
||||
.leptos_routes(&leptos_options, routes, || view! { <App/> })
|
||||
.fallback(file_and_error_handler)
|
||||
.with_state(leptos_options);
|
||||
.with_state(leptos_options)
|
||||
.layer(DefaultHeadersLayer::new(default_headers));
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
|
||||
log!("listening on http://{}", &addr);
|
||||
|
|
|
@ -2,7 +2,11 @@ use crate::core::url;
|
|||
use crate::core::StorageType;
|
||||
use crate::core::{ElementMaybeSignal, MaybeRwSignal};
|
||||
use crate::storage::{use_storage_with_options, UseStorageOptions};
|
||||
use crate::{sync_signal_with_options, use_cookie, use_preferred_dark, SyncSignalOptions};
|
||||
use crate::utils::get_header;
|
||||
use crate::{
|
||||
sync_signal_with_options, use_cookie, use_preferred_dark_with_options,
|
||||
SyncSignalOptions, UsePreferredDarkOptions,
|
||||
};
|
||||
use codee::string::FromToStringCodec;
|
||||
use default_struct_builder::DefaultBuilder;
|
||||
use leptos::*;
|
||||
|
@ -83,7 +87,7 @@ use wasm_bindgen::JsCast;
|
|||
/// # }
|
||||
/// ```
|
||||
///
|
||||
/// ### Cookies
|
||||
/// ### Cookie
|
||||
///
|
||||
/// To persist color mode in a cookie, use `use_cookie_with_options` and specify `.cookie_enabled(true)`.
|
||||
///
|
||||
|
@ -112,9 +116,24 @@ use wasm_bindgen::JsCast;
|
|||
///
|
||||
/// ## Server-Side Rendering
|
||||
///
|
||||
/// On the server this will by default return `ColorMode::Light`. Persistence with storage is disabled.
|
||||
/// On the server this will try to read the
|
||||
/// [`Sec-CH-Prefers-Color-Scheme` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-CH-Prefers-Color-Scheme)
|
||||
/// to determine the color mode. If the header is not present it will return `ColorMode::Light`.
|
||||
/// Please have a look at the linked documentation above for that header to see browser support
|
||||
/// as well as potential server requirements.
|
||||
///
|
||||
/// If `cookie_enabled` is set to `true`, cookies will be used and if present this value will be used
|
||||
/// > If you're using `axum` you have to enable the `"axum"` feature in your Cargo.toml.
|
||||
/// > In case it's `actix-web` enable the feature `"actix"`, for `spin` enable `"spin"`.
|
||||
///
|
||||
/// ### Bring your own header
|
||||
///
|
||||
/// In case you're neither using Axum, Actix nor Spin, or the default implementation is not to your liking,
|
||||
/// you can provide your own way of reading the color scheme header value using the option
|
||||
/// [`crate::UseColorModeOptions::ssr_color_header_getter`].
|
||||
///
|
||||
/// ### Cookie
|
||||
///
|
||||
/// If `cookie_enabled` is set to `true`, a cookie will be used and if present this value will be used
|
||||
/// on the server as well as on the client. Please note that you have to add the `axum` or `actix`
|
||||
/// feature as described in [`fn@crate::use_cookie`].
|
||||
///
|
||||
|
@ -151,6 +170,7 @@ where
|
|||
emit_auto,
|
||||
transition_enabled,
|
||||
listen_to_storage_changes,
|
||||
ssr_color_header_getter,
|
||||
_marker,
|
||||
} = options;
|
||||
|
||||
|
@ -162,7 +182,9 @@ where
|
|||
])
|
||||
.collect();
|
||||
|
||||
let preferred_dark = use_preferred_dark();
|
||||
let preferred_dark = use_preferred_dark_with_options(UsePreferredDarkOptions {
|
||||
ssr_color_header_getter,
|
||||
});
|
||||
|
||||
let system = Signal::derive(move || {
|
||||
if preferred_dark.get() {
|
||||
|
@ -471,6 +493,14 @@ where
|
|||
/// Defaults to true.
|
||||
listen_to_storage_changes: bool,
|
||||
|
||||
/// Getter function to return the string value of the
|
||||
/// [`Sec-CH-Prefers-Color-Scheme`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-CH-Prefers-Color-Scheme)
|
||||
/// header.
|
||||
/// When you use one of the features `"axum"`, `"actix"` or `"spin"` there's a valid default
|
||||
/// implementation provided.
|
||||
#[allow(dead_code)]
|
||||
ssr_color_header_getter: Rc<dyn Fn() -> Option<String>>,
|
||||
|
||||
#[builder(skip)]
|
||||
_marker: PhantomData<T>,
|
||||
}
|
||||
|
@ -496,6 +526,13 @@ impl Default for UseColorModeOptions<&'static str, web_sys::Element> {
|
|||
emit_auto: false,
|
||||
transition_enabled: false,
|
||||
listen_to_storage_changes: true,
|
||||
ssr_color_header_getter: Rc::new(move || {
|
||||
get_header!(
|
||||
HeaderName::from_static("sec-ch-prefers-color-scheme"),
|
||||
use_locale,
|
||||
ssr_color_header_getter
|
||||
)
|
||||
}),
|
||||
_marker: PhantomData,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -59,12 +59,13 @@ where
|
|||
.collect::<Vec<_>>();
|
||||
|
||||
const EMPTY_ERR_MSG: &str = "Empty supported list. You have to provide at least one locale in the `supported` parameter";
|
||||
|
||||
assert!(!supported.is_empty(), "{}", EMPTY_ERR_MSG);
|
||||
|
||||
Signal::derive(move || {
|
||||
let supported = supported.clone();
|
||||
|
||||
client_locales.with(|clienht_locales| {
|
||||
client_locales.with(|client_locales| {
|
||||
let mut first_supported = None;
|
||||
|
||||
for s in supported {
|
||||
|
@ -72,7 +73,7 @@ where
|
|||
first_supported = Some(s.clone());
|
||||
}
|
||||
|
||||
for client_locale in clienht_locales {
|
||||
for client_locale in client_locales {
|
||||
let client_locale: LanguageIdentifier = client_locale
|
||||
.parse()
|
||||
.expect("Client should provide a list of valid unicode locales");
|
||||
|
|
|
@ -39,7 +39,7 @@ use std::rc::Rc;
|
|||
/// ### Bring your own header
|
||||
///
|
||||
/// In case you're neither using Axum, Actix nor Spin, or the default implementation is not to your liking,
|
||||
/// you can provide your own way of reading and writing the cookie header value using the option
|
||||
/// you can provide your own way of reading the language header value using the option
|
||||
/// [`crate::UseLocalesOptions::ssr_lang_header_getter`].
|
||||
pub fn use_locales() -> Signal<Vec<String>> {
|
||||
use_locales_with_options(UseLocalesOptions::default())
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
use crate::use_media_query;
|
||||
use crate::utils::get_header;
|
||||
use default_struct_builder::DefaultBuilder;
|
||||
use leptos::*;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// Reactive [dark theme preference](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme).
|
||||
///
|
||||
|
@ -20,12 +22,66 @@ use leptos::*;
|
|||
///
|
||||
/// ## Server-Side Rendering
|
||||
///
|
||||
/// On the server this functions returns a Signal that is always `false`.
|
||||
/// On the server this will try to read the
|
||||
/// [`Sec-CH-Prefers-Color-Scheme` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-CH-Prefers-Color-Scheme)
|
||||
/// to determine the color mode. If the header is not present it will return `ColorMode::Light`.
|
||||
/// Please have a look at the linked documentation above for that header to see browser support
|
||||
/// as well as potential server requirements.
|
||||
///
|
||||
/// > If you're using `axum` you have to enable the `"axum"` feature in your Cargo.toml.
|
||||
/// > In case it's `actix-web` enable the feature `"actix"`, for `spin` enable `"spin"`.
|
||||
///
|
||||
/// ### Bring your own header
|
||||
///
|
||||
/// In case you're neither using Axum, Actix nor Spin, or the default implementation is not to your liking,
|
||||
/// you can provide your own way of reading the color scheme header value using the option
|
||||
/// [`crate::UsePreferredDarkOptions::ssr_color_header_getter`].
|
||||
///
|
||||
/// ## See also
|
||||
///
|
||||
/// * [`fn@crate::use_media_query`]
|
||||
/// * [`fn@crate::use_preferred_contrast`]
|
||||
pub fn use_preferred_dark() -> Signal<bool> {
|
||||
use_media_query("(prefers-color-scheme: dark)")
|
||||
use_preferred_dark_with_options(Default::default())
|
||||
}
|
||||
|
||||
/// Version of [`fn@crate::use_preferred_dark`] that accepts a `UsePreferredDarkOptions`.
|
||||
pub fn use_preferred_dark_with_options(options: UsePreferredDarkOptions) -> Signal<bool> {
|
||||
#[cfg(not(feature = "ssr"))]
|
||||
{
|
||||
let _ = options;
|
||||
|
||||
crate::use_media_query("(prefers-color-scheme: dark)")
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
{
|
||||
Signal::derive(move || (options.ssr_color_header_getter)() == Some("dark".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Options for [`fn@crate::use_preferred_dark_with_options`].
|
||||
#[derive(DefaultBuilder)]
|
||||
pub struct UsePreferredDarkOptions {
|
||||
/// Getter function to return the string value of the
|
||||
/// [`Sec-CH-Prefers-Color-Scheme`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-CH-Prefers-Color-Scheme)
|
||||
/// header.
|
||||
/// When you use one of the features `"axum"`, `"actix"` or `"spin"` there's a valid default
|
||||
/// implementation provided.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) ssr_color_header_getter: Rc<dyn Fn() -> Option<String>>,
|
||||
}
|
||||
|
||||
impl Default for UsePreferredDarkOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
ssr_color_header_getter: Rc::new(move || {
|
||||
get_header!(
|
||||
HeaderName::from_static("sec-ch-prefers-color-scheme"),
|
||||
use_locale,
|
||||
ssr_color_header_getter
|
||||
)
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
macro_rules! get_header {
|
||||
(
|
||||
$header_name:ident,
|
||||
$header_name:expr,
|
||||
$function_name:ident,
|
||||
$option_name:ident
|
||||
$(,)?
|
||||
|
@ -21,12 +21,17 @@ macro_rules! get_header {
|
|||
}
|
||||
|
||||
#[cfg(feature = "actix")]
|
||||
const $header_name: http0_2::HeaderName = http0_2::header::$header_name;
|
||||
#[allow(unused_imports)]
|
||||
use http0_2::{HeaderName, header::*};
|
||||
#[cfg(any(feature = "axum", feature = "spin"))]
|
||||
const $header_name: http1::HeaderName = http1::header::$header_name;
|
||||
#[allow(unused_imports)]
|
||||
use http1::{HeaderName, header::*};
|
||||
|
||||
#[cfg(any(feature = "axum", feature = "actix", feature = "spin"))]
|
||||
crate::utils::header($header_name)
|
||||
{
|
||||
let header_name = $header_name;
|
||||
crate::utils::header(header_name)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue