✨ (auth): Añade idioma y tema preferidos al usuario
- CurrentUser pasa de enum a struct con `id` opcional (anónimo = None) y preferencias de idioma, zona horaria y tema validadas en `with_*()`. - `RequestLocale` tiene en cuenta el idioma preferido del usuario, y Context usa su tema si lo tiene (`default_theme()` en otro caso). - Nuevos componentes `form::SelectLanguage`, `form::SelectTheme` y `form::SelectTimezone`, con `Timezone::supported_by_region()` para listar y validar las zonas IANA ofrecidas.
This commit is contained in:
parent
35f2b66161
commit
70744a51a5
25 changed files with 1162 additions and 190 deletions
178
src/auth.rs
178
src/auth.rs
|
|
@ -17,11 +17,14 @@
|
|||
//! [`Context`]: crate::core::component::Context
|
||||
|
||||
use crate::core::action::{ActionDispatcher, try_dispatch_actions};
|
||||
use crate::core::theme::{ThemeRef, theme_by_short_name};
|
||||
use crate::datetime::{Timezone, Tz};
|
||||
use crate::locale::Lc;
|
||||
use crate::locale::{LanguageIdentifier, Lc, Locale};
|
||||
use crate::response::ErrorPage;
|
||||
use crate::web::HttpRequest;
|
||||
use crate::{CowStr, Weight};
|
||||
use crate::{AutoDefault, CowStr, Getters, Weight, builder_impl};
|
||||
|
||||
use std::ops::ControlFlow;
|
||||
|
||||
// **< CurrentUser >********************************************************************************
|
||||
|
||||
|
|
@ -29,76 +32,133 @@ use crate::{CowStr, Weight};
|
|||
///
|
||||
/// Se almacena automáticamente en el [`Context`] a partir de la petición HTTP. La identidad se
|
||||
/// extrae de las extensiones de la petición, que una extensión de autenticación inyecta mediante su
|
||||
/// middleware.
|
||||
/// middleware. Sin extensión de autenticación, o si ésta no inyecta ninguna identidad, el usuario
|
||||
/// es anónimo ([`CurrentUser::anonymous()`], que también es el valor por defecto).
|
||||
///
|
||||
/// Se accede usando [`Contextual::current_user()`].
|
||||
///
|
||||
/// Los usuarios pueden tener idioma, zona horaria y tema preferidos. Se asignan con su valor en
|
||||
/// bruto y se validan al asignarlos: un idioma no soportado, una zona horaria desconocida o un tema
|
||||
/// no habilitado en la aplicación se descartan y el dato queda sin valor, como si el usuario no
|
||||
/// tuviera ninguno. Así, las preferencias de un `CurrentUser` son siempre válidas.
|
||||
///
|
||||
/// Los datos extendidos del usuario autenticado (roles, permisos, cuenta completa, ...) son
|
||||
/// responsabilidad de la extensión de autenticación y se obtienen a través de
|
||||
/// [`HttpRequest::extension`].
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pagetop::prelude::*;
|
||||
/// let user = CurrentUser::authenticated(42, "Alice")
|
||||
/// .with_language("es-ES")
|
||||
/// .with_timezone("Europe/Madrid");
|
||||
/// ```
|
||||
///
|
||||
/// [`Context`]: crate::core::component::Context
|
||||
/// [`Contextual::current_user()`]: crate::core::component::Contextual::current_user
|
||||
/// [`HttpRequest::extension`]: crate::web::HttpRequest::extension
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum CurrentUser {
|
||||
/// Usuario no autenticado.
|
||||
Anonymous,
|
||||
/// Usuario autenticado con su identificador, nombre visible y zona horaria propia.
|
||||
Authenticated {
|
||||
/// Identificador único del usuario en el sistema.
|
||||
id: i32,
|
||||
/// Nombre visible del usuario.
|
||||
display_name: String,
|
||||
/// Zona horaria del usuario, si tiene una configurada y es válida. En otro caso valdrá
|
||||
/// `None` y [`timezone()`](Self::timezone) devolverá la zona horaria predeterminada de la
|
||||
/// aplicación.
|
||||
timezone: Option<Tz>,
|
||||
},
|
||||
#[derive(AutoDefault, Clone, Debug, Getters)]
|
||||
pub struct CurrentUser {
|
||||
/// Devuelve el identificador del usuario, o `None` si es anónimo.
|
||||
#[getters(copy)]
|
||||
id: Option<i32>,
|
||||
// Siempre `Some` en un usuario autenticado y `None` en uno anónimo, igual que `id`.
|
||||
#[getters(skip)]
|
||||
display_name: Option<String>,
|
||||
/// Devuelve el idioma preferido del usuario, o `None` si no tiene ninguno.
|
||||
///
|
||||
/// Lo tiene en cuenta [`RequestLocale`](crate::locale::RequestLocale) al decidir el idioma de
|
||||
/// la petición.
|
||||
#[getters(copy)]
|
||||
language: Option<&'static LanguageIdentifier>,
|
||||
// Ver `timezone()`, que devuelve la zona horaria efectiva.
|
||||
#[getters(skip)]
|
||||
timezone: Option<Tz>,
|
||||
/// Devuelve el tema preferido del usuario, o `None` si no tiene ninguno.
|
||||
///
|
||||
/// Lo tiene en cuenta el [`Context`](crate::core::component::Context) de la petición al elegir
|
||||
/// el tema con el que se renderiza.
|
||||
#[getters(copy)]
|
||||
theme: Option<ThemeRef>,
|
||||
}
|
||||
|
||||
#[builder_impl]
|
||||
impl CurrentUser {
|
||||
/// Crea un usuario anónimo, sin idioma, zona horaria ni tema preferidos.
|
||||
pub fn anonymous() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Crea un usuario autenticado, sin idioma, zona horaria ni tema preferidos.
|
||||
pub fn authenticated(id: i32, display_name: impl Into<String>) -> Self {
|
||||
CurrentUser {
|
||||
id: Some(id),
|
||||
display_name: Some(display_name.into()),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
// **< CurrentUser BUILDER >********************************************************************
|
||||
|
||||
/// Asigna el idioma preferido a partir de su identificador (p. ej. `"es-ES"` o `"es"`).
|
||||
///
|
||||
/// Se resuelve con [`Locale::resolve()`](crate::locale::Locale::resolve); si el idioma no está
|
||||
/// soportado por la aplicación, o es `None`, el usuario queda sin idioma preferido.
|
||||
pub fn with_language<'a>(mut self, language: impl Into<Option<&'a str>>) -> Self {
|
||||
self.language = language
|
||||
.into()
|
||||
.and_then(|language| Locale::resolve(language).as_option());
|
||||
self
|
||||
}
|
||||
|
||||
/// Asigna la zona horaria a partir de su nombre IANA (p. ej. `"Europe/Madrid"`).
|
||||
///
|
||||
/// Si el nombre no corresponde a ninguna zona horaria conocida, o es `None`, el usuario queda
|
||||
/// sin zona horaria propia.
|
||||
pub fn with_timezone<'a>(mut self, timezone: impl Into<Option<&'a str>>) -> Self {
|
||||
self.timezone = timezone.into().and_then(|timezone| timezone.parse().ok());
|
||||
self
|
||||
}
|
||||
|
||||
/// Asigna el tema preferido a partir de su nombre corto (p. ej. `"basic"`).
|
||||
///
|
||||
/// Se busca con [`theme_by_short_name()`](crate::core::theme::theme_by_short_name); si el tema
|
||||
/// no está habilitado en la aplicación, o es `None`, el usuario queda sin tema preferido.
|
||||
pub fn with_theme<'a>(mut self, theme: impl Into<Option<&'a str>>) -> Self {
|
||||
self.theme = theme.into().and_then(theme_by_short_name);
|
||||
self
|
||||
}
|
||||
|
||||
// **< CurrentUser GETTERS >********************************************************************
|
||||
|
||||
/// Devuelve `true` si el usuario no está autenticado.
|
||||
pub fn is_anonymous(&self) -> bool {
|
||||
matches!(self, CurrentUser::Anonymous)
|
||||
self.id.is_none()
|
||||
}
|
||||
|
||||
/// Devuelve `true` si el usuario está autenticado.
|
||||
pub fn is_authenticated(&self) -> bool {
|
||||
matches!(self, CurrentUser::Authenticated { .. })
|
||||
}
|
||||
|
||||
/// Devuelve el identificador del usuario, o `None` si es anónimo.
|
||||
pub fn id(&self) -> Option<i32> {
|
||||
match self {
|
||||
CurrentUser::Anonymous => None,
|
||||
CurrentUser::Authenticated { id, .. } => Some(*id),
|
||||
}
|
||||
self.id.is_some()
|
||||
}
|
||||
|
||||
/// Devuelve el nombre visible del usuario, o `None` si es anónimo.
|
||||
pub fn display_name(&self) -> Option<&str> {
|
||||
match self {
|
||||
CurrentUser::Anonymous => None,
|
||||
CurrentUser::Authenticated { display_name, .. } => Some(display_name),
|
||||
}
|
||||
self.display_name.as_deref()
|
||||
}
|
||||
|
||||
/// Devuelve la zona horaria efectiva del usuario.
|
||||
///
|
||||
/// Un usuario autenticado devuelve la suya si tiene una configurada y es válida; en cualquier
|
||||
/// otro caso (incluido el usuario anónimo), devuelve [`Timezone::default_tz()`].
|
||||
/// Devuelve la suya si tiene una; en otro caso, devuelve [`Timezone::default_tz()`].
|
||||
///
|
||||
/// Normalmente se resuelve una sola vez, al construir el `Context` de la petición. A partir de
|
||||
/// ese momento el renderizado del documento no vuelve a llamarlo porque usa el valor ya
|
||||
/// resuelto vía [`Contextual::timezone()`](crate::core::component::Contextual::timezone).
|
||||
/// Normalmente se resuelve una sola vez, al construir el [`Context`] de la petición. A partir
|
||||
/// de ese momento el renderizado del documento no vuelve a llamarlo porque usa el valor ya
|
||||
/// resuelto vía [`Contextual::timezone()`].
|
||||
///
|
||||
/// [`Context`]: crate::core::component::Context
|
||||
/// [`Contextual::timezone()`]: crate::core::component::Contextual::timezone
|
||||
pub fn timezone(&self) -> Tz {
|
||||
match self {
|
||||
CurrentUser::Anonymous => Timezone::default_tz(),
|
||||
CurrentUser::Authenticated { timezone, .. } => {
|
||||
timezone.unwrap_or_else(Timezone::default_tz)
|
||||
}
|
||||
}
|
||||
self.timezone.unwrap_or_else(Timezone::default_tz)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -113,8 +173,7 @@ impl CurrentUser {
|
|||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pagetop::auth::Permission;
|
||||
/// # use pagetop::CowStr;
|
||||
/// # use pagetop::prelude::*;
|
||||
/// #[derive(Clone, Copy, Debug)]
|
||||
/// pub enum MyPermission {
|
||||
/// EditPosts,
|
||||
|
|
@ -222,21 +281,6 @@ impl CheckPermission {
|
|||
self.weight = value;
|
||||
self
|
||||
}
|
||||
|
||||
// Despacha las acciones registradas con salida anticipada en cuanto una concede el permiso.
|
||||
#[inline]
|
||||
pub(crate) fn check(request: &HttpRequest, perm: PermissionRef) -> bool {
|
||||
let mut granted = false;
|
||||
try_dispatch_actions(|action: &Self| {
|
||||
(action.f)(request, perm, &mut granted);
|
||||
if granted {
|
||||
std::ops::ControlFlow::Break(())
|
||||
} else {
|
||||
std::ops::ControlFlow::Continue(())
|
||||
}
|
||||
});
|
||||
granted
|
||||
}
|
||||
}
|
||||
|
||||
// **< has_permission >*****************************************************************************
|
||||
|
|
@ -267,7 +311,17 @@ impl CheckPermission {
|
|||
/// }
|
||||
/// ```
|
||||
pub fn has_permission(request: &HttpRequest, perm: PermissionRef) -> bool {
|
||||
CheckPermission::check(request, perm)
|
||||
// Despacha las acciones registradas con salida anticipada en cuanto una concede el permiso.
|
||||
let mut granted = false;
|
||||
try_dispatch_actions(|action: &CheckPermission| {
|
||||
(action.f)(request, perm, &mut granted);
|
||||
if granted {
|
||||
ControlFlow::Break(())
|
||||
} else {
|
||||
ControlFlow::Continue(())
|
||||
}
|
||||
});
|
||||
granted
|
||||
}
|
||||
|
||||
// **< require_permission >*************************************************************************
|
||||
|
|
@ -303,8 +357,6 @@ pub fn has_permission(request: &HttpRequest, perm: PermissionRef) -> bool {
|
|||
/// .await
|
||||
/// }
|
||||
/// ```
|
||||
// `ErrorPage` incluye `Option<HttpRequest>` en cada variante y es el tipo de error ya establecido
|
||||
// para toda la respuesta HTTP; boxearlo aquí sólo para esta función no compensa.
|
||||
pub fn require_permission(request: &HttpRequest, perm: PermissionRef) -> Result<(), ErrorPage> {
|
||||
if has_permission(request, perm) {
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -18,6 +18,15 @@ pub mod radio;
|
|||
|
||||
pub mod select;
|
||||
|
||||
mod select_language;
|
||||
pub use select_language::SelectLanguage;
|
||||
|
||||
mod select_theme;
|
||||
pub use select_theme::SelectTheme;
|
||||
|
||||
mod select_timezone;
|
||||
pub use select_timezone::SelectTimezone;
|
||||
|
||||
pub mod input;
|
||||
|
||||
mod number;
|
||||
|
|
|
|||
130
src/base/component/form/select_language.rs
Normal file
130
src/base/component/form/select_language.rs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
use crate::prelude::*;
|
||||
|
||||
/// Componente para **elegir un idioma** de la lista de idiomas soportados por PageTop.
|
||||
///
|
||||
/// Ofrece un elemento por cada idioma de [`Locale::supported_languages()`], con su identificador
|
||||
/// como valor (p. ej. `"es-ES"`) y su nombre traducido como etiqueta, ordenados por ese nombre en
|
||||
/// el idioma de la página. Se renderiza como cualquier [`form::select::Field`].
|
||||
///
|
||||
/// La primera opción, con valor vacío, depende de si el campo es obligatorio:
|
||||
///
|
||||
/// - Si no lo es (por defecto), siempre se incluye y propone usar el idioma del sitio. Quedarse sin
|
||||
/// idioma propio es válido y significa usar el predeterminado de la aplicación. Se selecciona
|
||||
/// cuando el valor elegido no corresponde a ningún idioma de la lista.
|
||||
/// - Si lo es ([`with_required(true)`](Self::with_required)), sólo se incluye cuando el valor
|
||||
/// seleccionado no corresponde a ningún idioma de la lista, y pide elegir uno; así el navegador
|
||||
/// no deja enviar el formulario sin elegir un idioma. El valor recibido debe validarse
|
||||
/// igualmente en el servidor.
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pagetop::prelude::*;
|
||||
/// let language = form::SelectLanguage::new()
|
||||
/// .with_name("language")
|
||||
/// .with_label(Lc::n("Language"))
|
||||
/// .with_selected("es-ES");
|
||||
/// ```
|
||||
#[derive(AutoDefault, Clone, Debug, Getters)]
|
||||
pub struct SelectLanguage {
|
||||
/// Devuelve la lista de selección interna con la configuración común (nombre, etiqueta, ayuda,
|
||||
/// propiedades...), todavía sin opciones; éstas se añaden al renderizar.
|
||||
field: form::select::Field,
|
||||
/// Devuelve el identificador del idioma seleccionado.
|
||||
selected: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Component for SelectLanguage {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn id(&self) -> Option<String> {
|
||||
self.field().id()
|
||||
}
|
||||
|
||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||
let mut field = self.field().clone();
|
||||
let mut languages = Locale::supported_languages();
|
||||
languages.sort_by_cached_key(|(_, name)| name.collation_key(&*cx));
|
||||
|
||||
let selected = Locale::resolve(self.selected()).as_option();
|
||||
let known = selected.is_some();
|
||||
if !field.required() {
|
||||
let default_langid = Locale::default_langid();
|
||||
let default_name = languages
|
||||
.iter()
|
||||
.find(|(langid, _)| *langid == default_langid)
|
||||
.and_then(|(_, name)| name.lookup(cx))
|
||||
.unwrap_or_else(|| default_langid.to_string());
|
||||
let label = Lc::l("select_language_site_default").with_arg("language", default_name);
|
||||
field.alter_item(form::select::Item::new("", label).with_selected(!known));
|
||||
} else if !known {
|
||||
let label = Lc::l("select_language_placeholder");
|
||||
field.alter_item(form::select::Item::new("", label).with_selected(true));
|
||||
}
|
||||
|
||||
for (langid, name) in languages {
|
||||
let item = form::select::Item::new(langid.to_string(), name);
|
||||
field.alter_item(item.with_selected(selected == Some(langid)));
|
||||
}
|
||||
Ok(field.render(cx).await)
|
||||
}
|
||||
}
|
||||
|
||||
#[builder_impl]
|
||||
impl SelectLanguage {
|
||||
// **< SelectLanguage BUILDER >*****************************************************************
|
||||
|
||||
/// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`.
|
||||
pub fn with_id(mut self, id: impl Into<CowStr>) -> Self {
|
||||
self.field.alter_id(id);
|
||||
self
|
||||
}
|
||||
|
||||
/// Modifica identificador, clases CSS, atributos HTML o valores extra del componente.
|
||||
pub fn with_prop(mut self, op: impl Into<PropsOp>) -> Self {
|
||||
self.field.alter_prop(op);
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece el nombre del campo.
|
||||
pub fn with_name(mut self, name: impl AsRef<str>) -> Self {
|
||||
self.field.alter_name(name);
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece la etiqueta del campo.
|
||||
pub fn with_label(mut self, label: Lc) -> Self {
|
||||
self.field.alter_label(label);
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece el texto de ayuda del campo.
|
||||
pub fn with_help_text(mut self, help_text: Lc) -> Self {
|
||||
self.field.alter_help_text(help_text);
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece si el campo es obligatorio, lo que cambia su primera opción (ver
|
||||
/// [`SelectLanguage`]).
|
||||
pub fn with_required(mut self, required: bool) -> Self {
|
||||
self.field.alter_required(required);
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece si el campo está deshabilitado.
|
||||
pub fn with_disabled(mut self, disabled: bool) -> Self {
|
||||
self.field.alter_disabled(disabled);
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece el identificador del idioma seleccionado (p. ej. `"es-ES"`). Se resuelve con
|
||||
/// [`Locale::resolve()`], así que también acepta alias o variantes (`"es"`, `"es-es"`...) del
|
||||
/// mismo idioma.
|
||||
pub fn with_selected(mut self, selected: impl Into<String>) -> Self {
|
||||
self.selected = selected.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
140
src/base/component/form/select_theme.rs
Normal file
140
src/base/component/form/select_theme.rs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
use crate::prelude::*;
|
||||
|
||||
/// Componente para **elegir un tema** de los temas habilitados en la aplicación.
|
||||
///
|
||||
/// Ofrece un elemento por cada tema de [`enabled_themes()`], con su nombre corto como valor (p. ej.
|
||||
/// `"Bootsier"`) y su nombre traducido como etiqueta, ordenados por ese nombre en el idioma de la
|
||||
/// página. Se renderiza como cualquier [`form::select::Field`].
|
||||
///
|
||||
/// Si sólo hay un tema habilitado, la lista se muestra deshabilitada con ese tema seleccionado (un
|
||||
/// campo deshabilitado no se envía con el formulario). Si hay varios, la primera opción, con valor
|
||||
/// vacío, depende de si el campo es obligatorio:
|
||||
///
|
||||
/// - Si no lo es (por defecto), siempre se incluye y propone usar el tema del sitio. Quedarse sin
|
||||
/// tema propio es válido y significa usar el predeterminado de la aplicación. Se selecciona
|
||||
/// cuando el valor elegido no corresponde a ningún tema de la lista (sin distinguir mayúsculas y
|
||||
/// minúsculas).
|
||||
/// - Si lo es ([`with_required(true)`](Self::with_required)), sólo se incluye cuando el valor
|
||||
/// seleccionado no corresponde a ningún tema de la lista, y pide elegir uno; así el navegador no
|
||||
/// deja enviar el formulario sin elegir un tema. El valor recibido debe validarse igualmente en
|
||||
/// el servidor.
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pagetop::prelude::*;
|
||||
/// let theme = form::SelectTheme::new()
|
||||
/// .with_name("theme")
|
||||
/// .with_label(Lc::n("Theme"))
|
||||
/// .with_selected("Basic");
|
||||
/// ```
|
||||
#[derive(AutoDefault, Clone, Debug, Getters)]
|
||||
pub struct SelectTheme {
|
||||
/// Devuelve la lista de selección interna con la configuración común (nombre, etiqueta, ayuda,
|
||||
/// propiedades...), todavía sin opciones; éstas se añaden al renderizar.
|
||||
field: form::select::Field,
|
||||
/// Devuelve el nombre corto del tema seleccionado.
|
||||
selected: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Component for SelectTheme {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn id(&self) -> Option<String> {
|
||||
self.field().id()
|
||||
}
|
||||
|
||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||
let mut field = self.field().clone();
|
||||
let mut themes = enabled_themes();
|
||||
|
||||
if let [theme] = themes[..] {
|
||||
field.alter_disabled(true);
|
||||
field.alter_item(
|
||||
form::select::Item::new(theme.short_name(), theme.name()).with_selected(true),
|
||||
);
|
||||
return Ok(field.render(cx).await);
|
||||
}
|
||||
|
||||
themes.sort_by_cached_key(|theme| theme.name().collation_key(&*cx));
|
||||
let is_selected =
|
||||
|theme: ThemeRef| theme.short_name().eq_ignore_ascii_case(self.selected());
|
||||
|
||||
let known = themes.iter().any(|theme| is_selected(*theme));
|
||||
if !field.required() {
|
||||
let default_theme = default_theme();
|
||||
let default_name = default_theme
|
||||
.name()
|
||||
.lookup(cx)
|
||||
.unwrap_or_else(|| default_theme.short_name().to_owned());
|
||||
let label = Lc::l("select_theme_site_default").with_arg("theme", default_name);
|
||||
field.alter_item(form::select::Item::new("", label).with_selected(!known));
|
||||
} else if !known {
|
||||
let label = Lc::l("select_theme_placeholder");
|
||||
field.alter_item(form::select::Item::new("", label).with_selected(true));
|
||||
}
|
||||
|
||||
for theme in themes {
|
||||
let item = form::select::Item::new(theme.short_name(), theme.name());
|
||||
field.alter_item(item.with_selected(is_selected(theme)));
|
||||
}
|
||||
Ok(field.render(cx).await)
|
||||
}
|
||||
}
|
||||
|
||||
#[builder_impl]
|
||||
impl SelectTheme {
|
||||
// **< SelectTheme BUILDER >********************************************************************
|
||||
|
||||
/// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`.
|
||||
pub fn with_id(mut self, id: impl Into<CowStr>) -> Self {
|
||||
self.field.alter_id(id);
|
||||
self
|
||||
}
|
||||
|
||||
/// Modifica identificador, clases CSS, atributos HTML o valores extra del componente.
|
||||
pub fn with_prop(mut self, op: impl Into<PropsOp>) -> Self {
|
||||
self.field.alter_prop(op);
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece el nombre del campo.
|
||||
pub fn with_name(mut self, name: impl AsRef<str>) -> Self {
|
||||
self.field.alter_name(name);
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece la etiqueta del campo.
|
||||
pub fn with_label(mut self, label: Lc) -> Self {
|
||||
self.field.alter_label(label);
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece el texto de ayuda del campo.
|
||||
pub fn with_help_text(mut self, help_text: Lc) -> Self {
|
||||
self.field.alter_help_text(help_text);
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece si el campo es obligatorio, lo que cambia su primera opción (ver [`SelectTheme`]).
|
||||
pub fn with_required(mut self, required: bool) -> Self {
|
||||
self.field.alter_required(required);
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece si el campo está deshabilitado. Con un solo tema habilitado lo está siempre.
|
||||
pub fn with_disabled(mut self, disabled: bool) -> Self {
|
||||
self.field.alter_disabled(disabled);
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece el nombre corto del tema seleccionado (p. ej. `"Bootsier"`). Vacío, o uno que no
|
||||
/// esté habilitado, selecciona la primera opción (ver [`SelectTheme`]).
|
||||
pub fn with_selected(mut self, selected: impl Into<String>) -> Self {
|
||||
self.selected = selected.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
142
src/base/component/form/select_timezone.rs
Normal file
142
src/base/component/form/select_timezone.rs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
use crate::prelude::*;
|
||||
|
||||
/// Componente para crear una **lista de selección de zonas horarias** IANA.
|
||||
///
|
||||
/// Ofrece las zonas horarias de [`Timezone::supported_by_region()`] agrupadas por región, con el
|
||||
/// nombre de la región traducido (*"Europa"*, *"América"*...) y el nombre IANA completo como valor
|
||||
/// y como etiqueta (p. ej. `"Europe/Madrid"`). No admite opciones libres. Se renderiza como
|
||||
/// cualquier [`form::select::Field`].
|
||||
///
|
||||
/// La primera opción, con valor vacío, depende de si el campo es obligatorio:
|
||||
///
|
||||
/// - Si no lo es (por defecto), siempre se incluye y propone usar la zona horaria del sitio.
|
||||
/// Quedarse sin zona propia es válido y significa usar la predeterminada de la aplicación. Se
|
||||
/// selecciona cuando el valor elegido no corresponde a ninguna zona de la lista.
|
||||
/// - Si lo es ([`with_required(true)`](Self::with_required)), sólo se incluye cuando el valor
|
||||
/// seleccionado no corresponde a ninguna zona de la lista, y pide elegir una; así el navegador no
|
||||
/// deja enviar el formulario sin elegir una zona horaria. El valor recibido debe validarse
|
||||
/// igualmente en el servidor.
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pagetop::prelude::*;
|
||||
/// let timezone = form::SelectTimezone::new()
|
||||
/// .with_name("timezone")
|
||||
/// .with_label(Lc::n("Time zone"))
|
||||
/// .with_selected("Europe/Madrid");
|
||||
/// ```
|
||||
#[derive(AutoDefault, Clone, Debug, Getters)]
|
||||
pub struct SelectTimezone {
|
||||
/// Devuelve la lista de selección interna con la configuración común (nombre, etiqueta, ayuda,
|
||||
/// propiedades...), todavía sin opciones; éstas se añaden al renderizar.
|
||||
field: form::select::Field,
|
||||
/// Devuelve el nombre IANA de la zona horaria seleccionada.
|
||||
selected: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Component for SelectTimezone {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn id(&self) -> Option<String> {
|
||||
self.field().id()
|
||||
}
|
||||
|
||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||
let mut field = self.field().clone();
|
||||
let regions = Timezone::supported_by_region();
|
||||
let known = regions
|
||||
.iter()
|
||||
.any(|(_, names)| names.contains(&self.selected()));
|
||||
if !field.required() {
|
||||
let label = Lc::l("select_timezone_site_default")
|
||||
.with_arg("timezone", Timezone::default_tz().name());
|
||||
field.alter_item(form::select::Item::new("", label).with_selected(!known));
|
||||
} else if !known {
|
||||
let label = Lc::l("select_timezone_placeholder");
|
||||
field.alter_item(form::select::Item::new("", label).with_selected(true));
|
||||
}
|
||||
for (region, names) in regions {
|
||||
let mut group = form::select::Group::new(match *region {
|
||||
"Africa" => Lc::l("timezone_region_africa"),
|
||||
"America" => Lc::l("timezone_region_america"),
|
||||
"Antarctica" => Lc::l("timezone_region_antarctica"),
|
||||
"Arctic" => Lc::l("timezone_region_arctic"),
|
||||
"Asia" => Lc::l("timezone_region_asia"),
|
||||
"Atlantic" => Lc::l("timezone_region_atlantic"),
|
||||
"Australia" => Lc::l("timezone_region_australia"),
|
||||
"Etc" => Lc::l("timezone_region_etc"),
|
||||
"Europe" => Lc::l("timezone_region_europe"),
|
||||
"Indian" => Lc::l("timezone_region_indian"),
|
||||
"Pacific" => Lc::l("timezone_region_pacific"),
|
||||
_ => Lc::n(*region),
|
||||
});
|
||||
for name in names {
|
||||
let selected = *name == self.selected();
|
||||
group.alter_item(
|
||||
form::select::Item::new(*name, Lc::n(*name)).with_selected(selected),
|
||||
);
|
||||
}
|
||||
field.alter_group(group);
|
||||
}
|
||||
Ok(field.render(cx).await)
|
||||
}
|
||||
}
|
||||
|
||||
#[builder_impl]
|
||||
impl SelectTimezone {
|
||||
// **< SelectTimezone BUILDER >*****************************************************************
|
||||
|
||||
/// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`.
|
||||
pub fn with_id(mut self, id: impl Into<CowStr>) -> Self {
|
||||
self.field.alter_id(id);
|
||||
self
|
||||
}
|
||||
|
||||
/// Modifica identificador, clases CSS, atributos HTML o valores extra del componente.
|
||||
pub fn with_prop(mut self, op: impl Into<PropsOp>) -> Self {
|
||||
self.field.alter_prop(op);
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece el nombre del campo.
|
||||
pub fn with_name(mut self, name: impl AsRef<str>) -> Self {
|
||||
self.field.alter_name(name);
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece la etiqueta del campo.
|
||||
pub fn with_label(mut self, label: Lc) -> Self {
|
||||
self.field.alter_label(label);
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece el texto de ayuda del campo.
|
||||
pub fn with_help_text(mut self, help_text: Lc) -> Self {
|
||||
self.field.alter_help_text(help_text);
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece si el campo es obligatorio, lo que cambia su primera opción (ver
|
||||
/// [`SelectTimezone`]).
|
||||
pub fn with_required(mut self, required: bool) -> Self {
|
||||
self.field.alter_required(required);
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece si el campo está deshabilitado.
|
||||
pub fn with_disabled(mut self, disabled: bool) -> Self {
|
||||
self.field.alter_disabled(disabled);
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece el nombre IANA de la zona horaria seleccionada (p. ej. `"Europe/Madrid"`). Vacía,
|
||||
/// o una que no esté en la lista, selecciona la primera opción (ver [`SelectTimezone`]).
|
||||
pub fn with_selected(mut self, selected: impl Into<String>) -> Self {
|
||||
self.selected = selected.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
use crate::auth::CurrentUser;
|
||||
use crate::core::TypeInfo;
|
||||
use crate::core::component::{ChildOp, Component, MessageLevel, StatusMessage};
|
||||
use crate::core::theme::all::DEFAULT_THEME;
|
||||
use crate::core::theme::{ChildrenInRegions, CoreRegions, CoreTemplates};
|
||||
use crate::core::theme::{ChildrenInRegions, CoreRegions, CoreTemplates, default_theme};
|
||||
use crate::core::theme::{RegionRef, TemplateRef, ThemeRef};
|
||||
use crate::datetime::Tz;
|
||||
use crate::html::{Assets, Favicon, JavaScript, Preload, ResponsiveStyles, StyleSheet};
|
||||
|
|
@ -32,7 +31,7 @@ pub use contextual::Contextual;
|
|||
/// [`Page::new()`](crate::response::Page::new) o [`Page::admin()`](crate::response::Page::admin)),
|
||||
/// y es la única vía por la que un componente, una acción o el tema activo conocen: la petición
|
||||
/// HTTP de origen, el idioma negociado y la zona horaria efectiva, el usuario autenticado
|
||||
/// ([`current_user()`](Contextual::current_user)), la plantilla y el tema en uso, y los recursos
|
||||
/// ([`current_user()`](Contextual::current_user)), el tema y la plantilla en uso, y los recursos
|
||||
/// (favicon, hojas de estilo, scripts) acumulados hasta ese momento. Otros datos que los
|
||||
/// componentes necesiten durante el renderizado pueden ser parámetros dinámicos tipados con
|
||||
/// [`with_param()`](Contextual::with_param)/[`param()`](Contextual::param).
|
||||
|
|
@ -99,8 +98,8 @@ pub struct Context {
|
|||
locale : RequestLocale, // Idioma asociado a la petición.
|
||||
current_user: CurrentUser, // Identidad del usuario actual.
|
||||
timezone : Tz, // Zona horaria efectiva del documento.
|
||||
template : TemplateRef, // Plantilla usada para renderizar.
|
||||
theme : ThemeRef, // Referencia al tema usado para renderizar.
|
||||
template : TemplateRef, // Plantilla usada para renderizar.
|
||||
favicon : Option<Favicon>, // Favicon, si se ha definido.
|
||||
preloads : Assets<Preload>, // Recursos para precarga.
|
||||
stylesheets : Assets<StyleSheet>, // Hojas de estilo CSS.
|
||||
|
|
@ -129,13 +128,14 @@ impl Context {
|
|||
let locale = RequestLocale::from_request(request.as_ref());
|
||||
let current_user = Self::resolve_current_user(request.as_ref());
|
||||
let timezone = current_user.timezone();
|
||||
let theme = current_user.theme().unwrap_or_else(default_theme);
|
||||
Context {
|
||||
request,
|
||||
locale,
|
||||
current_user,
|
||||
timezone,
|
||||
theme,
|
||||
template,
|
||||
theme : *DEFAULT_THEME,
|
||||
favicon : None,
|
||||
preloads : Assets::<Preload>::new(),
|
||||
stylesheets: Assets::<StyleSheet>::new(),
|
||||
|
|
@ -169,12 +169,12 @@ impl Context {
|
|||
}
|
||||
|
||||
// Extrae el `CurrentUser` inyectado por middleware en las extensiones de la petición, o
|
||||
// `CurrentUser::Anonymous` si no hay petición o ninguna extensión de autenticación está activa.
|
||||
// un usuario anónimo si no hay petición o ninguna extensión de autenticación está activa.
|
||||
fn resolve_current_user(request: Option<&HttpRequest>) -> CurrentUser {
|
||||
request
|
||||
.and_then(|r| r.extension::<CurrentUser>())
|
||||
.cloned()
|
||||
.unwrap_or(CurrentUser::Anonymous)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// **< Context RENDER >*************************************************************************
|
||||
|
|
@ -320,8 +320,9 @@ impl Context {
|
|||
|
||||
/// Permite a [`Context`] actuar como proveedor de idioma.
|
||||
///
|
||||
/// Internamente delega en [`RequestLocale`], que tiene en cuenta la petición HTTP, la configuración
|
||||
/// global de idioma de la aplicación, la cabecera `Accept-Language` y/o el idioma de respaldo.
|
||||
/// Internamente delega en [`RequestLocale`], que tiene en cuenta la petición HTTP (parámetro
|
||||
/// `?lang` e idioma preferido del usuario), la configuración global de idioma de la aplicación, la
|
||||
/// cabecera `Accept-Language` y/o el idioma de respaldo.
|
||||
///
|
||||
/// Todo ello según la negociación indicada en [`global::SETTINGS.app.lang_negotiation`]. Esto
|
||||
/// permite que el [`Context`] se use como fuente de idioma coherente en [`Lc::lookup()`] o
|
||||
|
|
@ -344,11 +345,12 @@ impl Contextual for Context {
|
|||
|
||||
fn with_request(mut self, request: Option<HttpRequest>) -> Self {
|
||||
self.request = request;
|
||||
// Recalcula el *locale*, el usuario actual y la zona horaria según la nueva petición y la
|
||||
// política de negociación configurada.
|
||||
// Recalcula el *locale*, el usuario actual, la zona horaria y el tema según la nueva
|
||||
// petición y la política de negociación configurada.
|
||||
self.locale = RequestLocale::from_request(self.request.as_ref());
|
||||
self.current_user = Self::resolve_current_user(self.request.as_ref());
|
||||
self.timezone = self.current_user.timezone();
|
||||
self.theme = self.current_user.theme().unwrap_or_else(default_theme);
|
||||
self
|
||||
}
|
||||
|
||||
|
|
@ -362,13 +364,13 @@ impl Contextual for Context {
|
|||
self
|
||||
}
|
||||
|
||||
fn with_template(mut self, template: TemplateRef) -> Self {
|
||||
self.template = template;
|
||||
fn with_theme(mut self, theme: ThemeRef) -> Self {
|
||||
self.theme = theme;
|
||||
self
|
||||
}
|
||||
|
||||
fn with_theme(mut self, theme: ThemeRef) -> Self {
|
||||
self.theme = theme;
|
||||
fn with_template(mut self, template: TemplateRef) -> Self {
|
||||
self.template = template;
|
||||
self
|
||||
}
|
||||
|
||||
|
|
@ -452,14 +454,14 @@ impl Contextual for Context {
|
|||
self.timezone
|
||||
}
|
||||
|
||||
fn template(&self) -> TemplateRef {
|
||||
self.template
|
||||
}
|
||||
|
||||
fn theme(&self) -> ThemeRef {
|
||||
self.theme
|
||||
}
|
||||
|
||||
fn template(&self) -> TemplateRef {
|
||||
self.template
|
||||
}
|
||||
|
||||
fn param<T: 'static>(&self, key: &'static str) -> Result<&T, ContextError> {
|
||||
let (any, type_name) = self.params.get(key).ok_or(ContextError::ParamNotFound)?;
|
||||
any.downcast_ref::<T>()
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const ISO_DATETIME: &str = "%Y-%m-%dT%H:%M:%S%:z";
|
|||
/// - Almacenar la **petición HTTP** de origen.
|
||||
/// - Conocer la **identidad del usuario actual** ([`current_user()`](Self::current_user)) y la
|
||||
/// **zona horaria efectiva** del documento ([`timezone()`](Self::timezone)).
|
||||
/// - Seleccionar la **plantilla** y el **tema** de renderizado.
|
||||
/// - Seleccionar el **tema** y la **plantilla** de renderizado.
|
||||
/// - Administrar **recursos** del documento como el icono [`Favicon`], las hojas de estilo
|
||||
/// [`StyleSheet`] o los scripts [`JavaScript`], directamente o mediante una operación
|
||||
/// [`AssetsOp`].
|
||||
|
|
@ -40,8 +40,8 @@ const ISO_DATETIME: &str = "%Y-%m-%dT%H:%M:%S%:z";
|
|||
/// # use pagetop_aliner::Aliner;
|
||||
/// fn prepare_context<C: Contextual>(cx: C) -> C {
|
||||
/// cx.with_langid(&Locale::resolve("es-ES"))
|
||||
/// .with_template(&CoreTemplates::Standard)
|
||||
/// .with_theme(&Aliner)
|
||||
/// .with_template(&CoreTemplates::Standard)
|
||||
/// .with_assets(Favicon::new().with_icon("/favicon.ico"))
|
||||
/// .with_assets(StyleSheet::from("/css/app.css"))
|
||||
/// .with_assets(JavaScript::defer("/js/app.js"))
|
||||
|
|
@ -67,22 +67,24 @@ pub trait Contextual: LangId {
|
|||
///
|
||||
/// Al asociar la petición, recalcula el idioma ([`RequestLocale::from_request()`]), establece
|
||||
/// el usuario actual ([`current_user()`]) y, a partir de éste, asigna la zona horaria efectiva
|
||||
/// ([`timezone()`]), descartando en el proceso cualquier idioma o zona horaria anteriores.
|
||||
/// ([`timezone()`]) y el tema ([`theme()`]), descartando en el proceso cualquier idioma, zona
|
||||
/// horaria o tema anteriores.
|
||||
///
|
||||
/// Si sabes que vas a forzar el idioma o la zona horaria, llama a `with_request()` primero en
|
||||
/// la cadena de construcción, nunca después.
|
||||
/// Si sabes que vas a forzar el idioma, la zona horaria o el tema, llama a `with_request()`
|
||||
/// primero en la cadena de construcción, nunca después.
|
||||
///
|
||||
/// [`RequestLocale::from_request()`]: crate::locale::RequestLocale::from_request
|
||||
/// [`current_user()`]: Self::current_user
|
||||
/// [`timezone()`]: Self::timezone
|
||||
/// [`theme()`]: Self::theme
|
||||
fn with_request(self, request: Option<HttpRequest>) -> Self;
|
||||
|
||||
/// Especifica la plantilla para renderizar el documento.
|
||||
fn with_template(self, template: TemplateRef) -> Self;
|
||||
|
||||
/// Especifica el tema para renderizar el documento.
|
||||
fn with_theme(self, theme: ThemeRef) -> Self;
|
||||
|
||||
/// Especifica la plantilla para renderizar el documento.
|
||||
fn with_template(self, template: TemplateRef) -> Self;
|
||||
|
||||
/// Añade o modifica un parámetro dinámico del contexto.
|
||||
///
|
||||
/// El valor se almacena junto con el nombre de su tipo, lo que permite generar mensajes de
|
||||
|
|
@ -125,7 +127,7 @@ pub trait Contextual: LangId {
|
|||
///
|
||||
/// Si ninguna extensión de autenticación ha inyectado un
|
||||
/// [`CurrentUser`](crate::auth::CurrentUser) en las extensiones de la petición HTTP, devuelve
|
||||
/// `&CurrentUser::Anonymous`.
|
||||
/// un usuario anónimo ([`CurrentUser::anonymous()`](crate::auth::CurrentUser::anonymous)).
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
|
|
@ -154,12 +156,12 @@ pub trait Contextual: LangId {
|
|||
/// [`CurrentUser::timezone()`]: crate::auth::CurrentUser::timezone
|
||||
fn timezone(&self) -> Tz;
|
||||
|
||||
/// Devuelve la plantilla configurada para renderizar el documento.
|
||||
fn template(&self) -> TemplateRef;
|
||||
|
||||
/// Devuelve el tema que se usará para renderizar el documento.
|
||||
fn theme(&self) -> ThemeRef;
|
||||
|
||||
/// Devuelve la plantilla configurada para renderizar el documento.
|
||||
fn template(&self) -> TemplateRef;
|
||||
|
||||
/// Recupera una *referencia tipada* al parámetro solicitado.
|
||||
///
|
||||
/// Devuelve:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use crate::core::action::publish_actions;
|
||||
use crate::core::extension::ExtensionRef;
|
||||
use crate::core::theme::ThemeRef;
|
||||
use crate::core::theme::all::THEMES;
|
||||
use crate::core::theme::all::register_theme;
|
||||
use crate::web::Router;
|
||||
use crate::{global, serve_static_files, trace, web};
|
||||
|
||||
|
|
@ -47,14 +47,7 @@ fn add_to_enabled(list: &mut Vec<ExtensionRef>, extension: ExtensionRef) {
|
|||
// Comprueba si la extensión tiene un tema asociado que deba registrarse.
|
||||
if let Some(theme) = extension.theme() {
|
||||
check_theme_parent_chain(theme);
|
||||
|
||||
let mut registered_themes = THEMES.write();
|
||||
// Asegura que el tema no esté ya registrado para evitar duplicados.
|
||||
if !registered_themes
|
||||
.iter()
|
||||
.any(|t| t.type_id() == theme.type_id())
|
||||
{
|
||||
registered_themes.push(theme);
|
||||
if register_theme(theme) {
|
||||
trace::debug!("Enabling \"{}\" theme", theme.short_name());
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -170,3 +170,4 @@ pub(crate) use regions::ChildrenInRegions;
|
|||
pub use regions::InRegion;
|
||||
|
||||
pub(crate) mod all;
|
||||
pub use all::{default_theme, enabled_themes, theme_by_short_name};
|
||||
|
|
|
|||
|
|
@ -7,27 +7,50 @@ use std::sync::LazyLock;
|
|||
|
||||
// **< TEMAS >**************************************************************************************
|
||||
|
||||
pub static THEMES: LazyLock<RwLock<Vec<ThemeRef>>> = LazyLock::new(|| RwLock::new(Vec::new()));
|
||||
static THEMES: LazyLock<RwLock<Vec<ThemeRef>>> = LazyLock::new(|| RwLock::new(Vec::new()));
|
||||
|
||||
// Registra el tema si no lo estaba ya, para evitar duplicados. Devuelve `true` si lo ha añadido.
|
||||
pub(crate) fn register_theme(theme: ThemeRef) -> bool {
|
||||
let mut themes = THEMES.write();
|
||||
if themes.iter().any(|t| t.type_id() == theme.type_id()) {
|
||||
return false;
|
||||
}
|
||||
themes.push(theme);
|
||||
true
|
||||
}
|
||||
|
||||
/// Devuelve los temas habilitados en la aplicación, en el orden en que se registraron.
|
||||
pub fn enabled_themes() -> Vec<ThemeRef> {
|
||||
THEMES.read().clone()
|
||||
}
|
||||
|
||||
/// Devuelve el tema identificado por su [`short_name()`](crate::core::AnyInfo::short_name), si está
|
||||
/// habilitado, sin distinguir mayúsculas y minúsculas.
|
||||
pub fn theme_by_short_name(short_name: &str) -> Option<ThemeRef> {
|
||||
THEMES
|
||||
.read()
|
||||
.iter()
|
||||
.find(|t| t.short_name().eq_ignore_ascii_case(short_name))
|
||||
.copied()
|
||||
}
|
||||
|
||||
// **< TEMA PREDETERMINADO >************************************************************************
|
||||
|
||||
pub static DEFAULT_THEME: LazyLock<ThemeRef> =
|
||||
static DEFAULT_THEME: LazyLock<ThemeRef> =
|
||||
LazyLock::new(|| match theme_by_short_name(&global::SETTINGS.app.theme) {
|
||||
Some(theme) => theme,
|
||||
None => &crate::base::theme::Basic,
|
||||
});
|
||||
|
||||
// **< TEMA POR NOMBRE >****************************************************************************
|
||||
|
||||
/// Devuelve el tema identificado por su [`short_name()`](AnyInfo::short_name).
|
||||
pub fn theme_by_short_name(short_name: &'static str) -> Option<ThemeRef> {
|
||||
let short_name = short_name.to_lowercase();
|
||||
match THEMES
|
||||
.read()
|
||||
.iter()
|
||||
.find(|t| t.short_name().to_lowercase() == short_name)
|
||||
{
|
||||
Some(theme) => Some(*theme),
|
||||
_ => None,
|
||||
}
|
||||
/// Devuelve el tema predeterminado de la aplicación: el configurado en `app.theme` si está
|
||||
/// habilitado o, en otro caso, [`Basic`](crate::base::theme::Basic).
|
||||
///
|
||||
/// Es el tema del sitio, no necesariamente el que se usa en una petición concreta: para renderizar,
|
||||
/// el tema efectivo es el de [`Contextual::theme()`], que tiene en cuenta el tema preferido del
|
||||
/// usuario ([`CurrentUser::theme()`]).
|
||||
///
|
||||
/// [`Contextual::theme()`]: crate::core::component::Contextual::theme
|
||||
/// [`CurrentUser::theme()`]: crate::auth::CurrentUser::theme
|
||||
pub fn default_theme() -> ThemeRef {
|
||||
*DEFAULT_THEME
|
||||
}
|
||||
|
|
|
|||
|
|
@ -431,6 +431,12 @@ pub trait Theme: Extension + Send + Sync {
|
|||
/// Referencia estática a un tema.
|
||||
pub type ThemeRef = &'static dyn Theme;
|
||||
|
||||
impl std::fmt::Debug for dyn Theme {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.short_name())
|
||||
}
|
||||
}
|
||||
|
||||
// **< setup_component! >***************************************************************************
|
||||
|
||||
/// Modifica un componente dentro de [`Theme::setup_component()`].
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
use crate::{global, trace, util};
|
||||
|
||||
use super::Tz;
|
||||
use super::{TZ_VARIANTS, Tz};
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
// Identificador de zona horaria configurado para la aplicación, si es válido.
|
||||
|
|
@ -17,6 +18,36 @@ static CONFIG_TZ: LazyLock<Option<Tz>> = LazyLock::new(|| {
|
|||
// Zona horaria de respaldo, garantizada incluso sin configuración válida.
|
||||
const FALLBACK_TZ: Tz = Tz::UTC;
|
||||
|
||||
// Regiones de la base IANA que sólo contienen alias heredados (fichero `backward`), todos con una
|
||||
// zona canónica equivalente en otra región (p. ej. `US/Eastern` es `America/New_York`).
|
||||
const LEGACY_REGIONS: [&str; 5] = ["Brazil", "Canada", "Chile", "Mexico", "US"];
|
||||
|
||||
// Zonas horarias IANA agrupadas por región (lo anterior a la primera `/`), ordenadas por región y
|
||||
// nombre. Se descartan los nombres sin región (alias heredados como `GB`, `Japan` o `EST5EDT`) y
|
||||
// las regiones de `LEGACY_REGIONS`. De `Etc` sólo se conserva `Etc/UTC`, cuyo grupo va al final:
|
||||
// el resto son zonas de desfase fijo (`Etc/GMT+1`...) que no representan ningún lugar. Siguen
|
||||
// apareciendo los alias heredados que viven dentro de una región normal (p. ej. `Asia/Calcutta`
|
||||
// junto a `Asia/Kolkata`): `chrono-tz` no distingue zonas canónicas de enlaces y filtrarlos
|
||||
// exigiría mantener a mano una lista de casi 180 nombres.
|
||||
static TZ_BY_REGION: LazyLock<Vec<(&'static str, Vec<&'static str>)>> = LazyLock::new(|| {
|
||||
let mut regions: BTreeMap<&'static str, Vec<&'static str>> = BTreeMap::new();
|
||||
for tz in TZ_VARIANTS.iter() {
|
||||
let name = tz.name();
|
||||
let Some((region, _)) = name.split_once('/') else {
|
||||
continue;
|
||||
};
|
||||
if LEGACY_REGIONS.contains(®ion) || (region == "Etc" && name != "Etc/UTC") {
|
||||
continue;
|
||||
}
|
||||
regions.entry(region).or_default().push(name);
|
||||
}
|
||||
for names in regions.values_mut() {
|
||||
names.sort_unstable();
|
||||
}
|
||||
let etc = regions.remove_entry("Etc");
|
||||
regions.into_iter().chain(etc).collect()
|
||||
});
|
||||
|
||||
/// Zona horaria configurada para la aplicación.
|
||||
///
|
||||
/// Resuelve [`global::SETTINGS.app.timezone`](crate::global::App::timezone) contra la base IANA de
|
||||
|
|
@ -69,4 +100,31 @@ impl Timezone {
|
|||
pub fn default_tz() -> Tz {
|
||||
Self::try_tz().unwrap_or(FALLBACK_TZ)
|
||||
}
|
||||
|
||||
/// Devuelve las zonas horarias IANA que se ofrecen para elegir, agrupadas por región.
|
||||
///
|
||||
/// Cada grupo es la región (lo anterior a la primera `/`, p. ej. `"Europe"`) con los nombres
|
||||
/// completos de sus zonas (p. ej. `"Europe/Madrid"`), ordenados por región y nombre; el grupo
|
||||
/// `"Etc"`, sólo con `"Etc/UTC"`, va al final. Se excluyen los nombres sin región (`"UTC"`,
|
||||
/// `"Japan"`...), las regiones formadas sólo por alias heredados (`"US"`, `"Canada"`...) y las
|
||||
/// zonas de desfase fijo (`"Etc/GMT+1"`...). Es la lista que ofrece
|
||||
/// [`form::SelectTimezone`](crate::base::component::form::SelectTimezone), útil también para
|
||||
/// validar el valor recibido.
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust
|
||||
/// # use pagetop::prelude::*;
|
||||
/// let is_supported = |name: &str| {
|
||||
/// Timezone::supported_by_region()
|
||||
/// .iter()
|
||||
/// .any(|(_, names)| names.contains(&name))
|
||||
/// };
|
||||
///
|
||||
/// assert!(is_supported("Europe/Madrid"));
|
||||
/// assert!(!is_supported("US/Eastern"));
|
||||
/// ```
|
||||
pub fn supported_by_region() -> &'static [(&'static str, Vec<&'static str>)] {
|
||||
&TZ_BY_REGION
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,21 +11,25 @@ use serde::{Deserialize, Deserializer};
|
|||
#[derive(AutoDefault, Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum LangNegotiation {
|
||||
/// Usa todas las fuentes disponibles para determinar el idioma, en este orden: comprueba el
|
||||
/// parámetro `?lang` de la URL; si no está presente o no es válido, usa la cabecera HTTP
|
||||
/// `Accept-Language`; si tampoco está disponible o no es válido, usa el idioma configurado en
|
||||
/// [`global::SETTINGS.app.language`](crate::global::App::language) o, en su defecto, el idioma
|
||||
/// de respaldo. Es el comportamiento por defecto.
|
||||
/// parámetro `?lang` de la URL; si no está presente o no es válido, usa el idioma preferido del
|
||||
/// usuario ([`CurrentUser::language()`]); si no tiene ninguno, usa el idioma configurado en
|
||||
/// [`global::SETTINGS.app.language`]; si tampoco está disponible o no es válido, usa la
|
||||
/// cabecera HTTP `Accept-Language` o, en su defecto, el idioma de respaldo. Es el
|
||||
/// comportamiento por defecto.
|
||||
///
|
||||
/// [`CurrentUser::language()`]: crate::auth::CurrentUser::language
|
||||
/// [`global::SETTINGS.app.language`]: crate::global::App::language
|
||||
#[default]
|
||||
Full,
|
||||
|
||||
/// Igual que `LangNegotiation::Full`, pero sin tener en cuenta el parámetro `?lang` de la URL.
|
||||
/// El idioma depende únicamente de la cabecera `Accept-Language` del navegador y, en última
|
||||
/// instancia, de la configuración o idioma de respaldo.
|
||||
/// El idioma depende, en este orden, del idioma preferido del usuario, de la configuración, de
|
||||
/// la cabecera `Accept-Language` del navegador y, en última instancia, del idioma de respaldo.
|
||||
NoQuery,
|
||||
|
||||
/// Usa sólo la configuración o, en su defecto, el idioma de respaldo; ignora la cabecera
|
||||
/// `Accept-Language` y el parámetro de la URL. Este modo proporciona un comportamiento estable
|
||||
/// con idioma fijo.
|
||||
/// Usa sólo la configuración o, en su defecto, el idioma de respaldo; ignora el idioma del
|
||||
/// usuario, la cabecera `Accept-Language` y el parámetro de la URL. Este modo proporciona un
|
||||
/// comportamiento estable con idioma fijo.
|
||||
ConfigOnly,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use crate::{global, trace, util};
|
||||
|
||||
use super::languages::LANGUAGES;
|
||||
use super::{LanguageIdentifier, langid};
|
||||
use super::languages::{LANGUAGES, SUPPORTED};
|
||||
use super::{LanguageIdentifier, Lc, langid};
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
|
|
@ -139,6 +139,33 @@ impl Locale {
|
|||
}
|
||||
}
|
||||
|
||||
/// Devuelve los idiomas soportados por PageTop con su nombre traducible, ordenados por
|
||||
/// identificador.
|
||||
///
|
||||
/// Incluye una entrada por identificador canónico (p. ej. `"es-ES"`), sin los alias de idioma
|
||||
/// base (`"es"`) que [`Locale::resolve()`] también acepta. El nombre es, por tanto, el de la
|
||||
/// variante regional (p. ej. *"Español (España)"*, no *"Español"*). Útil para ofrecer un
|
||||
/// selector de idioma.
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust
|
||||
/// # use pagetop::prelude::*;
|
||||
/// let codes: Vec<String> = Locale::supported_languages()
|
||||
/// .iter()
|
||||
/// .map(|(langid, _)| langid.to_string())
|
||||
/// .collect();
|
||||
///
|
||||
/// assert!(codes.contains(&"es-ES".to_string()));
|
||||
/// assert!(!codes.contains(&"es".to_string()));
|
||||
/// ```
|
||||
pub fn supported_languages() -> Vec<(&'static LanguageIdentifier, Lc)> {
|
||||
SUPPORTED
|
||||
.iter()
|
||||
.map(|(langid, key)| (*langid, Lc::l(*key)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// **< Locale HELPERS >*************************************************************************
|
||||
|
||||
/// Inicializa el idioma por defecto que utilizará la aplicación.
|
||||
|
|
|
|||
|
|
@ -11,6 +11,27 @@ dropdown_default_title = Dropdown
|
|||
# Form components.
|
||||
field_required = This field is required
|
||||
|
||||
select_language_site_default = Use the site language: { $language }
|
||||
select_language_placeholder = Choose a language...
|
||||
|
||||
select_theme_site_default = Use the site theme: { $theme }
|
||||
select_theme_placeholder = Choose a theme...
|
||||
|
||||
select_timezone_site_default = Use the site time zone: { $timezone }
|
||||
select_timezone_placeholder = Choose a time zone...
|
||||
|
||||
timezone_region_africa = Africa
|
||||
timezone_region_america = America
|
||||
timezone_region_antarctica = Antarctica
|
||||
timezone_region_arctic = Arctic
|
||||
timezone_region_asia = Asia
|
||||
timezone_region_atlantic = Atlantic
|
||||
timezone_region_australia = Australia
|
||||
timezone_region_europe = Europe
|
||||
timezone_region_indian = Indian Ocean
|
||||
timezone_region_pacific = Pacific
|
||||
timezone_region_etc = Other
|
||||
|
||||
# Intro component.
|
||||
intro_default_title = Hello, world!
|
||||
intro_default_slogan = Discover⚡{ $app }
|
||||
|
|
|
|||
|
|
@ -11,6 +11,27 @@ dropdown_default_title = Menú desplegable
|
|||
# Form components.
|
||||
field_required = Este campo es obligatorio
|
||||
|
||||
select_language_site_default = Usar el idioma del sitio: { $language }
|
||||
select_language_placeholder = Elige un idioma...
|
||||
|
||||
select_theme_site_default = Usar el tema del sitio: { $theme }
|
||||
select_theme_placeholder = Elige un tema...
|
||||
|
||||
select_timezone_site_default = Usar la zona horaria del sitio: { $timezone }
|
||||
select_timezone_placeholder = Elige una zona horaria...
|
||||
|
||||
timezone_region_africa = África
|
||||
timezone_region_america = América
|
||||
timezone_region_antarctica = Antártida
|
||||
timezone_region_arctic = Ártico
|
||||
timezone_region_asia = Asia
|
||||
timezone_region_atlantic = Atlántico
|
||||
timezone_region_australia = Australia
|
||||
timezone_region_europe = Europa
|
||||
timezone_region_indian = Océano Índico
|
||||
timezone_region_pacific = Pacífico
|
||||
timezone_region_etc = Otras
|
||||
|
||||
# Intro component.
|
||||
intro_default_title = ¡Hola, mundo!
|
||||
intro_default_slogan = Descubre⚡{ $app }
|
||||
|
|
|
|||
|
|
@ -27,3 +27,38 @@ pub(super) static LANGUAGES: LazyLock<HashMap<&str, (LanguageIdentifier, &str)>>
|
|||
"es-es" => ( langid!("es-ES"), "spanish_spain" ),
|
||||
]
|
||||
});
|
||||
|
||||
// Idiomas soportados sin alias: una entrada por identificador canónico (la de `LANGUAGES` cuyo
|
||||
// código coincide con él, p. ej. "es-es" y no "es"), ordenadas por identificador, con la clave de
|
||||
// su nombre.
|
||||
pub(super) static SUPPORTED: LazyLock<Vec<(&'static LanguageIdentifier, &'static str)>> =
|
||||
LazyLock::new(|| {
|
||||
let mut supported: Vec<_> = LANGUAGES
|
||||
.iter()
|
||||
.filter(|(code, (langid, _))| langid.to_string().eq_ignore_ascii_case(code))
|
||||
.map(|(_, (langid, key))| (langid, *key))
|
||||
.collect();
|
||||
supported.sort_by_cached_key(|(langid, _)| langid.to_string());
|
||||
supported
|
||||
});
|
||||
|
||||
// Un idioma añadido a `LANGUAGES` sólo con su alias (p. ej. "ca" sin "ca-es") lo aceptaría
|
||||
// `Locale::resolve()`, pero quedaría fuera de `SUPPORTED` y, con él, del selector de idioma.
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_language_has_its_canonical_entry() {
|
||||
for (code, (langid, _)) in LANGUAGES.iter() {
|
||||
assert_eq!(
|
||||
SUPPORTED
|
||||
.iter()
|
||||
.filter(|(supported, _)| *supported == langid)
|
||||
.count(),
|
||||
1,
|
||||
"language \"{code}\" has no canonical entry \"{langid}\" in LANGUAGES"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use crate::auth::CurrentUser;
|
||||
use crate::global;
|
||||
use crate::util;
|
||||
use crate::web::HttpRequest;
|
||||
|
|
@ -16,8 +17,8 @@ use super::{LangId, LanguageIdentifier, Locale};
|
|||
///
|
||||
/// [`LangNegotiation`]: crate::global::LangNegotiation
|
||||
pub struct RequestLocale {
|
||||
// Idioma elegido por la aplicación para esta petición, combinando la configuración, la cabecera
|
||||
// `Accept-Language` y/o el idioma de respaldo.
|
||||
// Idioma elegido por la aplicación para esta petición, combinando el idioma del usuario, la
|
||||
// configuración, la cabecera `Accept-Language` y/o el idioma de respaldo.
|
||||
base: &'static LanguageIdentifier,
|
||||
// Idioma finalmente aplicado a la petición (puede coincidir con `base` o no).
|
||||
effective: &'static LanguageIdentifier,
|
||||
|
|
@ -34,19 +35,25 @@ impl RequestLocale {
|
|||
///
|
||||
/// - [`LangNegotiation::Full`] determina el idioma en este orden:
|
||||
/// 1. Parámetro de *query* `?lang=...`, si existe y corresponde a un idioma soportado.
|
||||
/// 2. Idioma preferido del usuario ([`CurrentUser::language()`]), si tiene uno.
|
||||
/// 3. [`Locale::try_langid()`], si la aplicación tiene un idioma por defecto válido.
|
||||
/// 4. Cabecera `Accept-Language`, si puede resolverse con [`Locale::resolve()`].
|
||||
/// 5. Idioma de respaldo.
|
||||
///
|
||||
/// - [`LangNegotiation::NoQuery`] descarta el uso del parámetro `?lang=...` y determina el
|
||||
/// idioma en este orden:
|
||||
/// 1. Idioma preferido del usuario ([`CurrentUser::language()`]), si tiene uno.
|
||||
/// 2. [`Locale::try_langid()`], si la aplicación tiene un idioma por defecto válido.
|
||||
/// 3. Cabecera `Accept-Language`, si puede resolverse con [`Locale::resolve()`].
|
||||
/// 4. Idioma de respaldo.
|
||||
///
|
||||
/// - [`LangNegotiation::NoQuery`] descarta el uso del parámetro `?lang=...` y determina el
|
||||
/// idioma en este orden:
|
||||
/// 1. [`Locale::try_langid()`], si la aplicación tiene un idioma por defecto válido.
|
||||
/// 2. Cabecera `Accept-Language`, si puede resolverse con [`Locale::resolve()`].
|
||||
/// 3. Idioma de respaldo.
|
||||
///
|
||||
/// - [`LangNegotiation::ConfigOnly`] sólo usa la configuración de la aplicación mediante
|
||||
/// [`Locale::default_langid()`], sin consultar la cabecera `Accept-Language` ni el parámetro
|
||||
/// `?lang`. Este modo también aplica el idioma de respaldo si es necesario.
|
||||
/// [`Locale::default_langid()`], sin consultar el idioma del usuario, la cabecera
|
||||
/// `Accept-Language` ni el parámetro `?lang`. Este modo también aplica el idioma de respaldo
|
||||
/// si es necesario.
|
||||
///
|
||||
/// El idioma del usuario se lee del [`CurrentUser`] que la extensión de autenticación inserta
|
||||
/// en las extensiones de la petición.
|
||||
///
|
||||
/// En todos los casos, el idioma resultante es siempre un [`LanguageIdentifier`] soportado por
|
||||
/// la aplicación y será el que PageTop utilice para renderizar la respuesta de la petición.
|
||||
|
|
@ -65,10 +72,14 @@ impl RequestLocale {
|
|||
Locale::default_langid()
|
||||
}
|
||||
global::LangNegotiation::Full | global::LangNegotiation::NoQuery => {
|
||||
if let Some(default) = Locale::try_langid() {
|
||||
default
|
||||
let user_language = request
|
||||
.and_then(|req| req.extension::<CurrentUser>())
|
||||
.and_then(CurrentUser::language);
|
||||
if let Some(langid) = user_language.or_else(Locale::try_langid) {
|
||||
langid
|
||||
} else {
|
||||
// Sin idioma por defecto, se evalúa la cabecera `Accept-Language`.
|
||||
// Sin idioma del usuario ni por defecto, se evalúa la cabecera
|
||||
// `Accept-Language`.
|
||||
request
|
||||
.and_then(|req| req.headers().get("Accept-Language"))
|
||||
.and_then(|value| value.to_str().ok())
|
||||
|
|
@ -147,9 +158,10 @@ impl RequestLocale {
|
|||
|
||||
/// Fuerza el idioma que se utilizará para las traducciones de esta petición.
|
||||
///
|
||||
/// Este método permite sustituir el idioma calculado (por configuración, cabecera, `?lang`,
|
||||
/// etc.) por otro idioma. Normalmente se usa cuando quieres que toda la respuesta se genere en
|
||||
/// un idioma concreto, independientemente de cómo se haya llegado a él.
|
||||
/// Este método permite sustituir el idioma calculado (por `?lang`, idioma del usuario,
|
||||
/// configuración, cabecera, etc.) por otro idioma. Normalmente se usa cuando quieres que toda
|
||||
/// la respuesta se genere en un idioma concreto, independientemente de cómo se haya llegado a
|
||||
/// él.
|
||||
#[inline]
|
||||
pub fn with_langid(&mut self, language: &impl LangId) -> &mut Self {
|
||||
self.effective = language.langid();
|
||||
|
|
@ -162,11 +174,12 @@ impl RequestLocale {
|
|||
/// El comportamiento depende de la estrategia configurada en [`LangNegotiation`]:
|
||||
///
|
||||
/// - En modo [`LangNegotiation::Full`] devuelve `true` cuando la respuesta se está generando en
|
||||
/// un idioma distinto del que la aplicación habría elegido automáticamente a partir de la
|
||||
/// configuración, el navegador y el idioma de respaldo. En la práctica suele significar que
|
||||
/// el usuario ha pedido expresamente otro idioma (por ejemplo, con `?lang=...`) o que se ha
|
||||
/// forzado con [`with_langid()`](Self::with_langid), y por tanto es recomendable propagar
|
||||
/// `lang=...` en los enlaces para mantener esa preferencia mientras se navega.
|
||||
/// un idioma distinto del que la aplicación habría elegido automáticamente a partir del
|
||||
/// idioma del usuario, la configuración, el navegador y el idioma de respaldo. En la práctica
|
||||
/// suele significar que el usuario ha pedido expresamente otro idioma (por ejemplo, con
|
||||
/// `?lang=...`) o que se ha forzado con [`with_langid()`](Self::with_langid), y por tanto es
|
||||
/// recomendable propagar `lang=...` en los enlaces para mantener esa preferencia mientras se
|
||||
/// navega.
|
||||
///
|
||||
/// - En modos [`LangNegotiation::NoQuery`] y [`LangNegotiation::ConfigOnly`] siempre devuelve
|
||||
/// `false`, ya que en estas estrategias la aplicación no utiliza el parámetro `?lang=...`
|
||||
|
|
|
|||
|
|
@ -286,13 +286,13 @@ impl Contextual for Page {
|
|||
self
|
||||
}
|
||||
|
||||
fn with_template(mut self, template: TemplateRef) -> Self {
|
||||
self.context.alter_template(template);
|
||||
fn with_theme(mut self, theme: ThemeRef) -> Self {
|
||||
self.context.alter_theme(theme);
|
||||
self
|
||||
}
|
||||
|
||||
fn with_theme(mut self, theme: ThemeRef) -> Self {
|
||||
self.context.alter_theme(theme);
|
||||
fn with_template(mut self, template: TemplateRef) -> Self {
|
||||
self.context.alter_template(template);
|
||||
self
|
||||
}
|
||||
|
||||
|
|
@ -336,14 +336,14 @@ impl Contextual for Page {
|
|||
self.context.timezone()
|
||||
}
|
||||
|
||||
fn template(&self) -> TemplateRef {
|
||||
self.context.template()
|
||||
}
|
||||
|
||||
fn theme(&self) -> ThemeRef {
|
||||
self.context.theme()
|
||||
}
|
||||
|
||||
fn template(&self) -> TemplateRef {
|
||||
self.context.template()
|
||||
}
|
||||
|
||||
fn param<T: 'static>(&self, key: &'static str) -> Result<&T, ContextError> {
|
||||
self.context.param(key)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue