diff --git a/Cargo.lock b/Cargo.lock index 6a7d89f5..30f588d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -396,6 +396,12 @@ 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" @@ -1875,6 +1881,7 @@ dependencies = [ name = "pagetop-menu" version = "0.1.0" dependencies = [ + "chrono", "pagetop", "pagetop-seaorm", "serde", @@ -1885,6 +1892,7 @@ dependencies = [ name = "pagetop-minimal" version = "0.1.0" dependencies = [ + "concat-string", "indoc", "pastey", ] diff --git a/Cargo.toml b/Cargo.toml index 3e0974fd..730922ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ 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/assets/css/basic.css b/assets/css/basic.css index 2fe4bb7c..1f92da1f 100644 --- a/assets/css/basic.css +++ b/assets/css/basic.css @@ -44,7 +44,7 @@ body { -webkit-tap-highlight-color: transparent; } -/* Header, Aside, Content and Footer are stacked in that order, using the full available width. */ +/* Header, Aside, Content y Footer se apilan en el mismo orden ocupando todo el ancho disponible. */ .wrapper { display: flex; flex-direction: column; @@ -125,10 +125,10 @@ body { text-decoration: underline; } -/* 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). */ +/* 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). */ .dropdown-button, .dropdown-toggle { padding: 0.5rem 0.75rem; @@ -247,79 +247,6 @@ 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 */ @@ -391,10 +318,10 @@ body { display: inline-block; } -/* 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). */ +/* 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). */ .dropdown-toggle::after { content: "\25BC"; display: inline-block; @@ -1001,9 +928,9 @@ input:disabled + label { color: var(--val-color--text--muted); } -/* .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 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 { display: inline-block; diff --git a/assets/css/intro.css b/assets/css/intro.css index b7679c3e..5f07b378 100644 --- a/assets/css/intro.css +++ b/assets/css/intro.css @@ -22,10 +22,6 @@ 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); @@ -48,6 +44,20 @@ 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 */ @@ -107,7 +117,7 @@ body { width: 280px; height: 280px; } -@media (width >= 992px) { +@media (min-width: 64rem) { .intro-header { background-image: var(--intro-bg-img); background-image: var(--intro-bg-img-set); @@ -156,12 +166,12 @@ body { .intro-content-body:after { bottom: -1rem; } -@media (width < 768px) { +@media (width <= 48rem) { .intro-content-body { margin-top: -9.8rem; } } -@media (width >= 992px) { +@media (width >= 64rem) { .intro-content { margin-top: 0; } @@ -198,10 +208,6 @@ 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; @@ -304,7 +310,7 @@ body { .intro-button-link:hover span { animation-play-state: paused; } -@media (width < 768px) { +@media (width <= 48rem) { .intro-header { padding-bottom: 9rem;; } @@ -318,7 +324,7 @@ body { padding-top: .5rem; } } -@media (width >= 768px) { +@media (width > 48rem) { .intro-button { position: absolute; top: 0; @@ -355,12 +361,12 @@ body { font-size: 1.5rem; margin: 0 0 1.5rem; } -@media (width >= 768px) { +@media (width > 48rem) { .intro-button + .intro-text-body { padding-top: 7rem; } } -@media (width >= 992px) { +@media (width >= 64rem) { .intro-header { padding-bottom: 9rem;; } @@ -370,18 +376,9 @@ body { } .intro-text { box-shadow: var(--intro-shadow); - max-width: min(60rem, 100vw - 4rem); + max-width: 60rem; 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; @@ -482,20 +479,6 @@ 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; @@ -513,12 +496,12 @@ body { flex-wrap: wrap; margin-top: 2rem; } -@media (width < 768px) { +@media (width <= 48rem) { .intro-footer-logo { display: none; } } -@media (width < 992px) { +@media (width <= 64rem) { .intro-footer-body { padding: 0 1rem; } diff --git a/examples/form-controls.rs b/examples/form-controls.rs index e2c7dc43..e7311b21 100644 --- a/examples/form-controls.rs +++ b/examples/form-controls.rs @@ -19,9 +19,11 @@ impl Extension for FormControls { async fn form_controls(request: HttpRequest) -> Result { Page::new(request) .with_child( - Intro::custom() + Intro::default() + .with_opening(IntroOpening::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 2cea5c84..cccc303f 100644 --- a/examples/intro-colors.rs +++ b/examples/intro-colors.rs @@ -14,9 +14,11 @@ impl Extension for IntroColors { async fn intro_colors(request: HttpRequest) -> Result { Page::new(request) .with_child( - Intro::custom() + Intro::default() + .with_opening(IntroOpening::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 4f810f61..4e07a0bb 100644 --- a/examples/intro-flex.rs +++ b/examples/intro-flex.rs @@ -20,9 +20,11 @@ async fn intro_flex(request: HttpRequest) -> Result { .with_assets(demo_box_styles()) .with_assets(demo_row_styles()) .with_child( - Intro::custom() + Intro::default() + .with_opening(IntroOpening::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 71d7b6c6..c22c3912 100644 --- a/examples/intro-grid.rs +++ b/examples/intro-grid.rs @@ -20,9 +20,11 @@ async fn intro_grid(request: HttpRequest) -> Result { .with_assets(demo_box_styles()) .with_assets(demo_row_styles()) .with_child( - Intro::custom() + Intro::default() + .with_opening(IntroOpening::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()) @@ -470,8 +472,7 @@ 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 3b06df66..6306a678 100644 --- a/examples/intro-responsive.rs +++ b/examples/intro-responsive.rs @@ -21,9 +21,11 @@ async fn intro_responsive(request: HttpRequest) -> Result { .with_assets(demo_row_styles()) .with_assets(demo_code_styles()) .with_child( - Intro::custom() + Intro::default() + .with_opening(IntroOpening::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 a3b0d51a..eed4c38f 100644 --- a/examples/intro-spacing.rs +++ b/examples/intro-spacing.rs @@ -21,9 +21,11 @@ async fn intro_spacing(request: HttpRequest) -> Result { .with_assets(demo_row_styles()) .with_assets(demo_code_styles()) .with_child( - Intro::custom() + Intro::default() + .with_opening(IntroOpening::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/examples/locale/es-ES/navbar-menus.ftl b/examples/locale/es-ES/navbar-menus.ftl index a87e407d..af0b1ec3 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 = Externo -menus_item_disabled = Deshabilitado +menus_item_blank = Enlace externo +menus_item_disabled = Enlace deshabilitado menus_test_title = Desplegable diff --git a/examples/navbar-menus.rs b/examples/navbar-menus.rs index 54cb37b7..eb2fecaf 100644 --- a/examples/navbar-menus.rs +++ b/examples/navbar-menus.rs @@ -1,4 +1,5 @@ use pagetop::prelude::*; +use pagetop_bootsier::theme::*; include_locales!(LOC from "examples/locale"); @@ -15,84 +16,87 @@ impl Extension for SuperMenu { } async fn initialize(&self) { - 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( + 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( Lc::t("menus_item_blank", &LOC), "https://docs.rs/pagetop", )) - .with_item(nav::Item::dropdown( - Dropdown::new() + .with_item(bs::nav::Item::dropdown( + bs::Dropdown::new() .with_title(Lc::t("menus_test_title", &LOC)) - .with_item(dropdown::Item::header(Lc::t("menus_dev_header", &LOC))) - .with_item(dropdown::Item::link( + .with_item(bs::dropdown::Item::header(Lc::t("menus_dev_header", &LOC))) + .with_item(bs::dropdown::Item::link( Lc::t("menus_dev_getting_started", &LOC), "/dev/getting-started", )) - .with_item(dropdown::Item::link( + .with_item(bs::dropdown::Item::link( Lc::t("menus_dev_guides", &LOC), "/dev/guides", )) - .with_item(dropdown::Item::link_blank( + .with_item(bs::dropdown::Item::link_blank( Lc::t("menus_dev_forum", &LOC), "https://forum.example.dev", )) - .with_item(dropdown::Item::divider()) - .with_item(dropdown::Item::header(Lc::t("menus_sdk_header", &LOC))) - .with_item(dropdown::Item::link( + .with_item(bs::dropdown::Item::divider()) + .with_item(bs::dropdown::Item::header(Lc::t("menus_sdk_header", &LOC))) + .with_item(bs::dropdown::Item::link( Lc::t("menus_sdk_rust", &LOC), "/dev/sdks/rust", )) - .with_item(dropdown::Item::link( + .with_item(bs::dropdown::Item::link( Lc::t("menus_sdk_js", &LOC), "/dev/sdks/js", )) - .with_item(dropdown::Item::link( + .with_item(bs::dropdown::Item::link( Lc::t("menus_sdk_python", &LOC), "/dev/sdks/python", )) - .with_item(dropdown::Item::divider()) - .with_item(dropdown::Item::header(Lc::t("menus_plugin_header", &LOC))) - .with_item(dropdown::Item::link( + .with_item(bs::dropdown::Item::divider()) + .with_item(bs::dropdown::Item::header(Lc::t( + "menus_plugin_header", + &LOC, + ))) + .with_item(bs::dropdown::Item::link( Lc::t("menus_plugin_auth", &LOC), "/dev/sdks/rust/plugins/auth", )) - .with_item(dropdown::Item::link( + .with_item(bs::dropdown::Item::link( Lc::t("menus_plugin_cache", &LOC), "/dev/sdks/rust/plugins/cache", )) - .with_item(dropdown::Item::divider()) - .with_item(dropdown::Item::label(Lc::t("menus_item_label", &LOC))) - .with_item(dropdown::Item::link_disabled( + .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( Lc::t("menus_item_disabled", &LOC), "#", )), )) - .with_item(nav::Item::link_disabled( + .with_item(bs::nav::Item::link_disabled( Lc::t("menus_item_disabled", &LOC), "#", )), )) - .with_item(navbar::Item::nav( - Nav::new() + .with_item(bs::navbar::Item::nav( + bs::Nav::new() // Empuja este menú (y lo que le siga) al extremo final de la barra. .with_prop(FlexItem::push_end()) - .with_item(nav::Item::link( + .with_item(bs::nav::Item::link( Lc::t("menus_item_sign_up", &LOC), "/auth/sign-up", )) - .with_item(nav::Item::link( + .with_item(bs::nav::Item::link( Lc::t("menus_item_login", &LOC), "/auth/login", )), )); InRegion::Global(&CoreRegions::Header).add( - Container::new() - .with_width(container::Width::FluidMax(UnitValue::RelRem(75.0))) + bs::Container::new() + .with_width(bs::container::Width::FluidMax(UnitValue::RelRem(75.0))) .with_child(navbar_menu), ); } diff --git a/extensions/pagetop-admin/src/component/admin_frame.rs b/extensions/pagetop-admin/src/component/admin_frame.rs index 0be5c5d8..380beb47 100644 --- a/extensions/pagetop-admin/src/component/admin_frame.rs +++ b/extensions/pagetop-admin/src/component/admin_frame.rs @@ -1,3 +1,4 @@ +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 95b6c4c6..37b0329a 100644 --- a/extensions/pagetop-admin/src/entity/setting.rs +++ b/extensions/pagetop-admin/src/entity/setting.rs @@ -1,6 +1,7 @@ -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")] @@ -13,7 +14,7 @@ pub struct Model { pub updated_by: Option, } -#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)] +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} impl ActiveModelBehavior for ActiveModel {} diff --git a/extensions/pagetop-admin/src/settings.rs b/extensions/pagetop-admin/src/settings.rs index 60f73637..e2e38ee7 100644 --- a/extensions/pagetop-admin/src/settings.rs +++ b/extensions/pagetop-admin/src/settings.rs @@ -2,7 +2,8 @@ //! //! Proporciona una API async para leer y escribir valores JSON en la tabla `settings`. -use pagetop::prelude::*; +use pagetop::datetime::Utc; +use pagetop::{Getters, builder_impl}; use pagetop_seaorm::db::{ ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter, dbconn, }; @@ -230,6 +231,6 @@ impl SettingsSchema { /// Devuelve la clave completa de un campo: `"{scope}.{field_name}"`. pub fn key_for(&self, field_name: &str) -> String { - util::join!(&self.scope, ".", field_name) + format!("{}.{}", self.scope, field_name) } } diff --git a/extensions/pagetop-bootsier/src/lib.rs b/extensions/pagetop-bootsier/src/lib.rs index 949913ee..54528768 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), + Container => |c| theme::bs::container::setup(c, cx), 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 6879c222..f093ac42 100644 --- a/extensions/pagetop-bootsier/src/theme.rs +++ b/extensions/pagetop-bootsier/src/theme.rs @@ -103,6 +103,8 @@ 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 7bc36b71..0ce50d05 100644 --- a/extensions/pagetop-bootsier/src/theme/bs.rs +++ b/extensions/pagetop-bootsier/src/theme/bs.rs @@ -27,6 +27,8 @@ 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 00afd2ff..2bb08e79 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/container.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/container.rs @@ -1,37 +1,108 @@ //! 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::*; -pub use pagetop::base::component::container::{Container, Kind, Width}; +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 + } +} // **< Container SETUP >**************************************************************************** -pub(crate) fn setup(container: &mut Container) { - if container.width().is_none() { - container.alter_prop(PropsOp::prepend_classes("container")); +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())); } } diff --git a/extensions/pagetop-bootsier/src/theme/bs/dropdown/props.rs b/extensions/pagetop-bootsier/src/theme/bs/dropdown/props.rs index 948f349b..c07fcceb 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(util::join!(x.to_string(), ",", y.to_string())), + Self::Offset(x, y) => Some(format!("{x},{y}")), _ => None, } } diff --git a/extensions/pagetop-bootsier/src/theme/bs/layout/template.rs b/extensions/pagetop-bootsier/src/theme/bs/layout/template.rs index 90ba79c9..8b80c84b 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::bs; +use crate::theme::{ContainerBootsier, 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 1261c8e5..659bc926 100644 --- a/extensions/pagetop-bootsier/tests/breakpoint_class.rs +++ b/extensions/pagetop-bootsier/tests/breakpoint_class.rs @@ -1,6 +1,5 @@ -// 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`. +// 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`. use pagetop::prelude::*; use pagetop_bootsier::theme::*; @@ -9,43 +8,23 @@ 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() { - 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""#) + 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" ); } #[pagetop::test] async fn container_width_xxxl_resolves_as_xxl() { - assert!( - container_html(bs::container::Width::From(Breakpoint::Xxxl)) - .await - .contains(r#"class="container-xxl""#) + let cx = cx(); + assert_eq!( + bs::container::Width::From(Breakpoint::Xxxl).to_class(&cx), + "container-xxl" ); } diff --git a/extensions/pagetop-htmx/src/hx.rs b/extensions/pagetop-htmx/src/hx.rs index 824fa457..1022d077 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`]: +//! directamente en la macro `html!` sin necesidad de [`Props`](pagetop::html::Props): //! //! ```rust,no_run //! use pagetop::prelude::*; @@ -17,10 +17,10 @@ //! }; //! ``` //! -//! # Atributos dinámicos con [`Props`] +//! # Atributos dinámicos con [`Props`](pagetop::html::Props) //! //! Cuando los valores se construyen en tiempo de ejecución o se inyectan desde una extensión, -//! puedes usar [`Props`] combinado con las constantes de este módulo: +//! puedes usar [`Props`](pagetop::html::Props) combinado con las constantes de este módulo: //! //! ```rust,no_run //! # use pagetop::prelude::*; @@ -81,8 +81,6 @@ //! .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. @@ -358,7 +356,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 { - util::join!("hx-on:", event) + format!("hx-on:{event}") } /// Genera `hx-on::{event}` para escuchar eventos propios de HTMX en línea. @@ -374,7 +372,7 @@ pub fn on(event: &str) -> String { /// .with_prop(PropsOp::set(hx::on_htmx("after-swap"), "initTooltips()")); /// ``` pub fn on_htmx(event: &str) -> String { - util::join!("hx-on::", event) + format!("hx-on::{event}") } // **< HTMX Request Headers >*********************************************************************** diff --git a/extensions/pagetop-menu/Cargo.toml b/extensions/pagetop-menu/Cargo.toml index d58e29ba..bd8d676d 100644 --- a/extensions/pagetop-menu/Cargo.toml +++ b/extensions/pagetop-menu/Cargo.toml @@ -14,5 +14,6 @@ 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 3b6d9d61..c049883c 100644 --- a/extensions/pagetop-menu/src/component/menu_breadcrumb.rs +++ b/extensions/pagetop-menu/src/component/menu_breadcrumb.rs @@ -1,3 +1,4 @@ +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 eaf2220a..3206e900 100644 --- a/extensions/pagetop-menu/src/entity/menu.rs +++ b/extensions/pagetop-menu/src/entity/menu.rs @@ -1,6 +1,7 @@ -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 4e22ecf4..bcdc0238 100644 --- a/extensions/pagetop-menu/src/entity/menu_item.rs +++ b/extensions/pagetop-menu/src/entity/menu_item.rs @@ -1,6 +1,7 @@ -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 578b1798..4db2cea5 100644 --- a/extensions/pagetop-menu/src/repo.rs +++ b/extensions/pagetop-menu/src/repo.rs @@ -1,6 +1,8 @@ //! Operaciones de base de datos para menús e ítems de menú. -use pagetop::prelude::*; +use chrono::Utc; + +use pagetop::locale::Locale; 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 2d35d032..0df23a53 100644 --- a/extensions/pagetop-menu/src/tree.rs +++ b/extensions/pagetop-menu/src/tree.rs @@ -2,6 +2,7 @@ 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 57f6f8ee..8d136672 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, DeriveRelation, EnumIter)] +//! #[derive(Clone, Copy, Debug, EnumIter, DeriveRelation)] //! 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, DeriveRelation, EnumIter)] +/// #[derive(Clone, Copy, Debug, EnumIter, DeriveRelation)] /// 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 a0b8580f..b9167549 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::prelude::*; +use pagetop::trace::info; 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?; - trace::info!("Checking migration status"); + info!("Checking migration status"); for Migration { migration, status } in Self::get_migration_with_status(db).await? { - trace::info!("Migration '{}'... {}", migration.name(), status); + info!("Migration '{}'... {}", migration.name(), status); } Ok(()) @@ -284,35 +284,34 @@ where // Temporarily disable the foreign key check if db_backend == DbBackend::Sqlite { - trace::info!("Disabling foreign key check"); + info!("Disabling foreign key check"); db.execute(Statement::from_string( db_backend, "PRAGMA foreign_keys = OFF".to_owned(), )) .await?; - trace::info!("Foreign key check disabled"); + info!("Foreign key check disabled"); } // Drop all foreign keys if db_backend == DbBackend::MySql { - trace::info!("Dropping all foreign keys"); + 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")?; - trace::info!( + 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?; - trace::info!("Foreign key '{}' has been dropped", constraint_name); + info!("Foreign key '{}' has been dropped", constraint_name); } - trace::info!("All foreign keys dropped"); + info!("All foreign keys dropped"); } // Drop all tables @@ -320,39 +319,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")?; - trace::info!("Dropping table '{}'", table_name); + 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?; - trace::info!("Table '{}' has been dropped", table_name); + info!("Table '{}' has been dropped", table_name); } // Drop all types if db_backend == DbBackend::Postgres { - trace::info!("Dropping all types"); + 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")?; - trace::info!("Dropping type '{}'", type_name); + info!("Dropping type '{}'", type_name); let mut stmt = Type::drop(); stmt.name(Alias::new(&type_name)); db.execute(db_backend.build(&stmt)).await?; - trace::info!("Type '{}' has been dropped", type_name); + info!("Type '{}' has been dropped", type_name); } } // Restore the foreign key check if db_backend == DbBackend::Sqlite { - trace::info!("Restoring foreign key check"); + info!("Restoring foreign key check"); db.execute(Statement::from_string( db_backend, "PRAGMA foreign_keys = ON".to_owned(), )) .await?; - trace::info!("Foreign key check restored"); + info!("Foreign key check restored"); } // Reapply all migrations @@ -368,15 +367,15 @@ where M::install(db).await?; /* if let Some(steps) = steps { - trace::info!("Applying {} pending migrations", steps); + info!("Applying {} pending migrations", steps); } else { - trace::info!("Applying all pending migrations"); + info!("Applying all pending migrations"); } */ let migrations = M::get_pending_migrations(db).await?.into_iter(); /* if migrations.len() == 0 { - trace::info!("No pending migrations"); + info!("No pending migrations"); } */ for Migration { migration, .. } in migrations { @@ -386,9 +385,9 @@ where } *steps -= 1; } - trace::info!("Applying migration '{}'", migration.name()); + info!("Applying migration '{}'", migration.name()); migration.up(manager).await?; - trace::info!("Migration '{}' has been applied", migration.name()); + info!("Migration '{}' has been applied", migration.name()); let now = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .expect("SystemTime before UNIX EPOCH!"); @@ -413,14 +412,14 @@ where M::install(db).await?; if let Some(steps) = steps { - trace::info!("Rolling back {} applied migrations", steps); + info!("Rolling back {} applied migrations", steps); } else { - trace::info!("Rolling back all applied migrations"); + info!("Rolling back all applied migrations"); } let migrations = M::get_applied_migrations(db).await?.into_iter().rev(); if migrations.len() == 0 { - trace::info!("No applied migrations"); + info!("No applied migrations"); } for Migration { migration, .. } in migrations { if let Some(steps) = steps.as_mut() { @@ -429,9 +428,9 @@ where } *steps -= 1; } - trace::info!("Rolling back migration '{}'", migration.name()); + info!("Rolling back migration '{}'", migration.name()); migration.down(manager).await?; - trace::info!("Migration '{}' has been rollbacked", migration.name()); + 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 57c7832c..51da9300 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, DeriveEntityModel, Eq, PartialEq)] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] // 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(Clone, Copy, Debug, DeriveRelation, EnumIter)] +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} impl ActiveModelBehavior for ActiveModel {} diff --git a/extensions/pagetop-user/src/account.rs b/extensions/pagetop-user/src/account.rs index 54342077..106c7977 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::prelude::*; +use pagetop::auth::PermissionRef; // **< 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`] 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`](pagetop::auth::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 ece3800f..91801ab9 100644 --- a/extensions/pagetop-user/src/auth.rs +++ b/extensions/pagetop-user/src/auth.rs @@ -1,6 +1,7 @@ //! Lógica de autenticación: login, logout, registro, semilla inicial. -use pagetop::prelude::*; +use pagetop::datetime::{Duration, NaiveDateTime, Utc}; + use pagetop_seaorm::db::{ ActiveModelTrait, ActiveValue, ColumnTrait, Condition, EntityTrait, PaginatorTrait, QueryFilter, Set, dbconn, 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 5775d8b0..3c418660 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 = user_path(self.user_id(), "password"); + let action = format!("{ADMIN_USERS_PATH}/{}/password", self.user_id()); 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 f132f233..5bbfea89 100644 --- a/extensions/pagetop-user/src/component/admin/role_form.rs +++ b/extensions/pagetop-user/src/component/admin/role_form.rs @@ -4,7 +4,6 @@ use pagetop::prelude::*; use crate::ADMIN_ROLES_PATH; use crate::LOCALES_USER; -use crate::role_path; use crate::component::error_banner; @@ -36,8 +35,13 @@ impl Component for RoleForm { async fn prepare(&self, cx: &mut Context) -> Result { let action = match self.mode() { - RoleFormMode::New => util::join!(ADMIN_ROLES_PATH, "/new"), - RoleFormMode::Edit => role_path(self.role_id().copied().unwrap_or_default(), "edit"), + RoleFormMode::New => format!("{ADMIN_ROLES_PATH}/new"), + RoleFormMode::Edit => { + format!( + "{ADMIN_ROLES_PATH}/{}/edit", + self.role_id().copied().unwrap_or_default() + ) + } }; 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 8efc0d34..ad1b237c 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 = role_path(self.role_id(), "permissions"); + let action = format!("{ADMIN_ROLES_PATH}/{}/permissions", self.role_id()); 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(util::join!("permission-group-", idx.to_string())) + .with_id(format!("permission-group-{idx}")) .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(util::join!(text, " (", key, ")"))) + form::check::Item::new(key, Lc::n(format!("{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 231273d8..6ce4397b 100644 --- a/extensions/pagetop-user/src/component/admin/role_table.rs +++ b/extensions/pagetop-user/src/component/admin/role_table.rs @@ -1,12 +1,13 @@ //! 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; 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)] @@ -91,7 +92,7 @@ impl Component for RoleTable { }; table.alter_row( - table::Row::new() + Row::new() .with_cell(role.machine_name.as_str()) .with_cell(label_cell(role, &waypoint)) .with_cell(Html::with(move |_cx| { @@ -107,7 +108,7 @@ impl Component for RoleTable { ); } - let new_href = waypoint.append_to(cx.route(util::join!(ADMIN_ROLES_PATH, "/new"))); + let new_href = waypoint.append_to(cx.route(format!("{ADMIN_ROLES_PATH}/new"))); Ok(html! { div (self.props().unpack(cx)) { @@ -186,12 +187,7 @@ 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, - ) -> table::Column { + fn sort_column(&self, cx: &Context, field: RoleSortField, label_key: &'static str) -> Column { let is_active = *self.sort() == field; let active = is_active.then_some(*self.dir()); let next_dir = SortDir::next_for(active); @@ -200,7 +196,7 @@ impl RoleTable { .alter_param("sort", field.as_str()) .alter_param("dir", next_dir); - table::Column::new(Lc::t(label_key, &LOCALES_USER)).with_sort(sort_link( + Column::new(Lc::t(label_key, &LOCALES_USER)).with_sort(sort_link( route, "#role-table-wrapper", active, @@ -216,7 +212,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(role_path(id, "view"))); + let view_href = waypoint.append_to(cx.route(format!("{ADMIN_ROLES_PATH}/{id}/view"))); html! { a href=(view_href) { (label.as_str()) } } @@ -238,7 +234,8 @@ async fn actions_cell( let locked = role.locked; let waypoint = waypoint.clone(); - let permissions_href = waypoint.append_to(cx.route(role_path(id, "permissions"))); + let permissions_href = + waypoint.append_to(cx.route(format!("{ADMIN_ROLES_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 @@ -256,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(role_path(id, "edit"))); + let edit_href = waypoint.append_to(cx.route(format!("{ADMIN_ROLES_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) @@ -266,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(role_path(id, "delete/confirm")) + .route(format!("{ADMIN_ROLES_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 621f5d9e..ecaf8217 100644 --- a/extensions/pagetop-user/src/component/admin/user_form.rs +++ b/extensions/pagetop-user/src/component/admin/user_form.rs @@ -4,7 +4,6 @@ use pagetop::prelude::*; use crate::ADMIN_USERS_PATH; use crate::LOCALES_USER; -use crate::user_path; use crate::component::{PasswordConfirm, error_banner}; @@ -44,8 +43,13 @@ impl Component for UserForm { async fn prepare(&self, cx: &mut Context) -> Result { let action = match self.mode() { - UserFormMode::New => util::join!(ADMIN_USERS_PATH, "/new"), - UserFormMode::Edit => user_path(self.user_id().copied().unwrap_or_default(), "edit"), + UserFormMode::New => format!("{ADMIN_USERS_PATH}/new"), + UserFormMode::Edit => { + format!( + "{ADMIN_USERS_PATH}/{}/edit", + self.user_id().copied().unwrap_or_default() + ) + } }; 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 9a3a8dc0..927c07b0 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 = user_path(self.user_id(), "roles"); + let action = format!("{ADMIN_USERS_PATH}/{}/roles", self.user_id()); 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 603dcf98..f2122a19 100644 --- a/extensions/pagetop-user/src/component/admin/user_table.rs +++ b/extensions/pagetop-user/src/component/admin/user_table.rs @@ -1,6 +1,8 @@ //! 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; @@ -9,7 +11,6 @@ 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 { @@ -73,7 +74,7 @@ impl Component for UserTable { let status = user.status; table.alter_row( - table::Row::new() + Row::new() .with_cell(username_cell(user, &waypoint)) .with_cell(user.email.as_str()) .with_cell(user.display_name.as_deref().unwrap_or("-")) @@ -83,7 +84,7 @@ impl Component for UserTable { ); } - let new_href = waypoint.append_to(cx.route(util::join!(ADMIN_USERS_PATH, "/new"))); + let new_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/new"))); Ok(html! { div (self.props().unpack(cx)) { @@ -161,12 +162,7 @@ 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, - ) -> table::Column { + fn sort_column(&self, cx: &Context, field: UserSortField, label_key: &'static str) -> Column { let is_active = *self.sort() == field; let active = is_active.then_some(*self.dir()); let next_dir = SortDir::next_for(active); @@ -179,7 +175,7 @@ impl UserTable { .alter_param("sort", field.as_str()) .alter_param("dir", next_dir); - table::Column::new(Lc::t(label_key, &LOCALES_USER)).with_sort(sort_link( + Column::new(Lc::t(label_key, &LOCALES_USER)).with_sort(sort_link( route, "#user-table-wrapper", active, @@ -195,7 +191,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(user_path(id, "view"))); + let view_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{id}/view"))); html! { a href=(view_href) { (username.as_str()) } } @@ -212,7 +208,7 @@ async fn actions_cell( cx: &mut Context, ) -> Html { let id = user.id; - let edit_href = waypoint.append_to(cx.route(user_path(id, "edit"))); + let edit_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_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 @@ -224,7 +220,7 @@ async fn actions_cell( .await; let roles_button = if can_assign_roles { - let roles_href = waypoint.append_to(cx.route(user_path(id, "roles"))); + let roles_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_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/demo.rs b/extensions/pagetop-user/src/demo.rs index dd9935e7..d8e65d0e 100644 --- a/extensions/pagetop-user/src/demo.rs +++ b/extensions/pagetop-user/src/demo.rs @@ -4,7 +4,8 @@ //! `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::prelude::*; +use pagetop::datetime::Utc; + 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 fed14e52..6432a108 100644 --- a/extensions/pagetop-user/src/entity/role.rs +++ b/extensions/pagetop-user/src/entity/role.rs @@ -1,6 +1,7 @@ -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 6ed42368..67427dd9 100644 --- a/extensions/pagetop-user/src/entity/role_permission.rs +++ b/extensions/pagetop-user/src/entity/role_permission.rs @@ -1,6 +1,7 @@ -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 81614174..32ad7f08 100644 --- a/extensions/pagetop-user/src/entity/session.rs +++ b/extensions/pagetop-user/src/entity/session.rs @@ -1,6 +1,7 @@ -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 d2023057..5656a490 100644 --- a/extensions/pagetop-user/src/entity/user.rs +++ b/extensions/pagetop-user/src/entity/user.rs @@ -1,6 +1,7 @@ -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 de25dc6a..d497d979 100644 --- a/extensions/pagetop-user/src/entity/user_token.rs +++ b/extensions/pagetop-user/src/entity/user_token.rs @@ -1,6 +1,7 @@ -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 dac6f536..f38964e9 100644 --- a/extensions/pagetop-user/src/handlers/account.rs +++ b/extensions/pagetop-user/src/handlers/account.rs @@ -1,5 +1,6 @@ //! Handler HTTP para el perfil del propio usuario autenticado. +use pagetop::base::component::table::Row; use pagetop::prelude::*; use crate::account::UserStatus; @@ -46,32 +47,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( - table::Row::new() + Row::new() .with_cell(Lc::t("field-username-admin", &LOCALES_USER)) .with_cell(user.username.as_str()), ) .with_row( - table::Row::new() + Row::new() .with_cell(Lc::t("field-email", &LOCALES_USER)) .with_cell(user.email.as_str()), ) .with_row( - table::Row::new() + Row::new() .with_cell(Lc::t("field-display-name", &LOCALES_USER)) .with_cell(user.display_name.as_deref().unwrap_or("-")), ) .with_row( - table::Row::new() + Row::new() .with_cell(Lc::t("field-language", &LOCALES_USER)) .with_cell(user.language.as_deref().unwrap_or("-")), ) .with_row( - table::Row::new() + Row::new() .with_cell(Lc::t("field-timezone", &LOCALES_USER)) .with_cell(user.timezone.as_deref().unwrap_or("-")), ) .with_row( - table::Row::new() + Row::new() .with_cell(Lc::t("col-status", &LOCALES_USER)) .with_cell(Lc::t(status_key(status), &LOCALES_USER)), ); @@ -82,7 +83,7 @@ async fn profile_details(user: &user::Model, status: UserStatus, cx: &mut Contex .render(cx) .await; table = table.with_row( - table::Row::new() + 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 290712ff..a2211ce9 100644 --- a/extensions/pagetop-user/src/handlers/admin/roles.rs +++ b/extensions/pagetop-user/src/handlers/admin/roles.rs @@ -2,6 +2,7 @@ use serde::Deserialize; +use pagetop::base::component::table::Row; use pagetop::prelude::*; use pagetop_htmx::prelude::*; @@ -14,7 +15,6 @@ 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)] @@ -312,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( - table::Row::new() + Row::new() .with_cell(Lc::t("field-machine-name", &LOCALES_USER)) .with_cell(role.machine_name.as_str()), ) .with_row( - table::Row::new() + Row::new() .with_cell(Lc::t("field-label", &LOCALES_USER)) .with_cell(role.label.as_str()), ) .with_row( - table::Row::new() + Row::new() .with_cell(Lc::t("field-description", &LOCALES_USER)) .with_cell(role.description.as_deref().unwrap_or("-")), ) .with_row( - table::Row::new() + Row::new() .with_cell(Lc::t("field-weight", &LOCALES_USER)) .with_cell(role.weight.to_string()), ); @@ -338,7 +338,7 @@ async fn role_view_details(role: &role::Model, cx: &mut Context) -> Block { .render(cx) .await; table = table.with_row( - table::Row::new() + Row::new() .with_cell("") .with_cell(Html::with(move |_| badge.clone())), ); @@ -451,7 +451,7 @@ pub(crate) async fn delete_confirm_get( let mut cx = Context::admin(request); let delete_href = cx - .route(role_path(id, "delete")) + .route(format!("{ADMIN_ROLES_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 f3b833df..ed045684 100644 --- a/extensions/pagetop-user/src/handlers/admin/users.rs +++ b/extensions/pagetop-user/src/handlers/admin/users.rs @@ -2,9 +2,11 @@ use serde::Deserialize; +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; @@ -20,10 +22,8 @@ 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,10 +341,12 @@ fn edit_actions( ("true", "btn-grant-admin", "confirm-grant-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 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 mut status_form = Form::new() .with_action(status_action.clone()) @@ -516,32 +518,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( - table::Row::new() + Row::new() .with_cell(Lc::t("field-username-admin", &LOCALES_USER)) .with_cell(user.username.as_str()), ) .with_row( - table::Row::new() + Row::new() .with_cell(Lc::t("field-email", &LOCALES_USER)) .with_cell(user.email.as_str()), ) .with_row( - table::Row::new() + Row::new() .with_cell(Lc::t("field-display-name", &LOCALES_USER)) .with_cell(user.display_name.as_deref().unwrap_or("-")), ) .with_row( - table::Row::new() + Row::new() .with_cell(Lc::t("field-language", &LOCALES_USER)) .with_cell(user.language.as_deref().unwrap_or("-")), ) .with_row( - table::Row::new() + Row::new() .with_cell(Lc::t("field-timezone", &LOCALES_USER)) .with_cell(user.timezone.as_deref().unwrap_or("-")), ) .with_row( - table::Row::new() + Row::new() .with_cell(Lc::t("col-status", &LOCALES_USER)) .with_cell(Lc::t(status_key(status), &LOCALES_USER)), ); @@ -552,7 +554,7 @@ async fn user_view_details(user: &user::Model, status: UserStatus, cx: &mut Cont .render(cx) .await; table = table.with_row( - table::Row::new() + Row::new() .with_cell("") .with_cell(Html::with(move |_| badge.clone())), ); @@ -591,10 +593,9 @@ 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=(href) { + a href=(cx.route(format!("{ADMIN_ROLES_PATH}/{id}/view")).to_string()) { (label.as_str()) } } @@ -743,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(user_path(id, "edit"))); + let edit_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{id}/edit"))); if is_htmx { Ok(HtmxResponse::empty().redirect(edit_href).into_response()) } else { @@ -790,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(user_path(id, "edit"))); + let edit_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{id}/edit"))); if is_htmx { Ok(HtmxResponse::empty().redirect(edit_href).into_response()) } else { @@ -815,7 +816,10 @@ 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(user_path(id, "edit"))); + let edit_href = waypoint.append_to( + page.context() + .route(format!("{ADMIN_USERS_PATH}/{id}/edit")), + ); let title = Lc::t("title-admin-user-password", &LOCALES_USER); Ok(page .with_title(title.clone()) @@ -857,12 +861,15 @@ pub(crate) async fn password_post( match result { Ok(()) => { let cx = Context::admin(request); - let edit_href = waypoint.append_to(cx.route(user_path(id, "edit"))); + let edit_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_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(user_path(id, "edit"))); + let edit_href = waypoint.append_to( + page.context() + .route(format!("{ADMIN_USERS_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/handlers/auth.rs b/extensions/pagetop-user/src/handlers/auth.rs index 351880cf..7186eab4 100644 --- a/extensions/pagetop-user/src/handlers/auth.rs +++ b/extensions/pagetop-user/src/handlers/auth.rs @@ -2,6 +2,7 @@ use serde::Deserialize; +use pagetop::auth::CurrentUser; use pagetop::prelude::*; use crate::auth; diff --git a/extensions/pagetop-user/src/lib.rs b/extensions/pagetop-user/src/lib.rs index 781a1cff..3cffd393 100644 --- a/extensions/pagetop-user/src/lib.rs +++ b/extensions/pagetop-user/src/lib.rs @@ -131,16 +131,6 @@ 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/middleware.rs b/extensions/pagetop-user/src/middleware.rs index d72dff07..1ebd09c8 100644 --- a/extensions/pagetop-user/src/middleware.rs +++ b/extensions/pagetop-user/src/middleware.rs @@ -5,7 +5,9 @@ //! si el usuario está autenticado, el [`crate::account::Account`] con sus datos ricos en las //! extensiones de la petición HTTP. -use pagetop::prelude::*; +use pagetop::auth::PermissionRef; +use pagetop::web::middleware::Next; +use pagetop::web::{Request, Response}; use crate::account::Account; use crate::session; @@ -15,10 +17,7 @@ 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: web::Request, - next: web::middleware::Next, -) -> Response { +pub(crate) async fn session_middleware(mut req: Request, next: 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 f7bd3223..5b96bf9a 100644 --- a/extensions/pagetop-user/src/service/role_admin.rs +++ b/extensions/pagetop-user/src/service/role_admin.rs @@ -2,7 +2,8 @@ use std::collections::HashMap; -use pagetop::prelude::*; +use pagetop::datetime::Utc; +use pagetop::html::SortDir; 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 58aa35ed..55ca95ab 100644 --- a/extensions/pagetop-user/src/service/user_admin.rs +++ b/extensions/pagetop-user/src/service/user_admin.rs @@ -2,7 +2,9 @@ use std::collections::HashMap; -use pagetop::prelude::*; +use pagetop::datetime::Utc; +use pagetop::html::SortDir; +use pagetop::util; 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/session.rs b/extensions/pagetop-user/src/session.rs index a7a2b14b..485e3138 100644 --- a/extensions/pagetop-user/src/session.rs +++ b/extensions/pagetop-user/src/session.rs @@ -1,6 +1,8 @@ //! Gestión de sesiones de usuario (creación, carga, destrucción). -use pagetop::prelude::*; +use pagetop::auth::CurrentUser; +use pagetop::datetime::{Duration, Utc}; +use pagetop::web::http::{HeaderMap, header}; use pagetop_seaorm::db::{ ActiveModelTrait, ColumnTrait, DbErr, EntityTrait, QueryFilter, Set, dbconn, }; @@ -33,33 +35,32 @@ 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 cookie = util::join!( - &SETTINGS.session_cookie_name, - "=", - sid, - "; HttpOnly; SameSite=Lax; Path=/" - ); + let mut parts = vec![ + format!("{}={}", SETTINGS.session_cookie_name, sid), + "HttpOnly".into(), + "SameSite=Lax".into(), + "Path=/".into(), + ]; if SETTINGS.secure_cookie { - cookie.push_str("; Secure"); + parts.push("Secure".into()); } if remember { - cookie.push_str("; Max-Age="); - cookie.push_str(&SETTINGS.session_ttl_secs.to_string()); + parts.push(format!("Max-Age={}", SETTINGS.session_ttl_secs)); } - cookie + parts.join("; ") } /// Construye la cookie de expiración (Max-Age=0) para borrar la sesión del navegador. pub fn expiry_cookie() -> String { - util::join!( - &SETTINGS.session_cookie_name, - "=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0" + format!( + "{}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0", + SETTINGS.session_cookie_name ) } /// Extrae el session ID de las cabeceras HTTP de la petición, si existe. -pub fn extract_sid(headers: Option<&web::http::HeaderMap>) -> Option { - let cookie_str = headers?.get(web::http::header::COOKIE)?.to_str().ok()?; +pub fn extract_sid(headers: Option<&HeaderMap>) -> Option { + let cookie_str = headers?.get(header::COOKIE)?.to_str().ok()?; let name = SETTINGS.session_cookie_name.as_str(); for part in cookie_str.split(';') { @@ -77,7 +78,7 @@ pub fn extract_sid(headers: Option<&web::http::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: &web::http::HeaderMap) -> (CurrentUser, Option) { +pub async fn resolve_session(headers: &HeaderMap) -> (CurrentUser, Option) { let Some(sid) = extract_sid(Some(headers)) else { return (CurrentUser::Anonymous, None); }; diff --git a/extensions/pagetop-user/src/token.rs b/extensions/pagetop-user/src/token.rs index 611bca03..4da794a5 100644 --- a/extensions/pagetop-user/src/token.rs +++ b/extensions/pagetop-user/src/token.rs @@ -1,7 +1,9 @@ //! Generación y verificación de tokens de un solo uso (reset de contraseña, verificación de //! email...). -use pagetop::prelude::*; +use pagetop::datetime::{Duration, Utc}; +use sha2::{Digest, Sha256}; + use pagetop_seaorm::db::{ ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter, Set, dbconn, }; @@ -9,8 +11,6 @@ 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). diff --git a/helpers/pagetop-minimal/Cargo.toml b/helpers/pagetop-minimal/Cargo.toml index c67e7587..dfb37a9d 100644 --- a/helpers/pagetop-minimal/Cargo.toml +++ b/helpers/pagetop-minimal/Cargo.toml @@ -16,5 +16,6 @@ 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 162bce18..b7a17bc0 100644 --- a/helpers/pagetop-minimal/README.md +++ b/helpers/pagetop-minimal/README.md @@ -28,6 +28,11 @@ 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 14bd4211..eab70c84 100644 --- a/helpers/pagetop-minimal/src/lib.rs +++ b/helpers/pagetop-minimal/src/lib.rs @@ -29,6 +29,11 @@ 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). @@ -38,6 +43,9 @@ 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. @@ -52,17 +60,11 @@ 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 varios fragmentos en un [`String`] reservando una sola vez la memoria del resultado. +/// Concatena eficientemente varios fragmentos en un [`String`]. /// -/// 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!`. +/// 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. /// /// # Ejemplo /// @@ -79,26 +81,11 @@ 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),+ $(,)?) => { - [$(::core::convert::AsRef::::as_ref(&$arg)),+].concat() + ($($arg:expr),+) => { + $crate::concat_string!($($arg),+) }; } @@ -139,15 +126,15 @@ macro_rules! join_pair { let second_val = $second; let separator_val = $separator; - let first = ::core::convert::AsRef::::as_ref(&first_val); - let second = ::core::convert::AsRef::::as_ref(&second_val); + let first = AsRef::::as_ref(&first_val); + let second = AsRef::::as_ref(&second_val); let separator = if first.is_empty() || second.is_empty() { "" } else { - ::core::convert::AsRef::::as_ref(&separator_val) + AsRef::::as_ref(&separator_val) }; - [first, separator, second].concat() + $crate::concat_string!(first, separator, second) }}; } diff --git a/src/base/component.rs b/src/base/component.rs index 1365c704..807c57e2 100644 --- a/src/base/component.rs +++ b/src/base/component.rs @@ -47,9 +47,8 @@ pub mod image; #[doc(inline)] pub use image::Image; -pub mod intro; -#[doc(inline)] -pub use intro::Intro; +mod intro; +pub use intro::{Intro, IntroOpening}; mod messages; pub use messages::Messages; diff --git a/src/base/component/container.rs b/src/base/component/container.rs index 08e44934..a33ad79f 100644 --- a/src/base/component/container.rs +++ b/src/base/component/container.rs @@ -25,61 +25,28 @@ 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`]. Opcionalmente, su ancho se controla con [`Width`] mediante -/// [`with_width()`](Self::with_width); sin él, el contenedor no añade ninguna clase de ancho. +/// los tipos definidos en [`Kind`]. /// /// Si no contiene elementos, el componente **no se renderiza**. /// -/// # Ejemplos +/// # Ejemplo /// /// ```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, } @@ -94,25 +61,6 @@ 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; @@ -187,15 +135,6 @@ 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/src/base/component/intro.rs b/src/base/component/intro.rs index e7403eae..00fa866b 100644 --- a/src/base/component/intro.rs +++ b/src/base/component/intro.rs @@ -1,7 +1,292 @@ -//! Definiciones para crear introducciones ([`Intro`]). +use crate::prelude::*; -mod props; -pub use props::{Kind, Width}; +/// 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 component; -pub use component::Intro; +/// 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 + } +} diff --git a/src/base/component/intro/component.rs b/src/base/component/intro/component.rs deleted file mode 100644 index 52a80439..00000000 --- a/src/base/component/intro/component.rs +++ /dev/null @@ -1,320 +0,0 @@ -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 deleted file mode 100644 index b5f5cad3..00000000 --- a/src/base/component/intro/props.rs +++ /dev/null @@ -1,60 +0,0 @@ -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 9792877a..b951e52b 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, layout}; +use crate::base::component::{Html, Intro, IntroOpening, layout}; use crate::core::component::{ChildOp, Component, ComponentError, ComponentRender}; use crate::core::component::{Context, Contextual}; use crate::core::extension::Extension; @@ -413,9 +413,11 @@ pub trait Theme: Extension + Send + Sync { page.alter_title(title).alter_child_in( &CoreRegions::Content, ChildOp::Prepend( - Intro::custom() + Intro::new() .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)) } diff --git a/tests/component_container.rs b/tests/component_container.rs index 7a3374ee..95f6831a 100644 --- a/tests/component_container.rs +++ b/tests/component_container.rs @@ -31,83 +31,3 @@ 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")); -}