From c6cd4fcd7d68eb9387f888a0a40f453a21935d0d Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sat, 19 Sep 2026 03:14:21 +0200 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20(base):=20A=C3=B1ade=20`with=5F?= =?UTF-8?q?width()`=20a=20Container?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/css/basic.css | 97 +++++++++++-- examples/locale/es-ES/navbar-menus.ftl | 4 +- examples/navbar-menus.rs | 66 +++++---- extensions/pagetop-bootsier/src/lib.rs | 4 +- extensions/pagetop-bootsier/src/theme.rs | 2 - extensions/pagetop-bootsier/src/theme/bs.rs | 2 - .../src/theme/bs/container.rs | 127 ++++-------------- .../src/theme/bs/layout/template.rs | 2 +- .../tests/breakpoint_class.rs | 45 +++++-- src/base/component/container.rs | 65 ++++++++- tests/component_container.rs | 80 +++++++++++ 11 files changed, 325 insertions(+), 169 deletions(-) diff --git a/assets/css/basic.css b/assets/css/basic.css index 1f92da1f..2fe4bb7c 100644 --- a/assets/css/basic.css +++ b/assets/css/basic.css @@ -44,7 +44,7 @@ body { -webkit-tap-highlight-color: transparent; } -/* Header, Aside, Content y Footer se apilan en el mismo orden ocupando todo el ancho disponible. */ +/* Header, Aside, Content and Footer are stacked in that order, using the full available width. */ .wrapper { display: flex; flex-direction: column; @@ -125,10 +125,10 @@ body { text-decoration: underline; } -/* Geometría base de .dropdown-button/.dropdown-toggle (ver "Dropdown component" más abajo). Se - * declara aquí, antes de "Buttons", para que las clases .button-sm/.button-lg/.button-* ganen el - * empate de especificidad frente a ella cuando se combinan en el mismo elemento (CSS resuelve un - * empate por orden de aparición en el fichero, no por el orden de las clases en el HTML). */ +/* Base geometry of .dropdown-button/.dropdown-toggle (see "Dropdown component" below). It is + * declared here, before "Buttons", so that the .button-sm/.button-lg/.button-* classes win the + * specificity tie against it when combined on the same element (CSS resolves a tie by order of + * appearance in the file, not by the order of the classes in the HTML). */ .dropdown-button, .dropdown-toggle { padding: 0.5rem 0.75rem; @@ -247,6 +247,79 @@ body { gap: 0.5rem; } +/* + * Container component + */ + +/* Classes added by the `Container` component from `Width`. All of them are centered and fill the + * available width; `.container` and `.container-{name}` also get a maximum width from the + * breakpoint they name (`.container` from the first one). The minimum widths match the default + * `Theme::breakpoint_entry()` table, so a child theme that renames or resizes the breakpoints must + * also redefine these classes. */ +.container, +.container-fluid, +.container-sm, +.container-md, +.container-lg, +.container-xl, +.container-xxl, +.container-xxxl { + width: 100%; + margin-inline: auto; + padding-inline: 1rem; +} +@media (min-width: 576px) { + .container, + .container-sm { + max-width: 540px; + } +} +@media (min-width: 768px) { + .container, + .container-sm, + .container-md { + max-width: 720px; + } +} +@media (min-width: 992px) { + .container, + .container-sm, + .container-md, + .container-lg { + max-width: 960px; + } +} +@media (min-width: 1200px) { + .container, + .container-sm, + .container-md, + .container-lg, + .container-xl { + max-width: 1140px; + } +} +@media (min-width: 1400px) { + .container, + .container-sm, + .container-md, + .container-lg, + .container-xl, + .container-xxl { + max-width: 1320px; + } +} +@media (min-width: 1920px) { + .container, + .container-sm, + .container-md, + .container-lg, + .container-xl, + .container-xxl, + .container-xxxl { + max-width: 1800px; + } +} + /* * Dialog component */ @@ -318,10 +391,10 @@ body { display: inline-block; } -/* La geometría base de .dropdown-button/.dropdown-toggle vive junto a "Buttons" (antes de esta - * sección), para que las clases .button-sm/.button-lg/.button-* ganen el empate de especificidad - * frente a ella (CSS resuelve un empate por orden de aparición en el fichero, no por el orden de - * las clases en el HTML). */ +/* The base geometry of .dropdown-button/.dropdown-toggle lives next to "Buttons" (before this + * section), so that the .button-sm/.button-lg/.button-* classes win the specificity tie against it + * (CSS resolves a tie by order of appearance in the file, not by the order of the classes in the + * HTML). */ .dropdown-toggle::after { content: "\25BC"; display: inline-block; @@ -928,9 +1001,9 @@ input:disabled + label { color: var(--val-color--text--muted); } -/* .page-link-icon muestra el texto de navegación de los botones anterior/siguiente. El texto - accesible completo va aparte, en el aria-label del enlace. Por eso sustituir el texto visible por - otro contenido no afecta a la accesibilidad, p. ej. para usar "‹"/"›" se puede aplicar: +/* .page-link-icon shows the navigation text of the previous/next buttons. The full accessible text + goes separately, in the link's aria-label. So replacing the visible text with other content does + not affect accessibility, e.g. to use "‹"/"›" you can apply: .page-link-icon { display: inline-block; diff --git a/examples/locale/es-ES/navbar-menus.ftl b/examples/locale/es-ES/navbar-menus.ftl index af0b1ec3..a87e407d 100644 --- a/examples/locale/es-ES/navbar-menus.ftl +++ b/examples/locale/es-ES/navbar-menus.ftl @@ -1,7 +1,7 @@ menus_item_label = Etiqueta menus_item_link = Enlace -menus_item_blank = Enlace externo -menus_item_disabled = Enlace deshabilitado +menus_item_blank = Externo +menus_item_disabled = Deshabilitado menus_test_title = Desplegable diff --git a/examples/navbar-menus.rs b/examples/navbar-menus.rs index eb2fecaf..54cb37b7 100644 --- a/examples/navbar-menus.rs +++ b/examples/navbar-menus.rs @@ -1,5 +1,4 @@ use pagetop::prelude::*; -use pagetop_bootsier::theme::*; include_locales!(LOC from "examples/locale"); @@ -16,87 +15,84 @@ impl Extension for SuperMenu { } async fn initialize(&self) { - let navbar_menu = bs::Navbar::brand_left(bs::navbar::Brand::new()) - .with_expand(BreakPoint::LG) - .with_item(bs::navbar::Item::nav( - bs::Nav::new() - .with_item(bs::nav::Item::link(Lc::t("menus_item_link", &LOC), "/")) - .with_item(bs::nav::Item::link_blank( + let navbar_menu = Navbar::brand_left(Brand::new()) + .with_expand(Breakpoint::Lg) + .with_item(navbar::Item::nav( + Nav::new() + .with_item(nav::Item::link(Lc::t("menus_item_link", &LOC), "/")) + .with_item(nav::Item::link_blank( Lc::t("menus_item_blank", &LOC), "https://docs.rs/pagetop", )) - .with_item(bs::nav::Item::dropdown( - bs::Dropdown::new() + .with_item(nav::Item::dropdown( + Dropdown::new() .with_title(Lc::t("menus_test_title", &LOC)) - .with_item(bs::dropdown::Item::header(Lc::t("menus_dev_header", &LOC))) - .with_item(bs::dropdown::Item::link( + .with_item(dropdown::Item::header(Lc::t("menus_dev_header", &LOC))) + .with_item(dropdown::Item::link( Lc::t("menus_dev_getting_started", &LOC), "/dev/getting-started", )) - .with_item(bs::dropdown::Item::link( + .with_item(dropdown::Item::link( Lc::t("menus_dev_guides", &LOC), "/dev/guides", )) - .with_item(bs::dropdown::Item::link_blank( + .with_item(dropdown::Item::link_blank( Lc::t("menus_dev_forum", &LOC), "https://forum.example.dev", )) - .with_item(bs::dropdown::Item::divider()) - .with_item(bs::dropdown::Item::header(Lc::t("menus_sdk_header", &LOC))) - .with_item(bs::dropdown::Item::link( + .with_item(dropdown::Item::divider()) + .with_item(dropdown::Item::header(Lc::t("menus_sdk_header", &LOC))) + .with_item(dropdown::Item::link( Lc::t("menus_sdk_rust", &LOC), "/dev/sdks/rust", )) - .with_item(bs::dropdown::Item::link( + .with_item(dropdown::Item::link( Lc::t("menus_sdk_js", &LOC), "/dev/sdks/js", )) - .with_item(bs::dropdown::Item::link( + .with_item(dropdown::Item::link( Lc::t("menus_sdk_python", &LOC), "/dev/sdks/python", )) - .with_item(bs::dropdown::Item::divider()) - .with_item(bs::dropdown::Item::header(Lc::t( - "menus_plugin_header", - &LOC, - ))) - .with_item(bs::dropdown::Item::link( + .with_item(dropdown::Item::divider()) + .with_item(dropdown::Item::header(Lc::t("menus_plugin_header", &LOC))) + .with_item(dropdown::Item::link( Lc::t("menus_plugin_auth", &LOC), "/dev/sdks/rust/plugins/auth", )) - .with_item(bs::dropdown::Item::link( + .with_item(dropdown::Item::link( Lc::t("menus_plugin_cache", &LOC), "/dev/sdks/rust/plugins/cache", )) - .with_item(bs::dropdown::Item::divider()) - .with_item(bs::dropdown::Item::label(Lc::t("menus_item_label", &LOC))) - .with_item(bs::dropdown::Item::link_disabled( + .with_item(dropdown::Item::divider()) + .with_item(dropdown::Item::label(Lc::t("menus_item_label", &LOC))) + .with_item(dropdown::Item::link_disabled( Lc::t("menus_item_disabled", &LOC), "#", )), )) - .with_item(bs::nav::Item::link_disabled( + .with_item(nav::Item::link_disabled( Lc::t("menus_item_disabled", &LOC), "#", )), )) - .with_item(bs::navbar::Item::nav( - bs::Nav::new() + .with_item(navbar::Item::nav( + Nav::new() // Empuja este menú (y lo que le siga) al extremo final de la barra. .with_prop(FlexItem::push_end()) - .with_item(bs::nav::Item::link( + .with_item(nav::Item::link( Lc::t("menus_item_sign_up", &LOC), "/auth/sign-up", )) - .with_item(bs::nav::Item::link( + .with_item(nav::Item::link( Lc::t("menus_item_login", &LOC), "/auth/login", )), )); InRegion::Global(&CoreRegions::Header).add( - bs::Container::new() - .with_width(bs::container::Width::FluidMax(UnitValue::RelRem(75.0))) + Container::new() + .with_width(container::Width::FluidMax(UnitValue::RelRem(75.0))) .with_child(navbar_menu), ); } diff --git a/extensions/pagetop-bootsier/src/lib.rs b/extensions/pagetop-bootsier/src/lib.rs index 54528768..949913ee 100644 --- a/extensions/pagetop-bootsier/src/lib.rs +++ b/extensions/pagetop-bootsier/src/lib.rs @@ -146,12 +146,12 @@ impl Theme for Bootsier { theme::BootsierColors::from(intent).as_str() } - fn setup_component(&self, component: &mut dyn Component, cx: &mut Context) { + fn setup_component(&self, component: &mut dyn Component, _cx: &mut Context) { setup_component!(component, { Badge => |c| theme::bs::badge::setup(c), Brand => |c| theme::bs::brand::setup(c), Button => |c| theme::bs::button::setup(c), - Container => |c| theme::bs::container::setup(c, cx), + Container => |c| theme::bs::container::setup(c), Dialog => |c| theme::bs::dialog::setup(c), Dropdown => |c| theme::bs::dropdown::setup(c), Image => |c| theme::bs::image::setup(c), diff --git a/extensions/pagetop-bootsier/src/theme.rs b/extensions/pagetop-bootsier/src/theme.rs index f093ac42..6879c222 100644 --- a/extensions/pagetop-bootsier/src/theme.rs +++ b/extensions/pagetop-bootsier/src/theme.rs @@ -103,8 +103,6 @@ pub use bs::badge::BadgeBootsier; #[doc(hidden)] pub use bs::button::ButtonBootsier; #[doc(hidden)] -pub use bs::container::ContainerBootsier; -#[doc(hidden)] pub use bs::dropdown::DropdownBootsier; #[doc(hidden)] pub use bs::form::input::InputBootsier; diff --git a/extensions/pagetop-bootsier/src/theme/bs.rs b/extensions/pagetop-bootsier/src/theme/bs.rs index 0ce50d05..7bc36b71 100644 --- a/extensions/pagetop-bootsier/src/theme/bs.rs +++ b/extensions/pagetop-bootsier/src/theme/bs.rs @@ -27,8 +27,6 @@ pub use button::{Button, ButtonBootsier}; pub mod container; #[doc(inline)] pub use container::Container; -#[doc(inline)] -pub use container::ContainerBootsier; // Dialog. pub mod dialog; diff --git a/extensions/pagetop-bootsier/src/theme/bs/container.rs b/extensions/pagetop-bootsier/src/theme/bs/container.rs index 2bb08e79..00afd2ff 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/container.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/container.rs @@ -1,108 +1,37 @@ //! Definiciones para crear contenedores de componentes ([`Container`]). +//! +//! El comportamiento del ancho del contenedor se establece con [`Container::with_width()`], usando +//! [`Width`]. Sin él, Bootsier aplica los anchos máximos predefinidos de Bootstrap para cada punto +//! de corte ([`Width::Responsive`]). +//! +//! También se pueden aplicar al componente clases predefinidas para: +//! +//! - Modificar el color de fondo ([`Bg`](crate::theme::class::Bg)). +//! - Definir la apariencia del texto ([`Text`](crate::theme::class::Text)). +//! - Establecer bordes ([`Border`](crate::theme::class::Border)). +//! - Redondear las esquinas ([`Rounded`](crate::theme::class::Rounded)). +//! +//! ```rust,no_run +//! use pagetop::prelude::*; +//! use pagetop_bootsier::theme::*; +//! +//! let main = bs::Container::main() +//! .with_id("main-page") +//! .with_width(bs::container::Width::From(Breakpoint::Lg)) +//! .with_prop(PropsOp::add_classes(class::Bg::with(BootsierColors::Light))) +//! .with_prop(PropsOp::add_classes(class::Text::with(BootsierColors::Dark))) +//! .with_prop(PropsOp::add_classes(class::Border::with(ScaleSize::One))) +//! .with_prop(PropsOp::add_classes(class::Rounded::new())); +//! ``` use pagetop::prelude::*; -use crate::theme::*; - -pub use pagetop::base::component::container::{Container, Kind}; - -const EXTRA_WIDTH: &str = "bootsier.container.width"; - -/// Extensión de Bootsier para [`Container`]. -/// -/// Permite establecer el comportamiento del ancho del contenedor usando el método -/// [`with_width()`](Self::with_width). -/// -/// También habilita al componente para aceptar clases predefinidas para: -/// -/// - Modificar el color de fondo ([`Bg`](crate::theme::class::Bg)). -/// - Definir la apariencia del texto ([`Text`](crate::theme::class::Text)). -/// - Establecer bordes ([`Border`](crate::theme::class::Border)). -/// - Redondear las esquinas ([`Rounded`](crate::theme::class::Rounded)). -/// -/// ```rust,no_run -/// use pagetop::prelude::*; -/// use pagetop_bootsier::theme::*; -/// -/// let main = bs::Container::main() -/// .with_id("main-page") -/// .with_width(bs::container::Width::From(Breakpoint::Lg)) -/// .with_prop(PropsOp::add_classes(class::Bg::with(BootsierColors::Light))) -/// .with_prop(PropsOp::add_classes(class::Text::with(BootsierColors::Dark))) -/// .with_prop(PropsOp::add_classes(class::Border::with(ScaleSize::One))) -/// .with_prop(PropsOp::add_classes(class::Rounded::new())); -/// ``` -#[builder_impl] -pub trait ContainerBootsier { - /// Establece el comportamiento del ancho para el contenedor. - /// - /// Determina si el contenedor aplica los anchos máximos predefinidos para cada punto de corte, - /// o si ocupa siempre el 100% del ancho disponible, o lo hace hasta un ancho máximo explícito. - /// Ver [`Width`] para las variantes disponibles. - fn with_width(self, width: Width) -> Self; -} - -#[builder_impl] -impl ContainerBootsier for Container { - fn with_width(mut self, width: Width) -> Self { - self.alter_prop(PropsOp::set_extra(EXTRA_WIDTH, width)); - self - } -} - -// **< Width >************************************************************************************** - -/// Define cómo se comporta el ancho de un contenedor ([`Container`]). -#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] -pub enum Width { - /// Comportamiento por defecto, aplica los anchos máximos predefinidos para cada punto de corte. - /// Por debajo del menor punto de corte ocupa el 100% del ancho disponible. - #[default] - Default, - /// Aplica los anchos máximos predefinidos a partir del punto de corte indicado. Por debajo de - /// ese punto de corte ocupa el 100% del ancho disponible. - From(Breakpoint), - /// Ocupa el 100% del ancho disponible siempre. - Fluid, - /// Ocupa el 100% del ancho disponible hasta un ancho máximo explícito. - FluidMax(UnitValue), -} - -impl Width { - const CONTAINER: &str = "container"; - - /// Añade la clase asociada al ancho del contenedor a la cadena de clases. - /// - /// El nombre del punto de corte se resuelve en el tema activo de `cx`. - #[inline] - pub fn push_to(self, cx: &Context, classes: &mut String) { - match self { - Self::Default => { - push_breakpoint_class(cx, Breakpoint::Xs, classes, Self::CONTAINER, "") - } - Self::From(bp) => push_breakpoint_class(cx, bp, classes, Self::CONTAINER, ""), - Self::Fluid | Self::FluidMax(_) => { - push_breakpoint_class(cx, Breakpoint::Xs, classes, Self::CONTAINER, "fluid") - } - } - } - - /// Devuelve la clase asociada al ancho del contenedor. - pub fn to_class(self, cx: &Context) -> String { - let mut class = String::new(); - self.push_to(cx, &mut class); - class - } -} +pub use pagetop::base::component::container::{Container, Kind, Width}; // **< Container SETUP >**************************************************************************** -pub(crate) fn setup(container: &mut Container, cx: &Context) { - let width = container.props().extra_or(EXTRA_WIDTH, Width::default()); - container.alter_prop(PropsOp::prepend_classes(width.to_class(cx))); - if let Width::FluidMax(w) = width - && w.is_measurable() - { - container.alter_prop(PropsOp::add_style("max-width", w.to_string())); +pub(crate) fn setup(container: &mut Container) { + if container.width().is_none() { + container.alter_prop(PropsOp::prepend_classes("container")); } } diff --git a/extensions/pagetop-bootsier/src/theme/bs/layout/template.rs b/extensions/pagetop-bootsier/src/theme/bs/layout/template.rs index 8b80c84b..90ba79c9 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/layout/template.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/layout/template.rs @@ -1,7 +1,7 @@ use pagetop::prelude::*; use crate::config; -use crate::theme::{ContainerBootsier, bs}; +use crate::theme::bs; use crate::{ADMINLTE_VERSION, LOCALES_BOOTSIER}; // Regiones de Bootsier: se renderiza sin el `
` envolvente que aplica diff --git a/extensions/pagetop-bootsier/tests/breakpoint_class.rs b/extensions/pagetop-bootsier/tests/breakpoint_class.rs index 659bc926..1261c8e5 100644 --- a/extensions/pagetop-bootsier/tests/breakpoint_class.rs +++ b/extensions/pagetop-bootsier/tests/breakpoint_class.rs @@ -1,5 +1,6 @@ -// Verifies that Bootsier builds its breakpoint-based Bootstrap classes from the breakpoint names -// of `Bootsier::breakpoint_entry()`, and that `Xxxl` (absent in Bootstrap) resolves as `xxl`. +// Verifies that the breakpoint-based Bootstrap classes of `Container`, `Navbar`, `Dropdown` and +// `Offcanvas` use the breakpoint names of `Bootsier::breakpoint_entry()`, and that `Xxxl` (absent +// in Bootstrap) resolves as `xxl`. use pagetop::prelude::*; use pagetop_bootsier::theme::*; @@ -8,23 +9,43 @@ fn cx() -> Context { Context::default().with_theme(&pagetop_bootsier::Bootsier) } +async fn container_html(width: impl Into>) -> String { + let mut container = bs::Container::new() + .with_width(width) + .with_child(Html::with(|_| html! { p { "Content" } })); + container.render(&mut cx()).await.into_string() +} + +#[pagetop::test] +async fn container_without_width_gets_the_bootstrap_default() { + assert!(container_html(None).await.contains(r#"class="container""#)); + assert!( + container_html(bs::container::Width::Responsive) + .await + .contains(r#"class="container""#) + ); +} + #[pagetop::test] async fn container_width_uses_breakpoint_name() { - let cx = cx(); - assert_eq!(bs::container::Width::Default.to_class(&cx), "container"); - assert_eq!(bs::container::Width::Fluid.to_class(&cx), "container-fluid"); - assert_eq!( - bs::container::Width::From(Breakpoint::Lg).to_class(&cx), - "container-lg" + assert!( + container_html(bs::container::Width::Fluid) + .await + .contains(r#"class="container-fluid""#) + ); + assert!( + container_html(bs::container::Width::From(Breakpoint::Lg)) + .await + .contains(r#"class="container-lg""#) ); } #[pagetop::test] async fn container_width_xxxl_resolves_as_xxl() { - let cx = cx(); - assert_eq!( - bs::container::Width::From(Breakpoint::Xxxl).to_class(&cx), - "container-xxl" + assert!( + container_html(bs::container::Width::From(Breakpoint::Xxxl)) + .await + .contains(r#"class="container-xxl""#) ); } diff --git a/src/base/component/container.rs b/src/base/component/container.rs index a33ad79f..08e44934 100644 --- a/src/base/component/container.rs +++ b/src/base/component/container.rs @@ -25,28 +25,61 @@ pub enum Kind { Article, } +// **< Width >************************************************************************************** + +/// Define cómo se comporta el ancho de un contenedor ([`Container`]). +/// +/// Cada variante se traduce en una clase CSS (`container`, `container-{nombre}` o +/// `container-fluid`), con el nombre que el tema activo da al punto de corte, y el tema decide qué +/// anchos máximos le corresponden. [`FluidMax`](Self::FluidMax) añade además un `max-width` en +/// línea. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Width { + /// Aplica los anchos máximos predefinidos por el tema para cada punto de corte. Por debajo del + /// menor punto de corte ocupa el 100% del ancho disponible. + Responsive, + /// Aplica los anchos máximos predefinidos a partir del punto de corte indicado. Por debajo de + /// ese punto de corte ocupa el 100% del ancho disponible. + From(Breakpoint), + /// Ocupa el 100% del ancho disponible siempre. + Fluid, + /// Ocupa el 100% del ancho disponible hasta un ancho máximo explícito. + FluidMax(UnitValue), +} + // **< Container >********************************************************************************** /// Componente para crear un **contenedor de componentes**. /// /// Envuelve un conjunto de componentes en un contenedor establecido que se crea aplicando uno de -/// los tipos definidos en [`Kind`]. +/// los tipos definidos en [`Kind`]. Opcionalmente, su ancho se controla con [`Width`] mediante +/// [`with_width()`](Self::with_width); sin él, el contenedor no añade ninguna clase de ancho. /// /// Si no contiene elementos, el componente **no se renderiza**. /// -/// # Ejemplo +/// # Ejemplos /// /// ```rust,no_run /// use pagetop::prelude::*; /// /// let main = Container::main().with_id("main-page"); /// ``` +/// +/// Contenedor centrado que ocupa todo el ancho hasta un máximo explícito: +/// +/// ```rust,no_run +/// # use pagetop::prelude::*; +/// let page = Container::new().with_width(container::Width::FluidMax(UnitValue::RelRem(75.0))); +/// ``` #[derive(AutoDefault, Clone, Debug, Getters)] pub struct Container { /// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente. props: Props, /// Devuelve el tipo semántico del contenedor. kind: Kind, + /// Devuelve el comportamiento del ancho, si se ha fijado. + #[getters(copy)] + width: Option, /// Devuelve la lista de componentes (`children`) del contenedor. children: Children, } @@ -61,6 +94,25 @@ impl Component for Container { self.props.get_id() } + fn setup(&mut self, cx: &mut Context) { + if let Some(width) = self.width() { + let class: CowStr = match width { + Width::Responsive => "container".into(), + Width::From(bp) => match bp.resolved(cx) { + Some(entry) => util::join!("container-", entry.name).into(), + None => "container".into(), + }, + Width::Fluid | Width::FluidMax(_) => "container-fluid".into(), + }; + self.alter_prop(PropsOp::prepend_classes(class)); + if let Width::FluidMax(max) = width + && max.is_measurable() + { + self.alter_prop(PropsOp::add_style("max-width", max.to_string())); + } + } + } + #[rustfmt::skip] async fn prepare(&self, cx: &mut Context) -> Result { let output = self.children().render(cx).await; @@ -135,6 +187,15 @@ impl Container { self } + /// Define el comportamiento del ancho del contenedor. Con `None` no se añade ninguna clase de + /// ancho y cada tema decide cómo tratarlo. + /// + /// Ver [`Width`] para las variantes disponibles. + pub fn with_width(mut self, width: impl Into>) -> Self { + self.width = width.into(); + self + } + /// Añade un nuevo componente al contenedor o modifica la lista de componentes (`children`) con /// una operación [`ChildOp`]. pub fn with_child(mut self, op: impl Into) -> Self { diff --git a/tests/component_container.rs b/tests/component_container.rs index 95f6831a..7a3374ee 100644 --- a/tests/component_container.rs +++ b/tests/component_container.rs @@ -31,3 +31,83 @@ async fn main_kind_renders_a_main_element() { assert!(html.starts_with("")); } + +// **< Container::with_width >********************************************************************** + +async fn container_html(width: impl Into>) -> String { + let mut container = Container::new().with_width(width).with_child(Lc::n("x")); + container + .render(&mut Context::default()) + .await + .into_string() +} + +#[pagetop::test] +async fn width_is_none_by_default_and_adds_no_class() { + assert_eq!(Container::new().width(), None); + // A neutral box: without a width, the component adds no class at all. + assert!(!container_html(None).await.contains("container")); +} + +#[pagetop::test] +async fn width_can_be_set_and_reset() { + let width = container::Width::From(Breakpoint::Lg); + assert_eq!(Container::new().with_width(width).width(), Some(width)); + assert_eq!( + Container::new().with_width(width).with_width(None).width(), + None + ); +} + +#[pagetop::test] +async fn width_responsive_adds_the_container_class() { + assert!( + container_html(container::Width::Responsive) + .await + .contains(r#"class="container""#) + ); +} + +#[pagetop::test] +async fn width_from_uses_the_breakpoint_name() { + assert!( + container_html(container::Width::From(Breakpoint::Lg)) + .await + .contains(r#"class="container-lg""#) + ); +} + +#[pagetop::test] +async fn width_from_a_breakpoint_without_min_width_has_no_name() { + // `Xs` has no minimum width, so the class carries no breakpoint name. + assert!( + container_html(container::Width::From(Breakpoint::Xs)) + .await + .contains(r#"class="container""#) + ); +} + +#[pagetop::test] +async fn width_fluid_adds_the_container_fluid_class() { + assert!( + container_html(container::Width::Fluid) + .await + .contains(r#"class="container-fluid""#) + ); +} + +#[pagetop::test] +async fn width_fluid_max_adds_the_class_and_a_max_width_style() { + let html = container_html(container::Width::FluidMax(UnitValue::RelRem(75.0))).await; + + assert!(html.contains("container-fluid")); + assert!(html.contains("max-width: 75rem")); +} + +#[pagetop::test] +async fn width_fluid_max_ignores_a_non_measurable_value() { + let html = container_html(container::Width::FluidMax(UnitValue::Auto)).await; + + assert!(html.contains("container-fluid")); + assert!(!html.contains("max-width")); +} From 8651b91ab2eb71587852cca5ead56d367584cb6c Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sun, 20 Sep 2026 14:33:43 +0200 Subject: [PATCH 2/4] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(minimal):=20Reimpleme?= =?UTF-8?q?nta=20`join!`=20y=20`join=5Fpair!`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Usa `[&str]::concat()` de la biblioteca estándar y elimina la dependencia de `concat-string`. Cada argumento se evalúa una sola vez y no se consume. --- Cargo.lock | 7 --- Cargo.toml | 1 - extensions/pagetop-admin/src/settings.rs | 5 +- .../src/theme/bs/dropdown/props.rs | 2 +- extensions/pagetop-htmx/src/hx.rs | 6 ++- .../component/admin/admin_password_form.rs | 4 +- .../src/component/admin/role_form.rs | 10 ++-- .../component/admin/role_permissions_form.rs | 8 +-- .../src/component/admin/role_table.rs | 12 ++--- .../src/component/admin/user_form.rs | 10 ++-- .../src/component/admin/user_roles_form.rs | 4 +- .../src/component/admin/user_table.rs | 9 ++-- .../pagetop-user/src/handlers/admin/roles.rs | 3 +- .../pagetop-user/src/handlers/admin/users.rs | 32 +++++------- extensions/pagetop-user/src/lib.rs | 10 ++++ extensions/pagetop-user/src/session.rs | 35 +++++++------ helpers/pagetop-minimal/Cargo.toml | 1 - helpers/pagetop-minimal/README.md | 5 -- helpers/pagetop-minimal/src/lib.rs | 49 ++++++++++++------- 19 files changed, 105 insertions(+), 108 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 30f588d2..dad698e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -396,12 +396,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "concat-string" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7439becb5fafc780b6f4de382b1a7a3e70234afe783854a4702ee8adbb838609" - [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1892,7 +1886,6 @@ dependencies = [ name = "pagetop-minimal" version = "0.1.0" dependencies = [ - "concat-string", "indoc", "pastey", ] diff --git a/Cargo.toml b/Cargo.toml index 730922ca..3e0974fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,6 @@ base64ct = { version = "1.8", features = ["alloc"] } change-detection = "1.2" chrono = "0.4" colored = "3.1" -concat-string = "1.0" config = { version = "0.15", default-features = false, features = ["toml"] } figlet-rs = "1.0" fluent-templates = "0.14" diff --git a/extensions/pagetop-admin/src/settings.rs b/extensions/pagetop-admin/src/settings.rs index e2e38ee7..60f73637 100644 --- a/extensions/pagetop-admin/src/settings.rs +++ b/extensions/pagetop-admin/src/settings.rs @@ -2,8 +2,7 @@ //! //! Proporciona una API async para leer y escribir valores JSON en la tabla `settings`. -use pagetop::datetime::Utc; -use pagetop::{Getters, builder_impl}; +use pagetop::prelude::*; use pagetop_seaorm::db::{ ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter, dbconn, }; @@ -231,6 +230,6 @@ impl SettingsSchema { /// Devuelve la clave completa de un campo: `"{scope}.{field_name}"`. pub fn key_for(&self, field_name: &str) -> String { - format!("{}.{}", self.scope, field_name) + util::join!(&self.scope, ".", field_name) } } diff --git a/extensions/pagetop-bootsier/src/theme/bs/dropdown/props.rs b/extensions/pagetop-bootsier/src/theme/bs/dropdown/props.rs index c07fcceb..948f349b 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/dropdown/props.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/dropdown/props.rs @@ -202,7 +202,7 @@ impl MenuPosition { #[inline] pub(crate) fn data_offset(self) -> Option { match self { - Self::Offset(x, y) => Some(format!("{x},{y}")), + Self::Offset(x, y) => Some(util::join!(x.to_string(), ",", y.to_string())), _ => None, } } diff --git a/extensions/pagetop-htmx/src/hx.rs b/extensions/pagetop-htmx/src/hx.rs index 1022d077..9db67530 100644 --- a/extensions/pagetop-htmx/src/hx.rs +++ b/extensions/pagetop-htmx/src/hx.rs @@ -81,6 +81,8 @@ //! .with_prop(PropsOp::set(hx::on_htmx("after-swap"), "console.log('done')")); //! ``` +use pagetop::prelude::*; + // **< HTTP Methods >******************************************************************************* /// Realiza una petición GET al servidor y aplica la respuesta al objetivo. @@ -356,7 +358,7 @@ pub const DISABLE: &str = "hx-disable"; /// .with_prop(PropsOp::set(hx::on("mouseenter"), "this.style.opacity='0.8'")); /// ``` pub fn on(event: &str) -> String { - format!("hx-on:{event}") + util::join!("hx-on:", event) } /// Genera `hx-on::{event}` para escuchar eventos propios de HTMX en línea. @@ -372,7 +374,7 @@ pub fn on(event: &str) -> String { /// .with_prop(PropsOp::set(hx::on_htmx("after-swap"), "initTooltips()")); /// ``` pub fn on_htmx(event: &str) -> String { - format!("hx-on::{event}") + util::join!("hx-on::", event) } // **< HTMX Request Headers >*********************************************************************** diff --git a/extensions/pagetop-user/src/component/admin/admin_password_form.rs b/extensions/pagetop-user/src/component/admin/admin_password_form.rs index 3c418660..5775d8b0 100644 --- a/extensions/pagetop-user/src/component/admin/admin_password_form.rs +++ b/extensions/pagetop-user/src/component/admin/admin_password_form.rs @@ -2,8 +2,8 @@ use pagetop::prelude::*; -use crate::ADMIN_USERS_PATH; use crate::LOCALES_USER; +use crate::user_path; use crate::component::{PasswordConfirm, error_banner}; @@ -22,7 +22,7 @@ impl Component for AdminPasswordForm { } async fn prepare(&self, cx: &mut Context) -> Result { - let action = format!("{ADMIN_USERS_PATH}/{}/password", self.user_id()); + let action = user_path(self.user_id(), "password"); let action = self.waypoint().append_to(cx.route(action)); let mut form = Form::new() diff --git a/extensions/pagetop-user/src/component/admin/role_form.rs b/extensions/pagetop-user/src/component/admin/role_form.rs index 5bbfea89..f132f233 100644 --- a/extensions/pagetop-user/src/component/admin/role_form.rs +++ b/extensions/pagetop-user/src/component/admin/role_form.rs @@ -4,6 +4,7 @@ use pagetop::prelude::*; use crate::ADMIN_ROLES_PATH; use crate::LOCALES_USER; +use crate::role_path; use crate::component::error_banner; @@ -35,13 +36,8 @@ impl Component for RoleForm { async fn prepare(&self, cx: &mut Context) -> Result { let action = match self.mode() { - RoleFormMode::New => format!("{ADMIN_ROLES_PATH}/new"), - RoleFormMode::Edit => { - format!( - "{ADMIN_ROLES_PATH}/{}/edit", - self.role_id().copied().unwrap_or_default() - ) - } + RoleFormMode::New => util::join!(ADMIN_ROLES_PATH, "/new"), + RoleFormMode::Edit => role_path(self.role_id().copied().unwrap_or_default(), "edit"), }; let action = self.waypoint().append_to(cx.route(action)); diff --git a/extensions/pagetop-user/src/component/admin/role_permissions_form.rs b/extensions/pagetop-user/src/component/admin/role_permissions_form.rs index ad1b237c..8efc0d34 100644 --- a/extensions/pagetop-user/src/component/admin/role_permissions_form.rs +++ b/extensions/pagetop-user/src/component/admin/role_permissions_form.rs @@ -4,8 +4,8 @@ use pagetop::prelude::*; -use crate::ADMIN_ROLES_PATH; use crate::LOCALES_USER; +use crate::role_path; use crate::component::admin::PermissionGroups; use crate::component::error_banner; @@ -26,7 +26,7 @@ impl Component for RolePermissionsForm { } async fn prepare(&self, cx: &mut Context) -> Result { - let action = format!("{ADMIN_ROLES_PATH}/{}/permissions", self.role_id()); + let action = role_path(self.role_id(), "permissions"); let action = self.waypoint().append_to(cx.route(action)); let mut form = Form::new() @@ -37,12 +37,12 @@ impl Component for RolePermissionsForm { for (idx, (group_label, perms)) in self.groups().iter().enumerate() { let mut field = form::check::Field::new() - .with_id(format!("permission-group-{idx}")) + .with_id(util::join!("permission-group-", idx.to_string())) .with_name("permission_keys"); for (key, label, checked) in perms { let text = label.lookup(cx).unwrap_or_default(); field = field.with_item( - form::check::Item::new(key, Lc::n(format!("{text} ({key})"))) + form::check::Item::new(key, Lc::n(util::join!(text, " (", key, ")"))) .with_checked(*checked), ); } diff --git a/extensions/pagetop-user/src/component/admin/role_table.rs b/extensions/pagetop-user/src/component/admin/role_table.rs index 6ce4397b..cc298c67 100644 --- a/extensions/pagetop-user/src/component/admin/role_table.rs +++ b/extensions/pagetop-user/src/component/admin/role_table.rs @@ -8,6 +8,7 @@ use pagetop_htmx::hx_table::sort_link; use crate::ADMIN_ROLES_PATH; use crate::LOCALES_USER; +use crate::role_path; use crate::service::role_admin::{RoleListItem, RoleSortField}; #[derive(AutoDefault, Clone, Debug, Getters)] @@ -108,7 +109,7 @@ impl Component for RoleTable { ); } - let new_href = waypoint.append_to(cx.route(format!("{ADMIN_ROLES_PATH}/new"))); + let new_href = waypoint.append_to(cx.route(util::join!(ADMIN_ROLES_PATH, "/new"))); Ok(html! { div (self.props().unpack(cx)) { @@ -212,7 +213,7 @@ fn label_cell(role: &RoleListItem, waypoint: &Waypoint) -> Html { let id = role.id; let waypoint = waypoint.clone(); Html::with(move |cx| { - let view_href = waypoint.append_to(cx.route(format!("{ADMIN_ROLES_PATH}/{id}/view"))); + let view_href = waypoint.append_to(cx.route(role_path(id, "view"))); html! { a href=(view_href) { (label.as_str()) } } @@ -234,8 +235,7 @@ async fn actions_cell( let locked = role.locked; let waypoint = waypoint.clone(); - let permissions_href = - waypoint.append_to(cx.route(format!("{ADMIN_ROLES_PATH}/{id}/permissions"))); + let permissions_href = waypoint.append_to(cx.route(role_path(id, "permissions"))); // Los botones se renderizan aquí, no dentro del `Html::with()` de abajo: necesitan pasar por // su propio ciclo de renderizado (`.render().await`) para que el tema activo los estilice @@ -253,7 +253,7 @@ async fn actions_cell( let (edit_button, delete_button) = if locked { (None, None) } else { - let edit_href = waypoint.append_to(cx.route(format!("{ADMIN_ROLES_PATH}/{id}/edit"))); + let edit_href = waypoint.append_to(cx.route(role_path(id, "edit"))); let edit_button = Button::anchor(Lc::t("btn-edit", &LOCALES_USER), edit_href) .with_style(button::Style::Solid(Intent::Primary)) .with_size(button::Size::Small) @@ -263,7 +263,7 @@ async fn actions_cell( // Viaja como query string para que, tanto si el borrado falla como si tiene éxito, la // tabla vuelva a mostrarse en la misma página/orden en que estaba, en vez de reiniciarse. let confirm_href = cx - .route(format!("{ADMIN_ROLES_PATH}/{id}/delete/confirm")) + .route(role_path(id, "delete/confirm")) .alter_param("sort", sort.as_str()) .alter_param("dir", dir) .alter_param("page", page.to_string()) diff --git a/extensions/pagetop-user/src/component/admin/user_form.rs b/extensions/pagetop-user/src/component/admin/user_form.rs index ecaf8217..621f5d9e 100644 --- a/extensions/pagetop-user/src/component/admin/user_form.rs +++ b/extensions/pagetop-user/src/component/admin/user_form.rs @@ -4,6 +4,7 @@ use pagetop::prelude::*; use crate::ADMIN_USERS_PATH; use crate::LOCALES_USER; +use crate::user_path; use crate::component::{PasswordConfirm, error_banner}; @@ -43,13 +44,8 @@ impl Component for UserForm { async fn prepare(&self, cx: &mut Context) -> Result { let action = match self.mode() { - UserFormMode::New => format!("{ADMIN_USERS_PATH}/new"), - UserFormMode::Edit => { - format!( - "{ADMIN_USERS_PATH}/{}/edit", - self.user_id().copied().unwrap_or_default() - ) - } + UserFormMode::New => util::join!(ADMIN_USERS_PATH, "/new"), + UserFormMode::Edit => user_path(self.user_id().copied().unwrap_or_default(), "edit"), }; let action = self.waypoint().append_to(cx.route(action)); diff --git a/extensions/pagetop-user/src/component/admin/user_roles_form.rs b/extensions/pagetop-user/src/component/admin/user_roles_form.rs index 927c07b0..9a3a8dc0 100644 --- a/extensions/pagetop-user/src/component/admin/user_roles_form.rs +++ b/extensions/pagetop-user/src/component/admin/user_roles_form.rs @@ -2,8 +2,8 @@ use pagetop::prelude::*; -use crate::ADMIN_USERS_PATH; use crate::LOCALES_USER; +use crate::user_path; use crate::component::error_banner; @@ -25,7 +25,7 @@ impl Component for UserRolesForm { } async fn prepare(&self, cx: &mut Context) -> Result { - let action = format!("{ADMIN_USERS_PATH}/{}/roles", self.user_id()); + let action = user_path(self.user_id(), "roles"); let action = self.waypoint().append_to(cx.route(action)); let mut form = Form::new() diff --git a/extensions/pagetop-user/src/component/admin/user_table.rs b/extensions/pagetop-user/src/component/admin/user_table.rs index f2122a19..56ab070d 100644 --- a/extensions/pagetop-user/src/component/admin/user_table.rs +++ b/extensions/pagetop-user/src/component/admin/user_table.rs @@ -11,6 +11,7 @@ use crate::LOCALES_USER; use crate::account::UserStatus; use crate::permission::UserPermission; use crate::service::user_admin::{UserListItem, UserSortField}; +use crate::user_path; #[derive(AutoDefault, Clone, Debug, Getters)] pub(crate) struct UserTable { @@ -84,7 +85,7 @@ impl Component for UserTable { ); } - let new_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/new"))); + let new_href = waypoint.append_to(cx.route(util::join!(ADMIN_USERS_PATH, "/new"))); Ok(html! { div (self.props().unpack(cx)) { @@ -191,7 +192,7 @@ fn username_cell(user: &UserListItem, waypoint: &Waypoint) -> Html { let id = user.id; let waypoint = waypoint.clone(); Html::with(move |cx| { - let view_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{id}/view"))); + let view_href = waypoint.append_to(cx.route(user_path(id, "view"))); html! { a href=(view_href) { (username.as_str()) } } @@ -208,7 +209,7 @@ async fn actions_cell( cx: &mut Context, ) -> Html { let id = user.id; - let edit_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{id}/edit"))); + let edit_href = waypoint.append_to(cx.route(user_path(id, "edit"))); // El botón se renderiza aquí, no dentro del `Html::with()` de abajo: necesita pasar por su // propio ciclo de renderizado (`.render().await`) para que el tema activo lo estilice igual @@ -220,7 +221,7 @@ async fn actions_cell( .await; let roles_button = if can_assign_roles { - let roles_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{id}/roles"))); + let roles_href = waypoint.append_to(cx.route(user_path(id, "roles"))); Some( Button::anchor(Lc::t("btn-manage-roles", &LOCALES_USER), roles_href) .with_style(button::Style::Solid(Intent::Neutral)) diff --git a/extensions/pagetop-user/src/handlers/admin/roles.rs b/extensions/pagetop-user/src/handlers/admin/roles.rs index a2211ce9..4c63a698 100644 --- a/extensions/pagetop-user/src/handlers/admin/roles.rs +++ b/extensions/pagetop-user/src/handlers/admin/roles.rs @@ -15,6 +15,7 @@ use crate::config::SETTINGS; use crate::entity::role; use crate::handlers::admin::{back_link, frame, map_auth_error}; use crate::permission::{self, UserPermission}; +use crate::role_path; use crate::service::role_admin::{self, RolePageParams, RoleSortField}; #[derive(Deserialize)] @@ -451,7 +452,7 @@ pub(crate) async fn delete_confirm_get( let mut cx = Context::admin(request); let delete_href = cx - .route(format!("{ADMIN_ROLES_PATH}/{id}/delete")) + .route(role_path(id, "delete")) .alter_param( "sort", RoleSortField::from_query(query.sort.as_deref()).as_str(), diff --git a/extensions/pagetop-user/src/handlers/admin/users.rs b/extensions/pagetop-user/src/handlers/admin/users.rs index ed045684..40900feb 100644 --- a/extensions/pagetop-user/src/handlers/admin/users.rs +++ b/extensions/pagetop-user/src/handlers/admin/users.rs @@ -6,7 +6,6 @@ use pagetop::base::component::table::Row; use pagetop::prelude::*; use pagetop_htmx::prelude::*; -use crate::ADMIN_ROLES_PATH; use crate::ADMIN_USERS_PATH; use crate::ANONYMOUS_ROLE_ID; use crate::AUTHENTICATED_ROLE_ID; @@ -22,8 +21,10 @@ use crate::error::AuthError; use crate::handlers::admin::{back_link, frame, map_auth_error}; use crate::password; use crate::permission::UserPermission; +use crate::role_path; use crate::service::role_admin; use crate::service::user_admin::{self, UserListParams, UserSortField}; +use crate::user_path; #[derive(Deserialize)] pub(crate) struct UsersQuery { @@ -341,12 +342,10 @@ fn edit_actions( ("true", "btn-grant-admin", "confirm-grant-admin") }; - let roles_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{user_id}/roles"))); - let password_href = - waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{user_id}/password"))); - let status_action = - waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{user_id}/status"))); - let admin_action = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{user_id}/admin"))); + let roles_href = waypoint.append_to(cx.route(user_path(user_id, "roles"))); + let password_href = waypoint.append_to(cx.route(user_path(user_id, "password"))); + let status_action = waypoint.append_to(cx.route(user_path(user_id, "status"))); + let admin_action = waypoint.append_to(cx.route(user_path(user_id, "admin"))); let mut status_form = Form::new() .with_action(status_action.clone()) @@ -593,9 +592,10 @@ async fn user_view_roles(roles: &[role::Model], cx: &mut Context) -> Block { table.user-admin-table { tbody { @for (id, machine_name, label, system_badge) in &items { + @let href = cx.route(role_path(*id, "view")).to_string(); tr { td { - a href=(cx.route(format!("{ADMIN_ROLES_PATH}/{id}/view")).to_string()) { + a href=(href) { (label.as_str()) } } @@ -744,7 +744,7 @@ pub(crate) async fn status_post( match user_admin::set_user_status(id, new_status, account.id).await { Ok(()) => { let cx = Context::admin(request); - let edit_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{id}/edit"))); + let edit_href = waypoint.append_to(cx.route(user_path(id, "edit"))); if is_htmx { Ok(HtmxResponse::empty().redirect(edit_href).into_response()) } else { @@ -791,7 +791,7 @@ pub(crate) async fn admin_post( match user_admin::set_user_admin(id, new_is_admin, account.id).await { Ok(()) => { let cx = Context::admin(request); - let edit_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{id}/edit"))); + let edit_href = waypoint.append_to(cx.route(user_path(id, "edit"))); if is_htmx { Ok(HtmxResponse::empty().redirect(edit_href).into_response()) } else { @@ -816,10 +816,7 @@ pub(crate) async fn password_get( return Err(ErrorPage::NotFound(Some(request))); } let mut page = Page::admin(request); - let edit_href = waypoint.append_to( - page.context() - .route(format!("{ADMIN_USERS_PATH}/{id}/edit")), - ); + let edit_href = waypoint.append_to(page.context().route(user_path(id, "edit"))); let title = Lc::t("title-admin-user-password", &LOCALES_USER); Ok(page .with_title(title.clone()) @@ -861,15 +858,12 @@ pub(crate) async fn password_post( match result { Ok(()) => { let cx = Context::admin(request); - let edit_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{id}/edit"))); + let edit_href = waypoint.append_to(cx.route(user_path(id, "edit"))); Ok(Redirect::see_other(edit_href).into_response()) } Err(err) => { let mut page = Page::admin(request); - let edit_href = waypoint.append_to( - page.context() - .route(format!("{ADMIN_USERS_PATH}/{id}/edit")), - ); + let edit_href = waypoint.append_to(page.context().route(user_path(id, "edit"))); let title = Lc::t("title-admin-user-password", &LOCALES_USER); Ok(page .with_title(title.clone()) diff --git a/extensions/pagetop-user/src/lib.rs b/extensions/pagetop-user/src/lib.rs index 3cffd393..781a1cff 100644 --- a/extensions/pagetop-user/src/lib.rs +++ b/extensions/pagetop-user/src/lib.rs @@ -131,6 +131,16 @@ const ADMIN_ROLES_PATH: &str = "/admin/user/roles"; // Catálogo de permisos registrados, agrupado por extensión (solo lectura). const ADMIN_PERMISSIONS_PATH: &str = "/admin/user/permissions"; +// Ruta de una acción sobre el usuario `id`: `{ADMIN_USERS_PATH}/{id}/{tail}`. +fn user_path(id: i32, tail: &str) -> String { + util::join!(ADMIN_USERS_PATH, "/", id.to_string(), "/", tail) +} + +// Ruta de una acción sobre el rol `id`: `{ADMIN_ROLES_PATH}/{id}/{tail}`. +fn role_path(id: i32, tail: &str) -> String { + util::join!(ADMIN_ROLES_PATH, "/", id.to_string(), "/", tail) +} + // **< Registro en pagetop-admin (fijo) >*********************************************************** // Registra las páginas de usuarios, roles y permisos en el portal de `pagetop-admin`, bajo la diff --git a/extensions/pagetop-user/src/session.rs b/extensions/pagetop-user/src/session.rs index 485e3138..a7a2b14b 100644 --- a/extensions/pagetop-user/src/session.rs +++ b/extensions/pagetop-user/src/session.rs @@ -1,8 +1,6 @@ //! Gestión de sesiones de usuario (creación, carga, destrucción). -use pagetop::auth::CurrentUser; -use pagetop::datetime::{Duration, Utc}; -use pagetop::web::http::{HeaderMap, header}; +use pagetop::prelude::*; use pagetop_seaorm::db::{ ActiveModelTrait, ColumnTrait, DbErr, EntityTrait, QueryFilter, Set, dbconn, }; @@ -35,32 +33,33 @@ fn bytes_to_hex(bytes: &[u8]) -> String { /// Construye el valor de la cabecera `Set-Cookie` para la cookie de sesión. pub fn build_cookie(sid: &str, remember: bool) -> String { - let mut parts = vec![ - format!("{}={}", SETTINGS.session_cookie_name, sid), - "HttpOnly".into(), - "SameSite=Lax".into(), - "Path=/".into(), - ]; + let mut cookie = util::join!( + &SETTINGS.session_cookie_name, + "=", + sid, + "; HttpOnly; SameSite=Lax; Path=/" + ); if SETTINGS.secure_cookie { - parts.push("Secure".into()); + cookie.push_str("; Secure"); } if remember { - parts.push(format!("Max-Age={}", SETTINGS.session_ttl_secs)); + cookie.push_str("; Max-Age="); + cookie.push_str(&SETTINGS.session_ttl_secs.to_string()); } - parts.join("; ") + cookie } /// Construye la cookie de expiración (Max-Age=0) para borrar la sesión del navegador. pub fn expiry_cookie() -> String { - format!( - "{}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0", - SETTINGS.session_cookie_name + util::join!( + &SETTINGS.session_cookie_name, + "=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0" ) } /// Extrae el session ID de las cabeceras HTTP de la petición, si existe. -pub fn extract_sid(headers: Option<&HeaderMap>) -> Option { - let cookie_str = headers?.get(header::COOKIE)?.to_str().ok()?; +pub fn extract_sid(headers: Option<&web::http::HeaderMap>) -> Option { + let cookie_str = headers?.get(web::http::header::COOKIE)?.to_str().ok()?; let name = SETTINGS.session_cookie_name.as_str(); for part in cookie_str.split(';') { @@ -78,7 +77,7 @@ pub fn extract_sid(headers: Option<&HeaderMap>) -> Option { /// /// Si no hay cookie o la sesión ha expirado, devuelve `(CurrentUser::Anonymous, None)`. /// Se llama desde el middleware de sesión, que es async. -pub async fn resolve_session(headers: &HeaderMap) -> (CurrentUser, Option) { +pub async fn resolve_session(headers: &web::http::HeaderMap) -> (CurrentUser, Option) { let Some(sid) = extract_sid(Some(headers)) else { return (CurrentUser::Anonymous, None); }; diff --git a/helpers/pagetop-minimal/Cargo.toml b/helpers/pagetop-minimal/Cargo.toml index dfb37a9d..c67e7587 100644 --- a/helpers/pagetop-minimal/Cargo.toml +++ b/helpers/pagetop-minimal/Cargo.toml @@ -16,6 +16,5 @@ license.workspace = true authors.workspace = true [dependencies] -concat-string.workspace = true indoc.workspace = true pastey.workspace = true diff --git a/helpers/pagetop-minimal/README.md b/helpers/pagetop-minimal/README.md index b7a17bc0..162bce18 100644 --- a/helpers/pagetop-minimal/README.md +++ b/helpers/pagetop-minimal/README.md @@ -28,11 +28,6 @@ concatenación de cadenas y el uso rápido de colecciones clave-valor. Las macros para texto multilínea **`indoc!`**, **`formatdoc!`** y **`concatdoc!`** se reexportan del *crate* [indoc](https://crates.io/crates/indoc) de [David Tolnay](https://crates.io/users/dtolnay). -Las macros para la concatenación de cadenas **`join!`** y **`join_pair!`** se apoyan internamente en -el *crate* [concat-string](https://crates.io/crates/concat_string), desarrollado por -[FaultyRAM](https://crates.io/users/FaultyRAM), para evitar el formato de cadenas cuando la -eficiencia pueda ser relevante. - La macro para generar identificadores dinámicos **`paste!`** se reexporta del *crate* [pastey](https://crates.io/crates/pastey), una implementación avanzada y soportada del popular `paste!` de [David Tolnay](https://crates.io/users/dtolnay). diff --git a/helpers/pagetop-minimal/src/lib.rs b/helpers/pagetop-minimal/src/lib.rs index eab70c84..14bd4211 100644 --- a/helpers/pagetop-minimal/src/lib.rs +++ b/helpers/pagetop-minimal/src/lib.rs @@ -29,11 +29,6 @@ concatenación de cadenas y el uso rápido de colecciones clave-valor. Las macros para texto multilínea **`indoc!`**, **`formatdoc!`** y **`concatdoc!`** se reexportan del *crate* [indoc](https://crates.io/crates/indoc) de [David Tolnay](https://crates.io/users/dtolnay). -Las macros para la concatenación de cadenas **`join!`** y **`join_pair!`** se apoyan internamente en -el *crate* [concat-string](https://crates.io/crates/concat_string), desarrollado por -[FaultyRAM](https://crates.io/users/FaultyRAM), para evitar el formato de cadenas cuando la -eficiencia pueda ser relevante. - La macro para generar identificadores dinámicos **`paste!`** se reexporta del *crate* [pastey](https://crates.io/crates/pastey), una implementación avanzada y soportada del popular `paste!` de [David Tolnay](https://crates.io/users/dtolnay). @@ -43,9 +38,6 @@ La macro para generar identificadores dinámicos **`paste!`** se reexporta del * html_favicon_url = "https://git.cillero.es/manuelcillero/pagetop/raw/branch/main/assets/favicon.ico" )] -#[doc(hidden)] -pub use concat_string::concat_string; - pub use indoc::{concatdoc, formatdoc, indoc}; /// Permite *pegar* tokens y generar identificadores a partir de otros. @@ -60,11 +52,17 @@ pub use pastey::paste; // La documentación anterior se copia en `pagetop::util::paste!` porque el *crate* original no la // define y `pagetop` no la hereda automáticamente. -/// Concatena eficientemente varios fragmentos en un [`String`]. +/// Concatena varios fragmentos en un [`String`] reservando una sola vez la memoria del resultado. /// -/// Esta macro exporta [`concat_string!`](https://docs.rs/concat-string). Acepta cualquier número de -/// fragmentos que implementen [`AsRef`] y construye un [`String`] con el tamaño óptimo, de -/// forma eficiente y evitando el uso de cadenas de formato que penalicen el rendimiento. +/// Acepta uno o más fragmentos que implementen [`AsRef`] (literales, `&str`, [`String`], +/// `Cow`...) y construye el resultado con la capacidad exacta, sin pasar por el formateo. Es +/// la forma preferida de componer texto a partir de fragmentos, y suele ser más rápida que +/// `format!`. +/// +/// Cada expresión se evalúa **una sola vez**, por lo que admite llamadas como `n.to_string()` o +/// cierres con efectos secundarios. Los valores que no son cadenas (números, tipos `Display`) se +/// convierten antes con `to_string()`. Si hace falta formato real (`{:02}`, `{:?}`, `{:.2}`...), +/// usar `format!`. /// /// # Ejemplo /// @@ -81,11 +79,26 @@ pub use pastey::paste; /// // Un único fragmento devuelve el mismo valor. /// let single_result = join!("Hello"); /// assert_eq!(single_result, "Hello".to_string()); +/// +/// // Los fragmentos pueden ser de tipos distintos; los números se convierten antes. +/// let name = String::from("item"); +/// let result_mixed = join!(&name, "-", 7.to_string()); +/// assert_eq!(result_mixed, "item-7".to_string()); +/// +/// // Cada expresión se evalúa una sola vez. +/// let mut calls = 0; +/// let mut next = || { +/// calls += 1; +/// calls.to_string() +/// }; +/// let result_once = join!("n", next()); +/// assert_eq!(result_once, "n1".to_string()); +/// assert_eq!(calls, 1); /// ``` #[macro_export] macro_rules! join { - ($($arg:expr),+) => { - $crate::concat_string!($($arg),+) + ($($arg:expr),+ $(,)?) => { + [$(::core::convert::AsRef::::as_ref(&$arg)),+].concat() }; } @@ -126,15 +139,15 @@ macro_rules! join_pair { let second_val = $second; let separator_val = $separator; - let first = AsRef::::as_ref(&first_val); - let second = AsRef::::as_ref(&second_val); + let first = ::core::convert::AsRef::::as_ref(&first_val); + let second = ::core::convert::AsRef::::as_ref(&second_val); let separator = if first.is_empty() || second.is_empty() { "" } else { - AsRef::::as_ref(&separator_val) + ::core::convert::AsRef::::as_ref(&separator_val) }; - $crate::concat_string!(first, separator, second) + [first, separator, second].concat() }}; } From 404739a3fde6927dda5bbcbb148de6ee7f5c2d1e Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sun, 20 Sep 2026 18:33:37 +0200 Subject: [PATCH 3/4] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Importa=20PageTop=20us?= =?UTF-8?q?ando=20el=20`prelude`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 1 - .../src/component/admin_frame.rs | 1 - .../pagetop-admin/src/entity/setting.rs | 5 +- extensions/pagetop-htmx/src/hx.rs | 6 +- extensions/pagetop-menu/Cargo.toml | 1 - .../src/component/menu_breadcrumb.rs | 1 - extensions/pagetop-menu/src/entity/menu.rs | 3 +- .../pagetop-menu/src/entity/menu_item.rs | 3 +- extensions/pagetop-menu/src/repo.rs | 4 +- extensions/pagetop-menu/src/tree.rs | 1 - extensions/pagetop-seaorm/src/db.rs | 4 +- .../pagetop-seaorm/src/migration/migrator.rs | 55 ++++++++++--------- .../src/migration/seaql_migrations.rs | 4 +- extensions/pagetop-user/src/account.rs | 8 +-- extensions/pagetop-user/src/auth.rs | 3 +- .../src/component/admin/role_table.rs | 13 +++-- .../src/component/admin/user_table.rs | 13 +++-- extensions/pagetop-user/src/demo.rs | 3 +- extensions/pagetop-user/src/entity/role.rs | 3 +- .../src/entity/role_permission.rs | 3 +- extensions/pagetop-user/src/entity/session.rs | 3 +- extensions/pagetop-user/src/entity/user.rs | 3 +- .../pagetop-user/src/entity/user_token.rs | 3 +- .../pagetop-user/src/handlers/account.rs | 15 +++-- .../pagetop-user/src/handlers/admin/roles.rs | 11 ++-- .../pagetop-user/src/handlers/admin/users.rs | 15 +++-- extensions/pagetop-user/src/handlers/auth.rs | 1 - extensions/pagetop-user/src/middleware.rs | 9 +-- .../pagetop-user/src/service/role_admin.rs | 3 +- .../pagetop-user/src/service/user_admin.rs | 4 +- extensions/pagetop-user/src/token.rs | 6 +- 31 files changed, 96 insertions(+), 112 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dad698e5..6a7d89f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1875,7 +1875,6 @@ dependencies = [ name = "pagetop-menu" version = "0.1.0" dependencies = [ - "chrono", "pagetop", "pagetop-seaorm", "serde", diff --git a/extensions/pagetop-admin/src/component/admin_frame.rs b/extensions/pagetop-admin/src/component/admin_frame.rs index 380beb47..0be5c5d8 100644 --- a/extensions/pagetop-admin/src/component/admin_frame.rs +++ b/extensions/pagetop-admin/src/component/admin_frame.rs @@ -1,4 +1,3 @@ -use pagetop::base::component::breadcrumb; use pagetop::prelude::*; use crate::LOCALES_ADMIN; diff --git a/extensions/pagetop-admin/src/entity/setting.rs b/extensions/pagetop-admin/src/entity/setting.rs index 37b0329a..95b6c4c6 100644 --- a/extensions/pagetop-admin/src/entity/setting.rs +++ b/extensions/pagetop-admin/src/entity/setting.rs @@ -1,7 +1,6 @@ +use pagetop::prelude::*; use pagetop_seaorm::db::*; -use pagetop::datetime::NaiveDateTime; - /// Entidad SeaORM para la tabla `settings`. #[derive(Clone, Debug, PartialEq, DeriveEntityModel)] #[sea_orm(table_name = "settings")] @@ -14,7 +13,7 @@ pub struct Model { pub updated_by: Option, } -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)] pub enum Relation {} impl ActiveModelBehavior for ActiveModel {} diff --git a/extensions/pagetop-htmx/src/hx.rs b/extensions/pagetop-htmx/src/hx.rs index 9db67530..824fa457 100644 --- a/extensions/pagetop-htmx/src/hx.rs +++ b/extensions/pagetop-htmx/src/hx.rs @@ -7,7 +7,7 @@ //! # Atributos estáticos en `html!` //! //! Para valores conocidos en tiempo de compilación, los atributos `hx-*` pueden escribirse -//! directamente en la macro `html!` sin necesidad de [`Props`](pagetop::html::Props): +//! directamente en la macro `html!` sin necesidad de [`Props`]: //! //! ```rust,no_run //! use pagetop::prelude::*; @@ -17,10 +17,10 @@ //! }; //! ``` //! -//! # Atributos dinámicos con [`Props`](pagetop::html::Props) +//! # Atributos dinámicos con [`Props`] //! //! Cuando los valores se construyen en tiempo de ejecución o se inyectan desde una extensión, -//! puedes usar [`Props`](pagetop::html::Props) combinado con las constantes de este módulo: +//! puedes usar [`Props`] combinado con las constantes de este módulo: //! //! ```rust,no_run //! # use pagetop::prelude::*; diff --git a/extensions/pagetop-menu/Cargo.toml b/extensions/pagetop-menu/Cargo.toml index bd8d676d..d58e29ba 100644 --- a/extensions/pagetop-menu/Cargo.toml +++ b/extensions/pagetop-menu/Cargo.toml @@ -14,6 +14,5 @@ authors.workspace = true [dependencies] pagetop.workspace = true pagetop-seaorm.workspace = true -chrono.workspace = true serde.workspace = true thiserror.workspace = true diff --git a/extensions/pagetop-menu/src/component/menu_breadcrumb.rs b/extensions/pagetop-menu/src/component/menu_breadcrumb.rs index c049883c..3b6d9d61 100644 --- a/extensions/pagetop-menu/src/component/menu_breadcrumb.rs +++ b/extensions/pagetop-menu/src/component/menu_breadcrumb.rs @@ -1,4 +1,3 @@ -use pagetop::base::component::breadcrumb; use pagetop::prelude::*; use crate::tree::{MenuKey, MenuNode, TreeOptions, build_tree, try_resolve_menu_url}; diff --git a/extensions/pagetop-menu/src/entity/menu.rs b/extensions/pagetop-menu/src/entity/menu.rs index 3206e900..eaf2220a 100644 --- a/extensions/pagetop-menu/src/entity/menu.rs +++ b/extensions/pagetop-menu/src/entity/menu.rs @@ -1,7 +1,6 @@ +use pagetop::prelude::*; use pagetop_seaorm::db::*; -use chrono::NaiveDateTime; - #[derive(Clone, Debug, DeriveEntityModel, PartialEq)] #[sea_orm(table_name = "menus")] pub struct Model { diff --git a/extensions/pagetop-menu/src/entity/menu_item.rs b/extensions/pagetop-menu/src/entity/menu_item.rs index bcdc0238..4e22ecf4 100644 --- a/extensions/pagetop-menu/src/entity/menu_item.rs +++ b/extensions/pagetop-menu/src/entity/menu_item.rs @@ -1,7 +1,6 @@ +use pagetop::prelude::*; use pagetop_seaorm::db::*; -use chrono::NaiveDateTime; - #[derive(Clone, Debug, DeriveEntityModel, PartialEq)] #[sea_orm(table_name = "menu_items")] pub struct Model { diff --git a/extensions/pagetop-menu/src/repo.rs b/extensions/pagetop-menu/src/repo.rs index 4db2cea5..578b1798 100644 --- a/extensions/pagetop-menu/src/repo.rs +++ b/extensions/pagetop-menu/src/repo.rs @@ -1,8 +1,6 @@ //! Operaciones de base de datos para menús e ítems de menú. -use chrono::Utc; - -use pagetop::locale::Locale; +use pagetop::prelude::*; use pagetop_seaorm::db::{ ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, EntityTrait, QueryFilter, Set, dbconn, }; diff --git a/extensions/pagetop-menu/src/tree.rs b/extensions/pagetop-menu/src/tree.rs index 0df23a53..2d35d032 100644 --- a/extensions/pagetop-menu/src/tree.rs +++ b/extensions/pagetop-menu/src/tree.rs @@ -2,7 +2,6 @@ use std::collections::HashMap; -use pagetop::locale::{Locale, RequestLocale}; use pagetop::prelude::*; use crate::cache::FlatMenu; diff --git a/extensions/pagetop-seaorm/src/db.rs b/extensions/pagetop-seaorm/src/db.rs index 8d136672..57f6f8ee 100644 --- a/extensions/pagetop-seaorm/src/db.rs +++ b/extensions/pagetop-seaorm/src/db.rs @@ -49,7 +49,7 @@ //! pub name: String, //! } //! -//! #[derive(Clone, Copy, Debug, EnumIter, DeriveRelation)] +//! #[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)] //! pub enum Relation {} //! //! // `DeriveEntityModel` genera también `ActiveModel`, `Entity`, `Column` y `PrimaryKey`. @@ -468,7 +468,7 @@ impl Paginated { /// pub email: String, /// } /// -/// #[derive(Clone, Copy, Debug, EnumIter, DeriveRelation)] +/// #[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)] /// pub enum Relation {} /// /// impl ActiveModelBehavior for ActiveModel {} diff --git a/extensions/pagetop-seaorm/src/migration/migrator.rs b/extensions/pagetop-seaorm/src/migration/migrator.rs index b9167549..a0b8580f 100644 --- a/extensions/pagetop-seaorm/src/migration/migrator.rs +++ b/extensions/pagetop-seaorm/src/migration/migrator.rs @@ -4,7 +4,7 @@ use std::future::Future; use std::pin::Pin; use std::time::SystemTime; -use pagetop::trace::info; +use pagetop::prelude::*; use sea_orm::sea_query::{ self, Alias, Expr, ExprTrait, ForeignKey, IntoIden, Order, Query, SelectStatement, SimpleExpr, @@ -182,10 +182,10 @@ pub trait MigratorTrait: Send { { Self::install(db).await?; - info!("Checking migration status"); + trace::info!("Checking migration status"); for Migration { migration, status } in Self::get_migration_with_status(db).await? { - info!("Migration '{}'... {}", migration.name(), status); + trace::info!("Migration '{}'... {}", migration.name(), status); } Ok(()) @@ -284,34 +284,35 @@ where // Temporarily disable the foreign key check if db_backend == DbBackend::Sqlite { - info!("Disabling foreign key check"); + trace::info!("Disabling foreign key check"); db.execute(Statement::from_string( db_backend, "PRAGMA foreign_keys = OFF".to_owned(), )) .await?; - info!("Foreign key check disabled"); + trace::info!("Foreign key check disabled"); } // Drop all foreign keys if db_backend == DbBackend::MySql { - info!("Dropping all foreign keys"); + trace::info!("Dropping all foreign keys"); let stmt = query_mysql_foreign_keys(db); let rows = db.query_all(db_backend.build(&stmt)).await?; for row in rows.into_iter() { let constraint_name: String = row.try_get("", "CONSTRAINT_NAME")?; let table_name: String = row.try_get("", "TABLE_NAME")?; - info!( + trace::info!( "Dropping foreign key '{}' from table '{}'", - constraint_name, table_name + constraint_name, + table_name ); let mut stmt = ForeignKey::drop(); stmt.table(Alias::new(table_name.as_str())) .name(constraint_name.as_str()); db.execute(db_backend.build(&stmt)).await?; - info!("Foreign key '{}' has been dropped", constraint_name); + trace::info!("Foreign key '{}' has been dropped", constraint_name); } - info!("All foreign keys dropped"); + trace::info!("All foreign keys dropped"); } // Drop all tables @@ -319,39 +320,39 @@ where let rows = db.query_all(db_backend.build(&stmt)).await?; for row in rows.into_iter() { let table_name: String = row.try_get("", "table_name")?; - info!("Dropping table '{}'", table_name); + trace::info!("Dropping table '{}'", table_name); let mut stmt = Table::drop(); stmt.table(Alias::new(table_name.as_str())) .if_exists() .cascade(); db.execute(db_backend.build(&stmt)).await?; - info!("Table '{}' has been dropped", table_name); + trace::info!("Table '{}' has been dropped", table_name); } // Drop all types if db_backend == DbBackend::Postgres { - info!("Dropping all types"); + trace::info!("Dropping all types"); let stmt = query_pg_types(db); let rows = db.query_all(db_backend.build(&stmt)).await?; for row in rows { let type_name: String = row.try_get("", "typname")?; - info!("Dropping type '{}'", type_name); + trace::info!("Dropping type '{}'", type_name); let mut stmt = Type::drop(); stmt.name(Alias::new(&type_name)); db.execute(db_backend.build(&stmt)).await?; - info!("Type '{}' has been dropped", type_name); + trace::info!("Type '{}' has been dropped", type_name); } } // Restore the foreign key check if db_backend == DbBackend::Sqlite { - info!("Restoring foreign key check"); + trace::info!("Restoring foreign key check"); db.execute(Statement::from_string( db_backend, "PRAGMA foreign_keys = ON".to_owned(), )) .await?; - info!("Foreign key check restored"); + trace::info!("Foreign key check restored"); } // Reapply all migrations @@ -367,15 +368,15 @@ where M::install(db).await?; /* if let Some(steps) = steps { - info!("Applying {} pending migrations", steps); + trace::info!("Applying {} pending migrations", steps); } else { - info!("Applying all pending migrations"); + trace::info!("Applying all pending migrations"); } */ let migrations = M::get_pending_migrations(db).await?.into_iter(); /* if migrations.len() == 0 { - info!("No pending migrations"); + trace::info!("No pending migrations"); } */ for Migration { migration, .. } in migrations { @@ -385,9 +386,9 @@ where } *steps -= 1; } - info!("Applying migration '{}'", migration.name()); + trace::info!("Applying migration '{}'", migration.name()); migration.up(manager).await?; - info!("Migration '{}' has been applied", migration.name()); + trace::info!("Migration '{}' has been applied", migration.name()); let now = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .expect("SystemTime before UNIX EPOCH!"); @@ -412,14 +413,14 @@ where M::install(db).await?; if let Some(steps) = steps { - info!("Rolling back {} applied migrations", steps); + trace::info!("Rolling back {} applied migrations", steps); } else { - info!("Rolling back all applied migrations"); + trace::info!("Rolling back all applied migrations"); } let migrations = M::get_applied_migrations(db).await?.into_iter().rev(); if migrations.len() == 0 { - info!("No applied migrations"); + trace::info!("No applied migrations"); } for Migration { migration, .. } in migrations { if let Some(steps) = steps.as_mut() { @@ -428,9 +429,9 @@ where } *steps -= 1; } - info!("Rolling back migration '{}'", migration.name()); + trace::info!("Rolling back migration '{}'", migration.name()); migration.down(manager).await?; - info!("Migration '{}' has been rollbacked", migration.name()); + trace::info!("Migration '{}' has been rollbacked", migration.name()); seaql_migrations::Entity::delete_many() .filter(Expr::col(seaql_migrations::Column::Version).eq(migration.name())) .table_name(M::migration_table_name()) diff --git a/extensions/pagetop-seaorm/src/migration/seaql_migrations.rs b/extensions/pagetop-seaorm/src/migration/seaql_migrations.rs index 51da9300..57c7832c 100644 --- a/extensions/pagetop-seaorm/src/migration/seaql_migrations.rs +++ b/extensions/pagetop-seaorm/src/migration/seaql_migrations.rs @@ -1,6 +1,6 @@ use sea_orm::entity::prelude::*; -#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[derive(Clone, Debug, DeriveEntityModel, Eq, PartialEq)] // One should override the name of migration table via `MigratorTrait::migration_table_name` method #[sea_orm(table_name = "seaql_migrations")] pub struct Model { @@ -9,7 +9,7 @@ pub struct Model { pub applied_at: i64, } -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)] pub enum Relation {} impl ActiveModelBehavior for ActiveModel {} diff --git a/extensions/pagetop-user/src/account.rs b/extensions/pagetop-user/src/account.rs index 106c7977..54342077 100644 --- a/extensions/pagetop-user/src/account.rs +++ b/extensions/pagetop-user/src/account.rs @@ -2,7 +2,7 @@ use std::collections::HashSet; -use pagetop::auth::PermissionRef; +use pagetop::prelude::*; // **< UserStatus >********************************************************************************* @@ -56,9 +56,9 @@ impl PermissionSet { /// Datos ricos del usuario autenticado inyectados por el middleware de sesión. /// -/// Se almacena en las extensiones de la petición HTTP durante la fase de middleware y se -/// accede desde los handlers o desde handlers de [`CheckPermission`](pagetop::auth::CheckPermission) -/// mediante [`HttpRequest::extension::()`](pagetop::web::HttpRequest::extension). +/// Se almacena en las extensiones de la petición HTTP durante la fase de middleware y se accede +/// desde los handlers o desde handlers de [`CheckPermission`] mediante +/// [`HttpRequest::extension::()`](pagetop::web::HttpRequest::extension). #[derive(Clone, Debug)] pub struct Account { pub id: i32, diff --git a/extensions/pagetop-user/src/auth.rs b/extensions/pagetop-user/src/auth.rs index 91801ab9..ece3800f 100644 --- a/extensions/pagetop-user/src/auth.rs +++ b/extensions/pagetop-user/src/auth.rs @@ -1,7 +1,6 @@ //! Lógica de autenticación: login, logout, registro, semilla inicial. -use pagetop::datetime::{Duration, NaiveDateTime, Utc}; - +use pagetop::prelude::*; use pagetop_seaorm::db::{ ActiveModelTrait, ActiveValue, ColumnTrait, Condition, EntityTrait, PaginatorTrait, QueryFilter, Set, dbconn, diff --git a/extensions/pagetop-user/src/component/admin/role_table.rs b/extensions/pagetop-user/src/component/admin/role_table.rs index cc298c67..231273d8 100644 --- a/extensions/pagetop-user/src/component/admin/role_table.rs +++ b/extensions/pagetop-user/src/component/admin/role_table.rs @@ -1,8 +1,6 @@ //! Tabla de roles: cabeceras ordenables y filas con acciones. use pagetop::prelude::*; - -use pagetop::base::component::table::{Column, Row}; use pagetop_htmx::hx; use pagetop_htmx::hx_table::sort_link; @@ -93,7 +91,7 @@ impl Component for RoleTable { }; table.alter_row( - Row::new() + table::Row::new() .with_cell(role.machine_name.as_str()) .with_cell(label_cell(role, &waypoint)) .with_cell(Html::with(move |_cx| { @@ -188,7 +186,12 @@ impl RoleTable { // Construye la cabecera ordenable de una columna: el enlace ya funciona sin HTMX (navega con // una petición normal); `pagetop_htmx::hx_table::sort_link()` añade aparte los atributos `hx-*` // para que la tabla se actualice sin recargar la página cuando la extensión esté disponible. - fn sort_column(&self, cx: &Context, field: RoleSortField, label_key: &'static str) -> Column { + fn sort_column( + &self, + cx: &Context, + field: RoleSortField, + label_key: &'static str, + ) -> table::Column { let is_active = *self.sort() == field; let active = is_active.then_some(*self.dir()); let next_dir = SortDir::next_for(active); @@ -197,7 +200,7 @@ impl RoleTable { .alter_param("sort", field.as_str()) .alter_param("dir", next_dir); - Column::new(Lc::t(label_key, &LOCALES_USER)).with_sort(sort_link( + table::Column::new(Lc::t(label_key, &LOCALES_USER)).with_sort(sort_link( route, "#role-table-wrapper", active, diff --git a/extensions/pagetop-user/src/component/admin/user_table.rs b/extensions/pagetop-user/src/component/admin/user_table.rs index 56ab070d..603dcf98 100644 --- a/extensions/pagetop-user/src/component/admin/user_table.rs +++ b/extensions/pagetop-user/src/component/admin/user_table.rs @@ -1,8 +1,6 @@ //! Tabla de usuarios: cabeceras ordenables, filas y paginación embebida. use pagetop::prelude::*; - -use pagetop::base::component::table::{Column, Row}; use pagetop_htmx::hx; use pagetop_htmx::hx_table::sort_link; @@ -75,7 +73,7 @@ impl Component for UserTable { let status = user.status; table.alter_row( - Row::new() + table::Row::new() .with_cell(username_cell(user, &waypoint)) .with_cell(user.email.as_str()) .with_cell(user.display_name.as_deref().unwrap_or("-")) @@ -163,7 +161,12 @@ impl UserTable { // Construye la cabecera ordenable de una columna: el enlace ya funciona sin HTMX (navega con // una petición normal); `pagetop_htmx::hx_table::sort_link()` añade aparte los atributos `hx-*` // para que la tabla se actualice sin recargar la página cuando la extensión esté disponible. - fn sort_column(&self, cx: &Context, field: UserSortField, label_key: &'static str) -> Column { + fn sort_column( + &self, + cx: &Context, + field: UserSortField, + label_key: &'static str, + ) -> table::Column { let is_active = *self.sort() == field; let active = is_active.then_some(*self.dir()); let next_dir = SortDir::next_for(active); @@ -176,7 +179,7 @@ impl UserTable { .alter_param("sort", field.as_str()) .alter_param("dir", next_dir); - Column::new(Lc::t(label_key, &LOCALES_USER)).with_sort(sort_link( + table::Column::new(Lc::t(label_key, &LOCALES_USER)).with_sort(sort_link( route, "#user-table-wrapper", active, diff --git a/extensions/pagetop-user/src/demo.rs b/extensions/pagetop-user/src/demo.rs index d8e65d0e..dd9935e7 100644 --- a/extensions/pagetop-user/src/demo.rs +++ b/extensions/pagetop-user/src/demo.rs @@ -4,8 +4,7 @@ //! `Extension::initialize()`, después de `auth::seed_initial_data()`. Si el rol //! `demo_role_01` ya existe, la siembra se omite para no duplicar datos en reinicios. -use pagetop::datetime::Utc; - +use pagetop::prelude::*; use pagetop_seaorm::db::{ActiveValue, ColumnTrait, EntityTrait, QueryFilter, Set, dbconn}; use crate::account::UserStatus; diff --git a/extensions/pagetop-user/src/entity/role.rs b/extensions/pagetop-user/src/entity/role.rs index 6432a108..fed14e52 100644 --- a/extensions/pagetop-user/src/entity/role.rs +++ b/extensions/pagetop-user/src/entity/role.rs @@ -1,7 +1,6 @@ +use pagetop::prelude::*; use pagetop_seaorm::db::*; -use pagetop::datetime::NaiveDateTime; - #[derive(Clone, Debug, DeriveEntityModel, PartialEq)] #[sea_orm(table_name = "roles")] pub struct Model { diff --git a/extensions/pagetop-user/src/entity/role_permission.rs b/extensions/pagetop-user/src/entity/role_permission.rs index 67427dd9..6ed42368 100644 --- a/extensions/pagetop-user/src/entity/role_permission.rs +++ b/extensions/pagetop-user/src/entity/role_permission.rs @@ -1,7 +1,6 @@ +use pagetop::prelude::*; use pagetop_seaorm::db::*; -use pagetop::datetime::NaiveDateTime; - #[derive(Clone, Debug, DeriveEntityModel, PartialEq)] #[sea_orm(table_name = "role_permissions")] pub struct Model { diff --git a/extensions/pagetop-user/src/entity/session.rs b/extensions/pagetop-user/src/entity/session.rs index 32ad7f08..81614174 100644 --- a/extensions/pagetop-user/src/entity/session.rs +++ b/extensions/pagetop-user/src/entity/session.rs @@ -1,7 +1,6 @@ +use pagetop::prelude::*; use pagetop_seaorm::db::*; -use pagetop::datetime::NaiveDateTime; - #[derive(Clone, Debug, DeriveEntityModel, PartialEq)] #[sea_orm(table_name = "sessions")] pub struct Model { diff --git a/extensions/pagetop-user/src/entity/user.rs b/extensions/pagetop-user/src/entity/user.rs index 5656a490..d2023057 100644 --- a/extensions/pagetop-user/src/entity/user.rs +++ b/extensions/pagetop-user/src/entity/user.rs @@ -1,7 +1,6 @@ +use pagetop::prelude::*; use pagetop_seaorm::db::*; -use pagetop::datetime::NaiveDateTime; - #[derive(Clone, Debug, DeriveEntityModel, PartialEq)] #[sea_orm(table_name = "users")] pub struct Model { diff --git a/extensions/pagetop-user/src/entity/user_token.rs b/extensions/pagetop-user/src/entity/user_token.rs index d497d979..de25dc6a 100644 --- a/extensions/pagetop-user/src/entity/user_token.rs +++ b/extensions/pagetop-user/src/entity/user_token.rs @@ -1,7 +1,6 @@ +use pagetop::prelude::*; use pagetop_seaorm::db::*; -use pagetop::datetime::NaiveDateTime; - #[derive(Clone, Debug, DeriveEntityModel, PartialEq)] #[sea_orm(table_name = "user_tokens")] pub struct Model { diff --git a/extensions/pagetop-user/src/handlers/account.rs b/extensions/pagetop-user/src/handlers/account.rs index f38964e9..dac6f536 100644 --- a/extensions/pagetop-user/src/handlers/account.rs +++ b/extensions/pagetop-user/src/handlers/account.rs @@ -1,6 +1,5 @@ //! Handler HTTP para el perfil del propio usuario autenticado. -use pagetop::base::component::table::Row; use pagetop::prelude::*; use crate::account::UserStatus; @@ -47,32 +46,32 @@ async fn profile_details(user: &user::Model, status: UserStatus, cx: &mut Contex let mut table = Table::new() .with_prop(PropsOp::add_classes("user-admin-table")) .with_row( - Row::new() + table::Row::new() .with_cell(Lc::t("field-username-admin", &LOCALES_USER)) .with_cell(user.username.as_str()), ) .with_row( - Row::new() + table::Row::new() .with_cell(Lc::t("field-email", &LOCALES_USER)) .with_cell(user.email.as_str()), ) .with_row( - Row::new() + table::Row::new() .with_cell(Lc::t("field-display-name", &LOCALES_USER)) .with_cell(user.display_name.as_deref().unwrap_or("-")), ) .with_row( - Row::new() + table::Row::new() .with_cell(Lc::t("field-language", &LOCALES_USER)) .with_cell(user.language.as_deref().unwrap_or("-")), ) .with_row( - Row::new() + table::Row::new() .with_cell(Lc::t("field-timezone", &LOCALES_USER)) .with_cell(user.timezone.as_deref().unwrap_or("-")), ) .with_row( - Row::new() + table::Row::new() .with_cell(Lc::t("col-status", &LOCALES_USER)) .with_cell(Lc::t(status_key(status), &LOCALES_USER)), ); @@ -83,7 +82,7 @@ async fn profile_details(user: &user::Model, status: UserStatus, cx: &mut Contex .render(cx) .await; table = table.with_row( - Row::new() + table::Row::new() .with_cell("") .with_cell(Html::with(move |_| badge.clone())), ); diff --git a/extensions/pagetop-user/src/handlers/admin/roles.rs b/extensions/pagetop-user/src/handlers/admin/roles.rs index 4c63a698..290712ff 100644 --- a/extensions/pagetop-user/src/handlers/admin/roles.rs +++ b/extensions/pagetop-user/src/handlers/admin/roles.rs @@ -2,7 +2,6 @@ use serde::Deserialize; -use pagetop::base::component::table::Row; use pagetop::prelude::*; use pagetop_htmx::prelude::*; @@ -313,22 +312,22 @@ async fn role_view_details(role: &role::Model, cx: &mut Context) -> Block { let mut table = Table::new() .with_prop(PropsOp::add_classes("user-admin-table")) .with_row( - Row::new() + table::Row::new() .with_cell(Lc::t("field-machine-name", &LOCALES_USER)) .with_cell(role.machine_name.as_str()), ) .with_row( - Row::new() + table::Row::new() .with_cell(Lc::t("field-label", &LOCALES_USER)) .with_cell(role.label.as_str()), ) .with_row( - Row::new() + table::Row::new() .with_cell(Lc::t("field-description", &LOCALES_USER)) .with_cell(role.description.as_deref().unwrap_or("-")), ) .with_row( - Row::new() + table::Row::new() .with_cell(Lc::t("field-weight", &LOCALES_USER)) .with_cell(role.weight.to_string()), ); @@ -339,7 +338,7 @@ async fn role_view_details(role: &role::Model, cx: &mut Context) -> Block { .render(cx) .await; table = table.with_row( - Row::new() + table::Row::new() .with_cell("") .with_cell(Html::with(move |_| badge.clone())), ); diff --git a/extensions/pagetop-user/src/handlers/admin/users.rs b/extensions/pagetop-user/src/handlers/admin/users.rs index 40900feb..f3b833df 100644 --- a/extensions/pagetop-user/src/handlers/admin/users.rs +++ b/extensions/pagetop-user/src/handlers/admin/users.rs @@ -2,7 +2,6 @@ use serde::Deserialize; -use pagetop::base::component::table::Row; use pagetop::prelude::*; use pagetop_htmx::prelude::*; @@ -517,32 +516,32 @@ async fn user_view_details(user: &user::Model, status: UserStatus, cx: &mut Cont let mut table = Table::new() .with_prop(PropsOp::add_classes("user-admin-table")) .with_row( - Row::new() + table::Row::new() .with_cell(Lc::t("field-username-admin", &LOCALES_USER)) .with_cell(user.username.as_str()), ) .with_row( - Row::new() + table::Row::new() .with_cell(Lc::t("field-email", &LOCALES_USER)) .with_cell(user.email.as_str()), ) .with_row( - Row::new() + table::Row::new() .with_cell(Lc::t("field-display-name", &LOCALES_USER)) .with_cell(user.display_name.as_deref().unwrap_or("-")), ) .with_row( - Row::new() + table::Row::new() .with_cell(Lc::t("field-language", &LOCALES_USER)) .with_cell(user.language.as_deref().unwrap_or("-")), ) .with_row( - Row::new() + table::Row::new() .with_cell(Lc::t("field-timezone", &LOCALES_USER)) .with_cell(user.timezone.as_deref().unwrap_or("-")), ) .with_row( - Row::new() + table::Row::new() .with_cell(Lc::t("col-status", &LOCALES_USER)) .with_cell(Lc::t(status_key(status), &LOCALES_USER)), ); @@ -553,7 +552,7 @@ async fn user_view_details(user: &user::Model, status: UserStatus, cx: &mut Cont .render(cx) .await; table = table.with_row( - Row::new() + table::Row::new() .with_cell("") .with_cell(Html::with(move |_| badge.clone())), ); diff --git a/extensions/pagetop-user/src/handlers/auth.rs b/extensions/pagetop-user/src/handlers/auth.rs index 7186eab4..351880cf 100644 --- a/extensions/pagetop-user/src/handlers/auth.rs +++ b/extensions/pagetop-user/src/handlers/auth.rs @@ -2,7 +2,6 @@ use serde::Deserialize; -use pagetop::auth::CurrentUser; use pagetop::prelude::*; use crate::auth; diff --git a/extensions/pagetop-user/src/middleware.rs b/extensions/pagetop-user/src/middleware.rs index 1ebd09c8..d72dff07 100644 --- a/extensions/pagetop-user/src/middleware.rs +++ b/extensions/pagetop-user/src/middleware.rs @@ -5,9 +5,7 @@ //! si el usuario está autenticado, el [`crate::account::Account`] con sus datos ricos en las //! extensiones de la petición HTTP. -use pagetop::auth::PermissionRef; -use pagetop::web::middleware::Next; -use pagetop::web::{Request, Response}; +use pagetop::prelude::*; use crate::account::Account; use crate::session; @@ -17,7 +15,10 @@ use crate::session; /// /// - Siempre inserta [`pagetop::auth::CurrentUser`] (anónimo o autenticado). /// - Si hay sesión activa, inserta también el [`Account`] con roles y permisos. -pub(crate) async fn session_middleware(mut req: Request, next: Next) -> Response { +pub(crate) async fn session_middleware( + mut req: web::Request, + next: web::middleware::Next, +) -> Response { let (current_user, maybe_account) = session::resolve_session(req.headers()).await; req.extensions_mut().insert(current_user); diff --git a/extensions/pagetop-user/src/service/role_admin.rs b/extensions/pagetop-user/src/service/role_admin.rs index 5b96bf9a..f7bd3223 100644 --- a/extensions/pagetop-user/src/service/role_admin.rs +++ b/extensions/pagetop-user/src/service/role_admin.rs @@ -2,8 +2,7 @@ use std::collections::HashMap; -use pagetop::datetime::Utc; -use pagetop::html::SortDir; +use pagetop::prelude::*; use pagetop_seaorm::db::{ ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, Order, Paginated, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set, TransactionTrait, dbconn, flatten_txn_err, paginate, diff --git a/extensions/pagetop-user/src/service/user_admin.rs b/extensions/pagetop-user/src/service/user_admin.rs index 55ca95ab..58aa35ed 100644 --- a/extensions/pagetop-user/src/service/user_admin.rs +++ b/extensions/pagetop-user/src/service/user_admin.rs @@ -2,9 +2,7 @@ use std::collections::HashMap; -use pagetop::datetime::Utc; -use pagetop::html::SortDir; -use pagetop::util; +use pagetop::prelude::*; use pagetop_seaorm::db::{ ActiveModelTrait, ActiveValue, ColumnTrait, Condition, EntityTrait, Order, Paginated, PaginatorTrait, QueryFilter, QueryOrder, Set, TransactionTrait, dbconn, flatten_txn_err, diff --git a/extensions/pagetop-user/src/token.rs b/extensions/pagetop-user/src/token.rs index 4da794a5..611bca03 100644 --- a/extensions/pagetop-user/src/token.rs +++ b/extensions/pagetop-user/src/token.rs @@ -1,9 +1,7 @@ //! Generación y verificación de tokens de un solo uso (reset de contraseña, verificación de //! email...). -use pagetop::datetime::{Duration, Utc}; -use sha2::{Digest, Sha256}; - +use pagetop::prelude::*; use pagetop_seaorm::db::{ ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter, Set, dbconn, }; @@ -11,6 +9,8 @@ use pagetop_seaorm::db::{ use crate::entity::user_token; use crate::error::AuthError; +use sha2::{Digest, Sha256}; + // **< Generación de tokens >*********************************************************************** /// Genera un token URL-safe de 43 caracteres (32 bytes -> base64url sin padding). From 16eec316c4b347e0590753c92ee8af9d94857f2a Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sun, 20 Sep 2026 23:53:18 +0200 Subject: [PATCH 4/4] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(base):=20Reorganiza?= =?UTF-8?q?=20el=20componente=20Intro?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/css/intro.css | 65 ++++-- examples/form-controls.rs | 4 +- examples/intro-colors.rs | 4 +- examples/intro-flex.rs | 4 +- examples/intro-grid.rs | 7 +- examples/intro-responsive.rs | 4 +- examples/intro-spacing.rs | 4 +- src/base/component.rs | 5 +- src/base/component/intro.rs | 295 +----------------------- src/base/component/intro/component.rs | 320 ++++++++++++++++++++++++++ src/base/component/intro/props.rs | 60 +++++ src/core/theme/definition.rs | 6 +- 12 files changed, 439 insertions(+), 339 deletions(-) create mode 100644 src/base/component/intro/component.rs create mode 100644 src/base/component/intro/props.rs diff --git a/assets/css/intro.css b/assets/css/intro.css index 5f07b378..b7679c3e 100644 --- a/assets/css/intro.css +++ b/assets/css/intro.css @@ -22,6 +22,10 @@ body { overflow-x: clip; } +/* + * Media queries: 768px and 992px match the default `Md` and `Lg` breakpoints of the core. + */ + .intro { position: relative; color: var(--intro-color); @@ -44,20 +48,6 @@ body { text-align: center; } -.intro a { - color: currentColor; - text-decoration: underline; - transition: font-size 0.2s, text-decoration-color 0.2s; -} -.intro a:focus-visible { - outline: var(--intro-focus-outline); - outline-offset: var(--intro-focus-outline-offset); -} -.intro a:hover, -.intro a:hover:visited { - text-decoration-color: var(--intro-color-link); -} - /* * Intro Header */ @@ -117,7 +107,7 @@ body { width: 280px; height: 280px; } -@media (min-width: 64rem) { +@media (width >= 992px) { .intro-header { background-image: var(--intro-bg-img); background-image: var(--intro-bg-img-set); @@ -166,12 +156,12 @@ body { .intro-content-body:after { bottom: -1rem; } -@media (width <= 48rem) { +@media (width < 768px) { .intro-content-body { margin-top: -9.8rem; } } -@media (width >= 64rem) { +@media (width >= 992px) { .intro-content { margin-top: 0; } @@ -208,6 +198,10 @@ body { min-height: 7.6875rem; outline: none; } +.intro-button-link:focus-visible { + outline: var(--intro-focus-outline); + outline-offset: var(--intro-focus-outline-offset); +} .intro-button-link::before { content: ''; position: absolute; @@ -310,7 +304,7 @@ body { .intro-button-link:hover span { animation-play-state: paused; } -@media (width <= 48rem) { +@media (width < 768px) { .intro-header { padding-bottom: 9rem;; } @@ -324,7 +318,7 @@ body { padding-top: .5rem; } } -@media (width > 48rem) { +@media (width >= 768px) { .intro-button { position: absolute; top: 0; @@ -361,12 +355,12 @@ body { font-size: 1.5rem; margin: 0 0 1.5rem; } -@media (width > 48rem) { +@media (width >= 768px) { .intro-button + .intro-text-body { padding-top: 7rem; } } -@media (width >= 64rem) { +@media (width >= 992px) { .intro-header { padding-bottom: 9rem;; } @@ -376,9 +370,18 @@ body { } .intro-text { box-shadow: var(--intro-shadow); - max-width: 60rem; + max-width: min(60rem, 100vw - 4rem); margin: 0 auto 6rem; } + .intro-text-narrow { + max-width: min(50rem, 100vw - 4rem); + } + .intro-text-wide { + max-width: min(67rem, 100vw - 4rem); + } + .intro-text-full { + max-width: min(74rem, 100vw - 4rem); + } .intro-text-body { padding-left: 4.5rem; padding-right: 4.5rem; @@ -479,6 +482,20 @@ body { .intro-footer-body a:visited { color: var(--intro-color-gray); } +/* The footer has its own dark surface, so its links don't inherit the theme's link colors. */ +.intro-footer-links a { + color: currentColor; + text-decoration: underline; + transition: font-size 0.2s, text-decoration-color 0.2s; +} +.intro-footer-links a:focus-visible { + outline: var(--intro-focus-outline); + outline-offset: var(--intro-focus-outline-offset); +} +.intro-footer-links a:hover, +.intro-footer-links a:hover:visited { + text-decoration-color: var(--intro-color-link); +} .intro-footer-logo, .intro-footer-links { display: flex; @@ -496,12 +513,12 @@ body { flex-wrap: wrap; margin-top: 2rem; } -@media (width <= 48rem) { +@media (width < 768px) { .intro-footer-logo { display: none; } } -@media (width <= 64rem) { +@media (width < 992px) { .intro-footer-body { padding: 0 1rem; } diff --git a/examples/form-controls.rs b/examples/form-controls.rs index e7311b21..e2c7dc43 100644 --- a/examples/form-controls.rs +++ b/examples/form-controls.rs @@ -19,11 +19,9 @@ impl Extension for FormControls { async fn form_controls(request: HttpRequest) -> Result { Page::new(request) .with_child( - Intro::default() - .with_opening(IntroOpening::Custom) + Intro::custom() .with_title(Lc::t("title", &LOC)) .with_slogan(Lc::t("slogan", &LOC)) - .with_button(None::<(Lc, Route)>) // Bloque 1: casillas, interruptores y botones de opción. .with_child( Block::new() diff --git a/examples/intro-colors.rs b/examples/intro-colors.rs index cccc303f..2cea5c84 100644 --- a/examples/intro-colors.rs +++ b/examples/intro-colors.rs @@ -14,11 +14,9 @@ impl Extension for IntroColors { async fn intro_colors(request: HttpRequest) -> Result { Page::new(request) .with_child( - Intro::default() - .with_opening(IntroOpening::Custom) + Intro::custom() .with_title(Lc::n("PageTop")) .with_slogan(Lc::t("colors_slogan", &LOC)) - .with_button(None::<(Lc, Route)>) .with_child( Block::new() .with_title(Lc::t("colors_block", &LOC).with_arg("n", "1")) diff --git a/examples/intro-flex.rs b/examples/intro-flex.rs index 4e07a0bb..4f810f61 100644 --- a/examples/intro-flex.rs +++ b/examples/intro-flex.rs @@ -20,11 +20,9 @@ async fn intro_flex(request: HttpRequest) -> Result { .with_assets(demo_box_styles()) .with_assets(demo_row_styles()) .with_child( - Intro::default() - .with_opening(IntroOpening::Custom) + Intro::custom() .with_title(Lc::n("PageTop")) .with_slogan(Lc::t("flex_slogan", &LOC)) - .with_button(None::<(Lc, Route)>) .with_child(direction_block()) .with_child(justify_block()) .with_child(align_block()) diff --git a/examples/intro-grid.rs b/examples/intro-grid.rs index c22c3912..71d7b6c6 100644 --- a/examples/intro-grid.rs +++ b/examples/intro-grid.rs @@ -20,11 +20,9 @@ async fn intro_grid(request: HttpRequest) -> Result { .with_assets(demo_box_styles()) .with_assets(demo_row_styles()) .with_child( - Intro::default() - .with_opening(IntroOpening::Custom) + Intro::custom() .with_title(Lc::n("PageTop")) .with_slogan(Lc::t("grid_slogan", &LOC)) - .with_button(None::<(Lc, Route)>) .with_child(columns_block()) .with_child(rows_gap_block()) .with_child(placement_block()) @@ -472,7 +470,8 @@ fn layout_block() -> Block { // **< HELPERS >************************************************************************************ // Aspecto fijo de las cajas de muestra: esquinas rectas y borde claro, a diferencia de las cajas -// redondeadas de `intro-flex.rs`, para distinguir a simple vista los ejemplos de Grid de los de Flex. +// redondeadas de `intro-flex.rs`, para distinguir a simple vista los ejemplos de Grid de los de +// Flex. fn demo_box_styles() -> AssetsOp { AssetsOp::add_responsive_styles( None, diff --git a/examples/intro-responsive.rs b/examples/intro-responsive.rs index 6306a678..3b06df66 100644 --- a/examples/intro-responsive.rs +++ b/examples/intro-responsive.rs @@ -21,11 +21,9 @@ async fn intro_responsive(request: HttpRequest) -> Result { .with_assets(demo_row_styles()) .with_assets(demo_code_styles()) .with_child( - Intro::default() - .with_opening(IntroOpening::Custom) + Intro::custom() .with_title(Lc::n("PageTop")) .with_slogan(Lc::t("responsive_slogan", &LOC)) - .with_button(None::<(Lc, Route)>) .with_child(Html::with(|cx| { html! { p class="intro-text-lead" { diff --git a/examples/intro-spacing.rs b/examples/intro-spacing.rs index eed4c38f..a3b0d51a 100644 --- a/examples/intro-spacing.rs +++ b/examples/intro-spacing.rs @@ -21,11 +21,9 @@ async fn intro_spacing(request: HttpRequest) -> Result { .with_assets(demo_row_styles()) .with_assets(demo_code_styles()) .with_child( - Intro::default() - .with_opening(IntroOpening::Custom) + Intro::custom() .with_title(Lc::n("PageTop")) .with_slogan(Lc::t("spacing_slogan", &LOC)) - .with_button(None::<(Lc, Route)>) .with_child(Html::with(|cx| { html! { p class="intro-text-lead" { diff --git a/src/base/component.rs b/src/base/component.rs index 807c57e2..1365c704 100644 --- a/src/base/component.rs +++ b/src/base/component.rs @@ -47,8 +47,9 @@ pub mod image; #[doc(inline)] pub use image::Image; -mod intro; -pub use intro::{Intro, IntroOpening}; +pub mod intro; +#[doc(inline)] +pub use intro::Intro; mod messages; pub use messages::Messages; diff --git a/src/base/component/intro.rs b/src/base/component/intro.rs index 00fa866b..e7403eae 100644 --- a/src/base/component/intro.rs +++ b/src/base/component/intro.rs @@ -1,292 +1,7 @@ -use crate::prelude::*; +//! Definiciones para crear introducciones ([`Intro`]). -/// Tipo de apertura que se mostrará en la introducción del componente [`Intro`]. -/// -/// Permite elegir entre una apertura con textos predefinidos sobre PageTop (como hace la página de -/// bienvenida [`Welcome`](crate::base::extension::Welcome)) o una introducción completamente -/// personalizada. -#[derive(AutoDefault, Copy, Clone, Debug, Eq, PartialEq)] -pub enum IntroOpening { - /// Modo por defecto. Muestra una introducción estándar de PageTop e incluye automáticamente - /// *badges* con información de la última versión liberada, fecha del último lanzamiento y - /// licencia de uso. - #[default] - PageTop, - /// Modo totalmente personalizado. No añade *badges* ni textos predefinidos. Usa la imagen de - /// PageTop pero el contenido lo define el propio desarrollador. - Custom, -} +mod props; +pub use props::{Kind, Width}; -/// Componente para divulgar PageTop (como hace [`Welcome`](crate::base::extension::Welcome)), o -/// mostrar presentaciones. -/// -/// Usa la imagen de PageTop para mostrar: -/// -/// - Una **figura decorativa** (que incluye la *mascota* de PageTop) antecediendo al contenido. -/// - Una vista destacada del **título** de la página con un **eslogan** de presentación. -/// - Un **botón opcional** de llamada a la acción con texto y enlace configurables. -/// - Un **área para la presentación de contenidos**, con *badges* informativos de PageTop (si se -/// opta por [`IntroOpening::PageTop`]) y bloques ([`Block`](crate::base::component::Block)) de -/// contenido libre. Los párrafos que tengan la clase `.intro-text-lead` se mostrarán con una -/// tipografía ampliada, ideal para presentaciones breves e impactantes. -/// -/// # Ejemplos -/// -/// **Intro mínima por defecto** -/// -/// ```rust,no_run -/// # use pagetop::prelude::*; -/// let intro = Intro::default(); -/// ``` -/// -/// **Título, eslogan y botón personalizados** -/// -/// ```rust,no_run -/// # use pagetop::prelude::*; -/// let intro = Intro::default() -/// .with_title(Lc::l("intro_custom_title")) -/// .with_slogan(Lc::l("intro_custom_slogan")) -/// .with_button(Some(( -/// Lc::l("intro_learn_more"), -/// "/learn-more".into() -/// ))); -/// ``` -/// -/// **Sin botón y en modo *Custom* (sin *badges* predefinidos)** -/// -/// ```rust,no_run -/// # use pagetop::prelude::*; -/// let intro = Intro::default() -/// .with_button(None::<(Lc, Route)>) -/// .with_opening(IntroOpening::Custom); -/// ``` -/// -/// **Añadir contenidos hijo** -/// -/// ```rust,no_run -/// # use pagetop::prelude::*; -/// let intro = Intro::default() -/// .with_child( -/// Block::new() -/// .with_title(Lc::l("intro_custom_block_title")) -/// .with_child(Html::with(move |cx| { -/// html! { -/// p class="intro-text-lead" { -/// (Lc::l("intro_custom_paragraph_1").using(cx)) -/// } -/// p { (Lc::l("intro_custom_paragraph_2").using(cx)) } -/// } -/// })), -/// ); -/// ``` -#[derive(Clone, Debug, Getters)] -pub struct Intro { - /// Devuelve el título de entrada. - title: Lc, - /// Devuelve el eslogan de la entrada. - slogan: Lc, - /// Devuelve el botón de llamada a la acción, si existe. - button: Option<(Lc, Route)>, - /// Devuelve el modo de apertura configurado. - opening: IntroOpening, - /// Devuelve la lista de componentes hijo de la intro. - children: Children, -} - -impl Default for Intro { - fn default() -> Self { - const BUTTON_LINK: &str = "https://pagetop.cillero.es"; - - Intro { - title: Lc::l("intro_default_title"), - slogan: Lc::l("intro_default_slogan").with_arg("app", &global::SETTINGS.app.name), - button: Some((Lc::l("intro_default_button"), BUTTON_LINK.into())), - opening: IntroOpening::default(), - children: Children::default(), - } - } -} - -#[async_trait] -impl Component for Intro { - fn new() -> Self { - Self::default() - } - - async fn prepare(&self, cx: &mut Context) -> Result { - cx.alter_assets(StyleSheet::from("/pagetop/css/intro.css").with_version(PAGETOP_VERSION)); - if *self.opening() == IntroOpening::PageTop { - cx.alter_assets(JavaScript::on_load_async("intro-js", |cx| - util::indoc!(r#" - try { - const resp = await fetch("https://crates.io/api/v1/crates/pagetop"); - const data = await resp.json(); - const date = new Date(data.versions[0].created_at); - const formatted = date.toLocaleDateString("LANGID", { year: "numeric", month: "2-digit", day: "2-digit" }); - document.getElementById("intro-release").src = `https://img.shields.io/badge/Release%20date-${encodeURIComponent(formatted)}-blue?label=LABEL&style=for-the-badge`; - } catch (e) { - console.error("Failed to fetch release date from crates.io:", e); - } finally { - document.getElementById("intro-badges").style.visibility = "visible"; - } - "#) - .replace("LANGID", cx.langid().to_string().as_str()) - .replace("LABEL", Lc::l("intro_release_label").using(cx).as_str()) - )); - } - - Ok(html! { - div class="intro" { - div class="intro-header" { - section class="intro-header-body" { - h1 class="intro-header-title" { - span { (self.title().using(cx)) } - (self.slogan().using(cx)) - } - } - aside class="intro-header-img" aria-hidden="true" { - div class="intro-header-mascot" { - (PageTopSvg::Color.markup()) - } - } - } - div class="intro-content" { - section class="intro-content-body" { - div class="intro-text" { - @if let Some((txt, lnk)) = self.button() { - div class="intro-button" { - a - class="intro-button-link" - href=(lnk.resolve(cx)) - target="_blank" - rel="noopener noreferrer" - { - span {} span {} span {} - div class="intro-button-text" { - (txt.using(cx)) - } - } - } - } - div class="intro-text-body" { - @if *self.opening() == IntroOpening::PageTop { - p class="intro-text-lead" { - (Lc::l("intro_text1").using(cx)) - } - div id="intro-badges" { - img - src="https://img.shields.io/crates/v/pagetop.svg?label=PageTop&style=for-the-badge" - alt=[Lc::l("intro_pagetop_label").lookup(cx)] {} (" ") - img - id="intro-release" - alt=[Lc::l("intro_release_label").lookup(cx)] {} (" ") - img - src=(format!( - "https://img.shields.io/badge/license-MIT%2FApache-blue.svg?label={}&style=for-the-badge", - Lc::l("intro_license_label").lookup(cx).unwrap_or_default() - )) - alt=[Lc::l("intro_license_label").lookup(cx)] {} - } - p class="intro-text-lead" { - (Lc::l("intro_text2").using(cx)) - } - } - (self.children().render(cx).await) - } - } - } - } - div class="intro-footer" { - section class="intro-footer-body" { - div class="intro-footer-logo" { - (PageTopSvg::LineLight.markup()) - } - div class="intro-footer-links" { - a href="https://crates.io/crates/pagetop" target="_blank" rel="noopener noreferrer" { ("Crates.io") } - a href="https://docs.rs/pagetop" target="_blank" rel="noopener noreferrer" { ("Docs.rs") } - a href="https://git.cillero.es/manuelcillero/pagetop" target="_blank" rel="noopener noreferrer" { (Lc::l("intro_code").using(cx)) } - em { (Lc::l("intro_have_fun").using(cx)) } - } - } - } - } - }) - } -} - -#[builder_impl] -impl Intro { - // **< Intro BUILDER >************************************************************************** - - /// Establece el título de entrada. - /// - /// # Ejemplo - /// - /// ```rust,no_run - /// # use pagetop::prelude::*; - /// let intro = Intro::default().with_title(Lc::n("Intro title")); - /// ``` - pub fn with_title(mut self, title: Lc) -> Self { - self.title = title; - self - } - - /// Establece el eslogan de entrada (línea secundaria del título). - /// - /// # Ejemplo - /// - /// ```rust,no_run - /// # use pagetop::prelude::*; - /// let intro = Intro::default().with_slogan(Lc::n("A short slogan")); - /// ``` - pub fn with_slogan(mut self, slogan: Lc) -> Self { - self.slogan = slogan; - self - } - - /// Configura el botón opcional de llamada a la acción. - /// - /// - Usa `Some((texto, ruta))` para mostrarlo, donde [`Route`] resuelve la ruta o URL final al - /// pulsar el botón según el contexto de renderizado. - /// - Usa `None` para ocultarlo. - /// - /// # Ejemplo - /// - /// ```rust,no_run - /// # use pagetop::prelude::*; - /// // Define un botón con texto y una ruta interna (preserva `lang` si corresponde). - /// let intro = Intro::default().with_button(Some((Lc::n("Start"), "/start".into()))); - /// // Descarta el botón de la intro. - /// let intro_no_button = Intro::default().with_button(None); - /// ``` - pub fn with_button(mut self, button: impl Into>) -> Self { - self.button = button.into(); - self - } - - /// Selecciona el tipo de apertura: [`IntroOpening::PageTop`] (por defecto) o - /// [`IntroOpening::Custom`]. - /// - /// - `PageTop`: añade *badges* automáticos y una presentación de lo que es PageTop. - /// - `Custom`: introducción en blanco para añadir cualquier contenido. - /// - /// # Ejemplo - /// - /// ```rust,no_run - /// # use pagetop::prelude::*; - /// let intro = Intro::default().with_opening(IntroOpening::Custom); - /// ``` - pub fn with_opening(mut self, opening: IntroOpening) -> Self { - self.opening = opening; - self - } - - /// Añade un nuevo componente a la intro o modifica la lista de componentes (`children`) con una - /// operación [`ChildOp`]. - /// - /// Si se añade un bloque ([`Block`]) se aplicarán estilos específicos para destacarlo. - pub fn with_child(mut self, op: impl Into) -> Self { - self.children.alter_child(op.into()); - self - } -} +mod component; +pub use component::Intro; diff --git a/src/base/component/intro/component.rs b/src/base/component/intro/component.rs new file mode 100644 index 00000000..52a80439 --- /dev/null +++ b/src/base/component/intro/component.rs @@ -0,0 +1,320 @@ +use crate::prelude::*; + +/// Componente para divulgar PageTop (como hace [`Welcome`](crate::base::extension::Welcome)), o +/// mostrar presentaciones. +/// +/// Usa la imagen de PageTop para mostrar: +/// +/// - Una **figura decorativa** (que incluye la *mascota* de PageTop) antecediendo al contenido. +/// - Una vista destacada del **título** de la página con un **eslogan** de presentación. +/// - Un **botón opcional** de llamada a la acción con texto y enlace configurables. +/// - Un **área para la presentación de contenidos**, con *badges* informativos de PageTop (si se +/// opta por [`intro::Kind::PageTop`]) y bloques ([`Block`](crate::base::component::Block)) de +/// contenido libre. Los párrafos que tengan la clase `.intro-text-lead` se mostrarán con una +/// tipografía ampliada, ideal para presentaciones breves e impactantes. +/// +/// # Ejemplos +/// +/// **Intro mínima por defecto** +/// +/// ```rust,no_run +/// # use pagetop::prelude::*; +/// let intro = Intro::default(); +/// ``` +/// +/// **Título, eslogan y botón personalizados** +/// +/// ```rust,no_run +/// # use pagetop::prelude::*; +/// let intro = Intro::default() +/// .with_title(Lc::l("intro_custom_title")) +/// .with_slogan(Lc::l("intro_custom_slogan")) +/// .with_button(Some(( +/// Lc::l("intro_learn_more"), +/// "/learn-more".into() +/// ))); +/// ``` +/// +/// **Modo *Custom* con título y botón propios** +/// +/// ```rust,no_run +/// # use pagetop::prelude::*; +/// let intro = Intro::custom() +/// .with_title(Lc::l("intro_custom_title")) +/// .with_button(Some((Lc::l("intro_learn_more"), "/learn-more".into()))); +/// ``` +/// +/// **Área de contenidos al ancho máximo** +/// +/// ```rust,no_run +/// # use pagetop::prelude::*; +/// let intro = Intro::default().with_width(intro::Width::Full); +/// ``` +/// +/// **Añadir contenidos hijo** +/// +/// ```rust,no_run +/// # use pagetop::prelude::*; +/// let intro = Intro::default() +/// .with_child( +/// Block::new() +/// .with_title(Lc::l("intro_custom_block_title")) +/// .with_child(Html::with(move |cx| { +/// html! { +/// p class="intro-text-lead" { +/// (Lc::l("intro_custom_paragraph_1").using(cx)) +/// } +/// p { (Lc::l("intro_custom_paragraph_2").using(cx)) } +/// } +/// })), +/// ); +/// ``` +#[derive(Clone, Debug, Getters)] +pub struct Intro { + /// Devuelve el título de entrada. + title: Lc, + /// Devuelve el eslogan de la entrada. + slogan: Lc, + /// Devuelve el botón de llamada a la acción, si existe. + button: Option<(Lc, Route)>, + /// Devuelve el tipo de introducción, fijado al construirla. + kind: intro::Kind, + /// Devuelve el ancho máximo del área de contenidos. + width: intro::Width, + /// Devuelve la lista de componentes hijo de la intro. + children: Children, +} + +impl Default for Intro { + fn default() -> Self { + const BUTTON_LINK: &str = "https://pagetop.cillero.es"; + + Intro { + title: Lc::l("intro_default_title"), + slogan: Lc::l("intro_default_slogan").with_arg("app", &global::SETTINGS.app.name), + button: Some((Lc::l("intro_default_button"), BUTTON_LINK.into())), + kind: intro::Kind::default(), + width: intro::Width::default(), + children: Children::default(), + } + } +} + +#[async_trait] +impl Component for Intro { + fn new() -> Self { + Self::default() + } + + async fn prepare(&self, cx: &mut Context) -> Result { + cx.alter_assets(StyleSheet::from("/pagetop/css/intro.css").with_version(PAGETOP_VERSION)); + if *self.kind() == intro::Kind::PageTop { + cx.alter_assets(JavaScript::on_load_async("intro-js", |cx| + util::indoc!(r#" + try { + const resp = await fetch("https://crates.io/api/v1/crates/pagetop"); + const data = await resp.json(); + const date = new Date(data.versions[0].created_at); + const formatted = date.toLocaleDateString("LANGID", { year: "numeric", month: "2-digit", day: "2-digit" }); + document.getElementById("intro-release").src = `https://img.shields.io/badge/Release%20date-${encodeURIComponent(formatted)}-blue?label=LABEL&style=for-the-badge`; + } catch (e) { + console.error("Failed to fetch release date from crates.io:", e); + } finally { + document.getElementById("intro-badges").style.visibility = "visible"; + } + "#) + .replace("LANGID", cx.langid().to_string().as_str()) + .replace("LABEL", Lc::l("intro_release_label").using(cx).as_str()) + )); + } + + let title = self.title().using(cx); + let slogan = self.slogan().using(cx); + + Ok(html! { + div class="intro" { + div class="intro-header" { + @if !title.is_empty() || !slogan.is_empty() { + section class="intro-header-body" { + h1 class="intro-header-title" { + @if !title.is_empty() { + span { (title) } + } + (slogan) + } + } + } + aside class="intro-header-img" aria-hidden="true" { + div class="intro-header-mascot" { + (PageTopSvg::Color.markup()) + } + } + } + div class="intro-content" { + section class="intro-content-body" { + div class=(self.width().classes()) { + @if let Some((txt, lnk)) = self.button() { + div class="intro-button" { + a + class="intro-button-link" + href=(lnk.resolve(cx)) + target="_blank" + rel="noopener noreferrer" + { + span {} span {} span {} + div class="intro-button-text" { + (txt.using(cx)) + } + } + } + } + div class="intro-text-body" { + @if *self.kind() == intro::Kind::PageTop { + p class="intro-text-lead" { + (Lc::l("intro_text1").using(cx)) + } + div id="intro-badges" { + img + src="https://img.shields.io/crates/v/pagetop.svg?label=PageTop&style=for-the-badge" + alt=[Lc::l("intro_pagetop_label").lookup(cx)] {} (" ") + img + id="intro-release" + alt=[Lc::l("intro_release_label").lookup(cx)] {} (" ") + img + src=(util::join!( + "https://img.shields.io/badge/license-MIT%2FApache-blue.svg?label=", + Lc::l("intro_license_label").lookup(cx).unwrap_or_default(), + "&style=for-the-badge" + )) + alt=[Lc::l("intro_license_label").lookup(cx)] {} + } + p class="intro-text-lead" { + (Lc::l("intro_text2").using(cx)) + } + } + (self.children().render(cx).await) + } + } + } + } + div class="intro-footer" { + section class="intro-footer-body" { + div class="intro-footer-logo" { + (PageTopSvg::LineLight.markup()) + } + div class="intro-footer-links" { + a href="https://crates.io/crates/pagetop" target="_blank" rel="noopener noreferrer" { ("Crates.io") } + a href="https://docs.rs/pagetop" target="_blank" rel="noopener noreferrer" { ("Docs.rs") } + a href="https://git.cillero.es/manuelcillero/pagetop" target="_blank" rel="noopener noreferrer" { (Lc::l("intro_code").using(cx)) } + em { (Lc::l("intro_have_fun").using(cx)) } + } + } + } + } + }) + } +} + +#[builder_impl] +impl Intro { + // **< Intro BUILDER >************************************************************************** + + /// Crea una introducción de tipo [`intro::Kind::Custom`]. + /// + /// Parte de una introducción sin el contenido de presentación de PageTop (párrafos y *badges*), + /// sin título, sin eslogan y sin botón de llamada a la acción. Todo ello puede añadirse con + /// [`with_title()`], [`with_slogan()`] y [`with_button()`]. + /// + /// El tipo se fija al construirla y no se puede cambiar después; para la introducción estándar + /// de PageTop (tipo [`intro::Kind::PageTop`]) se usa [`Intro::default()`] o [`Intro::new()`]. + /// + /// # Ejemplo + /// + /// ```rust,no_run + /// # use pagetop::prelude::*; + /// let intro = Intro::custom().with_title(Lc::n("Intro title")); + /// ``` + /// + /// [`with_title()`]: Self::with_title + /// [`with_slogan()`]: Self::with_slogan + /// [`with_button()`]: Self::with_button + pub fn custom() -> Self { + Self { + kind: intro::Kind::Custom, + title: Lc::none(), + slogan: Lc::none(), + button: None, + ..Default::default() + } + } + + /// Establece el título de entrada. + /// + /// # Ejemplo + /// + /// ```rust,no_run + /// # use pagetop::prelude::*; + /// let intro = Intro::default().with_title(Lc::n("Intro title")); + /// ``` + pub fn with_title(mut self, title: Lc) -> Self { + self.title = title; + self + } + + /// Establece el eslogan de entrada (línea secundaria del título). + /// + /// # Ejemplo + /// + /// ```rust,no_run + /// # use pagetop::prelude::*; + /// let intro = Intro::default().with_slogan(Lc::n("A short slogan")); + /// ``` + pub fn with_slogan(mut self, slogan: Lc) -> Self { + self.slogan = slogan; + self + } + + /// Configura el botón opcional de llamada a la acción. + /// + /// - Usa `Some((texto, ruta))` para mostrarlo, donde [`Route`] resuelve la ruta o URL final al + /// pulsar el botón según el contexto de renderizado. + /// - Usa `None` para ocultarlo. + /// + /// # Ejemplo + /// + /// ```rust,no_run + /// # use pagetop::prelude::*; + /// // Define un botón con texto y una ruta interna (preserva `lang` si corresponde). + /// let intro = Intro::default().with_button(Some((Lc::n("Start"), "/start".into()))); + /// // Descarta el botón de la intro. + /// let intro_no_button = Intro::default().with_button(None); + /// ``` + pub fn with_button(mut self, button: impl Into>) -> Self { + self.button = button.into(); + self + } + + /// Establece el ancho máximo del área de contenidos. + /// + /// Ver [`intro::Width`] para las variantes disponibles. + /// + /// # Ejemplo + /// + /// ```rust,no_run + /// # use pagetop::prelude::*; + /// let intro = Intro::default().with_width(intro::Width::Wide); + /// ``` + pub fn with_width(mut self, width: intro::Width) -> Self { + self.width = width; + self + } + + /// Añade un nuevo componente a la intro o modifica la lista de componentes (`children`) con una + /// operación [`ChildOp`]. + /// + /// Si se añade un bloque ([`Block`]) se aplicarán estilos específicos para destacarlo. + pub fn with_child(mut self, op: impl Into) -> Self { + self.children.alter_child(op.into()); + self + } +} diff --git a/src/base/component/intro/props.rs b/src/base/component/intro/props.rs new file mode 100644 index 00000000..b5f5cad3 --- /dev/null +++ b/src/base/component/intro/props.rs @@ -0,0 +1,60 @@ +use crate::prelude::*; + +// **< Kind >*************************************************************************************** + +/// Tipo de introducción de un componente [`Intro`](super::Intro). +/// +/// Distingue entre una presentación estándar sobre PageTop (la que usa, por ejemplo, la página de +/// bienvenida [`Welcome`]) y una introducción completamente personalizada. +/// +/// Se fija al construir la introducción, con [`Intro::default()`] o [`Intro::new()`] para +/// [`Kind::PageTop`], o con [`Intro::custom()`] para [`Kind::Custom`], y se consulta con +/// [`Intro::kind()`]. +/// +/// [`Welcome`]: crate::base::extension::Welcome +/// [`Intro::default()`]: super::Intro::default +/// [`Intro::new()`]: super::Intro::new +/// [`Intro::custom()`]: super::Intro::custom +/// [`Intro::kind()`]: super::Intro::kind +#[derive(AutoDefault, Clone, Copy, Debug, Eq, PartialEq)] +pub enum Kind { + /// Presentación predeterminada. Muestra una introducción estándar de PageTop e incluye + /// automáticamente *badges* con información sobre la última versión liberada, fecha del último + /// lanzamiento y licencia de uso. + #[default] + PageTop, + /// Modo personalizado. Usa la imagen de PageTop pero sin contenido de ningún tipo. + Custom, +} + +// **< Width >************************************************************************************** + +/// Ancho máximo para el área de contenidos de un componente [`Intro`](super::Intro). +/// +/// Sólo afecta a partir del punto de corte [`Breakpoint::Lg`], donde el área de contenidos se +/// muestra como un panel centrado. Por debajo ocupa siempre todo el ancho disponible. En pantallas +/// que no alcanzan el ancho máximo elegido, el panel se reduce para dejar un margen lateral. +#[derive(AutoDefault, Clone, Copy, Debug, Eq, PartialEq)] +pub enum Width { + /// Ancho estrecho. + Narrow, + /// Ancho por defecto. + #[default] + Normal, + /// Ancho amplio. + Wide, + /// Ancho más amplio de los disponibles. + Full, +} + +impl Width { + // Clases CSS del área de contenidos: la base más la modificadora, si el ancho no es el normal. + pub(super) fn classes(self) -> &'static str { + match self { + Width::Narrow => "intro-text intro-text-narrow", + Width::Normal => "intro-text", + Width::Wide => "intro-text intro-text-wide", + Width::Full => "intro-text intro-text-full", + } + } +} diff --git a/src/core/theme/definition.rs b/src/core/theme/definition.rs index b951e52b..9792877a 100644 --- a/src/core/theme/definition.rs +++ b/src/core/theme/definition.rs @@ -1,5 +1,5 @@ use crate::async_trait; -use crate::base::component::{Html, Intro, IntroOpening, layout}; +use crate::base::component::{Html, Intro, layout}; use crate::core::component::{ChildOp, Component, ComponentError, ComponentRender}; use crate::core::component::{Context, Contextual}; use crate::core::extension::Extension; @@ -413,11 +413,9 @@ pub trait Theme: Extension + Send + Sync { page.alter_title(title).alter_child_in( &CoreRegions::Content, ChildOp::Prepend( - Intro::new() + Intro::custom() .with_title(Lc::l("error_code").with_arg("code", code.to_string())) .with_slogan(Lc::n(code.to_string())) - .with_button(None) - .with_opening(IntroOpening::Custom) .with_child(Html::with(move |cx| { html! { h1 { (alert.using(cx)) }