✨ (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:
Manuel Cillero 2026-10-04 23:09:07 +02:00
parent 35f2b66161
commit 70744a51a5
25 changed files with 1162 additions and 190 deletions

View file

@ -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;

View 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
}
}

View 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
}
}

View 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
}
}