Compare commits

..

4 commits

Author SHA1 Message Date
16eec316c4 ♻️ (base): Reorganiza el componente Intro 2026-09-20 23:53:18 +02:00
404739a3fd ♻️ Importa PageTop usando el prelude 2026-09-20 18:33:37 +02:00
8651b91ab2 ♻️ (minimal): Reimplementa join! y join_pair!
Usa `[&str]::concat()` de la biblioteca estándar y elimina la
dependencia de `concat-string`. Cada argumento se evalúa una sola vez y
no se consume.
2026-09-20 18:18:51 +02:00
c6cd4fcd7d ✨ (base): Añade with_width() a Container 2026-09-19 03:14:21 +02:00
67 changed files with 965 additions and 728 deletions

8
Cargo.lock generated
View file

@ -396,12 +396,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "concat-string"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7439becb5fafc780b6f4de382b1a7a3e70234afe783854a4702ee8adbb838609"
[[package]]
name = "concurrent-queue"
version = "2.5.0"
@ -1881,7 +1875,6 @@ dependencies = [
name = "pagetop-menu"
version = "0.1.0"
dependencies = [
"chrono",
"pagetop",
"pagetop-seaorm",
"serde",
@ -1892,7 +1885,6 @@ dependencies = [
name = "pagetop-minimal"
version = "0.1.0"
dependencies = [
"concat-string",
"indoc",
"pastey",
]

View file

@ -31,7 +31,6 @@ base64ct = { version = "1.8", features = ["alloc"] }
change-detection = "1.2"
chrono = "0.4"
colored = "3.1"
concat-string = "1.0"
config = { version = "0.15", default-features = false, features = ["toml"] }
figlet-rs = "1.0"
fluent-templates = "0.14"

View file

@ -44,7 +44,7 @@ body {
-webkit-tap-highlight-color: transparent;
}
/* Header, Aside, Content y Footer se apilan en el mismo orden ocupando todo el ancho disponible. */
/* Header, Aside, Content and Footer are stacked in that order, using the full available width. */
.wrapper {
display: flex;
flex-direction: column;
@ -125,10 +125,10 @@ body {
text-decoration: underline;
}
/* Geometría base de .dropdown-button/.dropdown-toggle (ver "Dropdown component" más abajo). Se
* declara aquí, antes de "Buttons", para que las clases .button-sm/.button-lg/.button-* ganen el
* empate de especificidad frente a ella cuando se combinan en el mismo elemento (CSS resuelve un
* empate por orden de aparición en el fichero, no por el orden de las clases en el HTML). */
/* Base geometry of .dropdown-button/.dropdown-toggle (see "Dropdown component" below). It is
* declared here, before "Buttons", so that the .button-sm/.button-lg/.button-* classes win the
* specificity tie against it when combined on the same element (CSS resolves a tie by order of
* appearance in the file, not by the order of the classes in the HTML). */
.dropdown-button,
.dropdown-toggle {
padding: 0.5rem 0.75rem;
@ -247,6 +247,79 @@ body {
gap: 0.5rem;
}
/*
* Container component
*/
/* Classes added by the `Container` component from `Width`. All of them are centered and fill the
* available width; `.container` and `.container-{name}` also get a maximum width from the
* breakpoint they name (`.container` from the first one). The minimum widths match the default
* `Theme::breakpoint_entry()` table, so a child theme that renames or resizes the breakpoints must
* also redefine these classes. */
.container,
.container-fluid,
.container-sm,
.container-md,
.container-lg,
.container-xl,
.container-xxl,
.container-xxxl {
width: 100%;
margin-inline: auto;
padding-inline: 1rem;
}
@media (min-width: 576px) {
.container,
.container-sm {
max-width: 540px;
}
}
@media (min-width: 768px) {
.container,
.container-sm,
.container-md {
max-width: 720px;
}
}
@media (min-width: 992px) {
.container,
.container-sm,
.container-md,
.container-lg {
max-width: 960px;
}
}
@media (min-width: 1200px) {
.container,
.container-sm,
.container-md,
.container-lg,
.container-xl {
max-width: 1140px;
}
}
@media (min-width: 1400px) {
.container,
.container-sm,
.container-md,
.container-lg,
.container-xl,
.container-xxl {
max-width: 1320px;
}
}
@media (min-width: 1920px) {
.container,
.container-sm,
.container-md,
.container-lg,
.container-xl,
.container-xxl,
.container-xxxl {
max-width: 1800px;
}
}
/*
* Dialog component
*/
@ -318,10 +391,10 @@ body {
display: inline-block;
}
/* La geometría base de .dropdown-button/.dropdown-toggle vive junto a "Buttons" (antes de esta
* sección), para que las clases .button-sm/.button-lg/.button-* ganen el empate de especificidad
* frente a ella (CSS resuelve un empate por orden de aparición en el fichero, no por el orden de
* las clases en el HTML). */
/* The base geometry of .dropdown-button/.dropdown-toggle lives next to "Buttons" (before this
* section), so that the .button-sm/.button-lg/.button-* classes win the specificity tie against it
* (CSS resolves a tie by order of appearance in the file, not by the order of the classes in the
* HTML). */
.dropdown-toggle::after {
content: "\25BC";
display: inline-block;
@ -928,9 +1001,9 @@ input:disabled + label {
color: var(--val-color--text--muted);
}
/* .page-link-icon muestra el texto de navegación de los botones anterior/siguiente. El texto
accesible completo va aparte, en el aria-label del enlace. Por eso sustituir el texto visible por
otro contenido no afecta a la accesibilidad, p. ej. para usar "‹"/"›" se puede aplicar:
/* .page-link-icon shows the navigation text of the previous/next buttons. The full accessible text
goes separately, in the link's aria-label. So replacing the visible text with other content does
not affect accessibility, e.g. to use "‹"/"›" you can apply:
.page-link-icon {
display: inline-block;

View file

@ -22,6 +22,10 @@ body {
overflow-x: clip;
}
/*
* Media queries: 768px and 992px match the default `Md` and `Lg` breakpoints of the core.
*/
.intro {
position: relative;
color: var(--intro-color);
@ -44,20 +48,6 @@ body {
text-align: center;
}
.intro a {
color: currentColor;
text-decoration: underline;
transition: font-size 0.2s, text-decoration-color 0.2s;
}
.intro a:focus-visible {
outline: var(--intro-focus-outline);
outline-offset: var(--intro-focus-outline-offset);
}
.intro a:hover,
.intro a:hover:visited {
text-decoration-color: var(--intro-color-link);
}
/*
* Intro Header
*/
@ -117,7 +107,7 @@ body {
width: 280px;
height: 280px;
}
@media (min-width: 64rem) {
@media (width >= 992px) {
.intro-header {
background-image: var(--intro-bg-img);
background-image: var(--intro-bg-img-set);
@ -166,12 +156,12 @@ body {
.intro-content-body:after {
bottom: -1rem;
}
@media (width <= 48rem) {
@media (width < 768px) {
.intro-content-body {
margin-top: -9.8rem;
}
}
@media (width >= 64rem) {
@media (width >= 992px) {
.intro-content {
margin-top: 0;
}
@ -208,6 +198,10 @@ body {
min-height: 7.6875rem;
outline: none;
}
.intro-button-link:focus-visible {
outline: var(--intro-focus-outline);
outline-offset: var(--intro-focus-outline-offset);
}
.intro-button-link::before {
content: '';
position: absolute;
@ -310,7 +304,7 @@ body {
.intro-button-link:hover span {
animation-play-state: paused;
}
@media (width <= 48rem) {
@media (width < 768px) {
.intro-header {
padding-bottom: 9rem;;
}
@ -324,7 +318,7 @@ body {
padding-top: .5rem;
}
}
@media (width > 48rem) {
@media (width >= 768px) {
.intro-button {
position: absolute;
top: 0;
@ -361,12 +355,12 @@ body {
font-size: 1.5rem;
margin: 0 0 1.5rem;
}
@media (width > 48rem) {
@media (width >= 768px) {
.intro-button + .intro-text-body {
padding-top: 7rem;
}
}
@media (width >= 64rem) {
@media (width >= 992px) {
.intro-header {
padding-bottom: 9rem;;
}
@ -376,9 +370,18 @@ body {
}
.intro-text {
box-shadow: var(--intro-shadow);
max-width: 60rem;
max-width: min(60rem, 100vw - 4rem);
margin: 0 auto 6rem;
}
.intro-text-narrow {
max-width: min(50rem, 100vw - 4rem);
}
.intro-text-wide {
max-width: min(67rem, 100vw - 4rem);
}
.intro-text-full {
max-width: min(74rem, 100vw - 4rem);
}
.intro-text-body {
padding-left: 4.5rem;
padding-right: 4.5rem;
@ -479,6 +482,20 @@ body {
.intro-footer-body a:visited {
color: var(--intro-color-gray);
}
/* The footer has its own dark surface, so its links don't inherit the theme's link colors. */
.intro-footer-links a {
color: currentColor;
text-decoration: underline;
transition: font-size 0.2s, text-decoration-color 0.2s;
}
.intro-footer-links a:focus-visible {
outline: var(--intro-focus-outline);
outline-offset: var(--intro-focus-outline-offset);
}
.intro-footer-links a:hover,
.intro-footer-links a:hover:visited {
text-decoration-color: var(--intro-color-link);
}
.intro-footer-logo,
.intro-footer-links {
display: flex;
@ -496,12 +513,12 @@ body {
flex-wrap: wrap;
margin-top: 2rem;
}
@media (width <= 48rem) {
@media (width < 768px) {
.intro-footer-logo {
display: none;
}
}
@media (width <= 64rem) {
@media (width < 992px) {
.intro-footer-body {
padding: 0 1rem;
}

View file

@ -19,11 +19,9 @@ impl Extension for FormControls {
async fn form_controls(request: HttpRequest) -> Result<Markup, ErrorPage> {
Page::new(request)
.with_child(
Intro::default()
.with_opening(IntroOpening::Custom)
Intro::custom()
.with_title(Lc::t("title", &LOC))
.with_slogan(Lc::t("slogan", &LOC))
.with_button(None::<(Lc, Route)>)
// Bloque 1: casillas, interruptores y botones de opción.
.with_child(
Block::new()

View file

@ -14,11 +14,9 @@ impl Extension for IntroColors {
async fn intro_colors(request: HttpRequest) -> Result<Markup, ErrorPage> {
Page::new(request)
.with_child(
Intro::default()
.with_opening(IntroOpening::Custom)
Intro::custom()
.with_title(Lc::n("PageTop"))
.with_slogan(Lc::t("colors_slogan", &LOC))
.with_button(None::<(Lc, Route)>)
.with_child(
Block::new()
.with_title(Lc::t("colors_block", &LOC).with_arg("n", "1"))

View file

@ -20,11 +20,9 @@ async fn intro_flex(request: HttpRequest) -> Result<Markup, ErrorPage> {
.with_assets(demo_box_styles())
.with_assets(demo_row_styles())
.with_child(
Intro::default()
.with_opening(IntroOpening::Custom)
Intro::custom()
.with_title(Lc::n("PageTop"))
.with_slogan(Lc::t("flex_slogan", &LOC))
.with_button(None::<(Lc, Route)>)
.with_child(direction_block())
.with_child(justify_block())
.with_child(align_block())

View file

@ -20,11 +20,9 @@ async fn intro_grid(request: HttpRequest) -> Result<Markup, ErrorPage> {
.with_assets(demo_box_styles())
.with_assets(demo_row_styles())
.with_child(
Intro::default()
.with_opening(IntroOpening::Custom)
Intro::custom()
.with_title(Lc::n("PageTop"))
.with_slogan(Lc::t("grid_slogan", &LOC))
.with_button(None::<(Lc, Route)>)
.with_child(columns_block())
.with_child(rows_gap_block())
.with_child(placement_block())
@ -472,7 +470,8 @@ fn layout_block() -> Block {
// **< HELPERS >************************************************************************************
// Aspecto fijo de las cajas de muestra: esquinas rectas y borde claro, a diferencia de las cajas
// redondeadas de `intro-flex.rs`, para distinguir a simple vista los ejemplos de Grid de los de Flex.
// redondeadas de `intro-flex.rs`, para distinguir a simple vista los ejemplos de Grid de los de
// Flex.
fn demo_box_styles() -> AssetsOp {
AssetsOp::add_responsive_styles(
None,

View file

@ -21,11 +21,9 @@ async fn intro_responsive(request: HttpRequest) -> Result<Markup, ErrorPage> {
.with_assets(demo_row_styles())
.with_assets(demo_code_styles())
.with_child(
Intro::default()
.with_opening(IntroOpening::Custom)
Intro::custom()
.with_title(Lc::n("PageTop"))
.with_slogan(Lc::t("responsive_slogan", &LOC))
.with_button(None::<(Lc, Route)>)
.with_child(Html::with(|cx| {
html! {
p class="intro-text-lead" {

View file

@ -21,11 +21,9 @@ async fn intro_spacing(request: HttpRequest) -> Result<Markup, ErrorPage> {
.with_assets(demo_row_styles())
.with_assets(demo_code_styles())
.with_child(
Intro::default()
.with_opening(IntroOpening::Custom)
Intro::custom()
.with_title(Lc::n("PageTop"))
.with_slogan(Lc::t("spacing_slogan", &LOC))
.with_button(None::<(Lc, Route)>)
.with_child(Html::with(|cx| {
html! {
p class="intro-text-lead" {

View file

@ -1,7 +1,7 @@
menus_item_label = Etiqueta
menus_item_link = Enlace
menus_item_blank = Enlace externo
menus_item_disabled = Enlace deshabilitado
menus_item_blank = Externo
menus_item_disabled = Deshabilitado
menus_test_title = Desplegable

View file

@ -1,5 +1,4 @@
use pagetop::prelude::*;
use pagetop_bootsier::theme::*;
include_locales!(LOC from "examples/locale");
@ -16,87 +15,84 @@ impl Extension for SuperMenu {
}
async fn initialize(&self) {
let navbar_menu = bs::Navbar::brand_left(bs::navbar::Brand::new())
.with_expand(BreakPoint::LG)
.with_item(bs::navbar::Item::nav(
bs::Nav::new()
.with_item(bs::nav::Item::link(Lc::t("menus_item_link", &LOC), "/"))
.with_item(bs::nav::Item::link_blank(
let navbar_menu = Navbar::brand_left(Brand::new())
.with_expand(Breakpoint::Lg)
.with_item(navbar::Item::nav(
Nav::new()
.with_item(nav::Item::link(Lc::t("menus_item_link", &LOC), "/"))
.with_item(nav::Item::link_blank(
Lc::t("menus_item_blank", &LOC),
"https://docs.rs/pagetop",
))
.with_item(bs::nav::Item::dropdown(
bs::Dropdown::new()
.with_item(nav::Item::dropdown(
Dropdown::new()
.with_title(Lc::t("menus_test_title", &LOC))
.with_item(bs::dropdown::Item::header(Lc::t("menus_dev_header", &LOC)))
.with_item(bs::dropdown::Item::link(
.with_item(dropdown::Item::header(Lc::t("menus_dev_header", &LOC)))
.with_item(dropdown::Item::link(
Lc::t("menus_dev_getting_started", &LOC),
"/dev/getting-started",
))
.with_item(bs::dropdown::Item::link(
.with_item(dropdown::Item::link(
Lc::t("menus_dev_guides", &LOC),
"/dev/guides",
))
.with_item(bs::dropdown::Item::link_blank(
.with_item(dropdown::Item::link_blank(
Lc::t("menus_dev_forum", &LOC),
"https://forum.example.dev",
))
.with_item(bs::dropdown::Item::divider())
.with_item(bs::dropdown::Item::header(Lc::t("menus_sdk_header", &LOC)))
.with_item(bs::dropdown::Item::link(
.with_item(dropdown::Item::divider())
.with_item(dropdown::Item::header(Lc::t("menus_sdk_header", &LOC)))
.with_item(dropdown::Item::link(
Lc::t("menus_sdk_rust", &LOC),
"/dev/sdks/rust",
))
.with_item(bs::dropdown::Item::link(
.with_item(dropdown::Item::link(
Lc::t("menus_sdk_js", &LOC),
"/dev/sdks/js",
))
.with_item(bs::dropdown::Item::link(
.with_item(dropdown::Item::link(
Lc::t("menus_sdk_python", &LOC),
"/dev/sdks/python",
))
.with_item(bs::dropdown::Item::divider())
.with_item(bs::dropdown::Item::header(Lc::t(
"menus_plugin_header",
&LOC,
)))
.with_item(bs::dropdown::Item::link(
.with_item(dropdown::Item::divider())
.with_item(dropdown::Item::header(Lc::t("menus_plugin_header", &LOC)))
.with_item(dropdown::Item::link(
Lc::t("menus_plugin_auth", &LOC),
"/dev/sdks/rust/plugins/auth",
))
.with_item(bs::dropdown::Item::link(
.with_item(dropdown::Item::link(
Lc::t("menus_plugin_cache", &LOC),
"/dev/sdks/rust/plugins/cache",
))
.with_item(bs::dropdown::Item::divider())
.with_item(bs::dropdown::Item::label(Lc::t("menus_item_label", &LOC)))
.with_item(bs::dropdown::Item::link_disabled(
.with_item(dropdown::Item::divider())
.with_item(dropdown::Item::label(Lc::t("menus_item_label", &LOC)))
.with_item(dropdown::Item::link_disabled(
Lc::t("menus_item_disabled", &LOC),
"#",
)),
))
.with_item(bs::nav::Item::link_disabled(
.with_item(nav::Item::link_disabled(
Lc::t("menus_item_disabled", &LOC),
"#",
)),
))
.with_item(bs::navbar::Item::nav(
bs::Nav::new()
.with_item(navbar::Item::nav(
Nav::new()
// Empuja este menú (y lo que le siga) al extremo final de la barra.
.with_prop(FlexItem::push_end())
.with_item(bs::nav::Item::link(
.with_item(nav::Item::link(
Lc::t("menus_item_sign_up", &LOC),
"/auth/sign-up",
))
.with_item(bs::nav::Item::link(
.with_item(nav::Item::link(
Lc::t("menus_item_login", &LOC),
"/auth/login",
)),
));
InRegion::Global(&CoreRegions::Header).add(
bs::Container::new()
.with_width(bs::container::Width::FluidMax(UnitValue::RelRem(75.0)))
Container::new()
.with_width(container::Width::FluidMax(UnitValue::RelRem(75.0)))
.with_child(navbar_menu),
);
}

View file

@ -1,4 +1,3 @@
use pagetop::base::component::breadcrumb;
use pagetop::prelude::*;
use crate::LOCALES_ADMIN;

View file

@ -1,7 +1,6 @@
use pagetop::prelude::*;
use pagetop_seaorm::db::*;
use pagetop::datetime::NaiveDateTime;
/// Entidad SeaORM para la tabla `settings`.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "settings")]
@ -14,7 +13,7 @@ pub struct Model {
pub updated_by: Option<i32>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -2,8 +2,7 @@
//!
//! Proporciona una API async para leer y escribir valores JSON en la tabla `settings`.
use pagetop::datetime::Utc;
use pagetop::{Getters, builder_impl};
use pagetop::prelude::*;
use pagetop_seaorm::db::{
ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter, dbconn,
};
@ -231,6 +230,6 @@ impl SettingsSchema {
/// Devuelve la clave completa de un campo: `"{scope}.{field_name}"`.
pub fn key_for(&self, field_name: &str) -> String {
format!("{}.{}", self.scope, field_name)
util::join!(&self.scope, ".", field_name)
}
}

View file

@ -146,12 +146,12 @@ impl Theme for Bootsier {
theme::BootsierColors::from(intent).as_str()
}
fn setup_component(&self, component: &mut dyn Component, cx: &mut Context) {
fn setup_component(&self, component: &mut dyn Component, _cx: &mut Context) {
setup_component!(component, {
Badge => |c| theme::bs::badge::setup(c),
Brand => |c| theme::bs::brand::setup(c),
Button => |c| theme::bs::button::setup(c),
Container => |c| theme::bs::container::setup(c, cx),
Container => |c| theme::bs::container::setup(c),
Dialog => |c| theme::bs::dialog::setup(c),
Dropdown => |c| theme::bs::dropdown::setup(c),
Image => |c| theme::bs::image::setup(c),

View file

@ -103,8 +103,6 @@ pub use bs::badge::BadgeBootsier;
#[doc(hidden)]
pub use bs::button::ButtonBootsier;
#[doc(hidden)]
pub use bs::container::ContainerBootsier;
#[doc(hidden)]
pub use bs::dropdown::DropdownBootsier;
#[doc(hidden)]
pub use bs::form::input::InputBootsier;

View file

@ -27,8 +27,6 @@ pub use button::{Button, ButtonBootsier};
pub mod container;
#[doc(inline)]
pub use container::Container;
#[doc(inline)]
pub use container::ContainerBootsier;
// Dialog.
pub mod dialog;

View file

@ -1,108 +1,37 @@
//! Definiciones para crear contenedores de componentes ([`Container`]).
//!
//! El comportamiento del ancho del contenedor se establece con [`Container::with_width()`], usando
//! [`Width`]. Sin él, Bootsier aplica los anchos máximos predefinidos de Bootstrap para cada punto
//! de corte ([`Width::Responsive`]).
//!
//! También se pueden aplicar al componente clases predefinidas para:
//!
//! - Modificar el color de fondo ([`Bg`](crate::theme::class::Bg)).
//! - Definir la apariencia del texto ([`Text`](crate::theme::class::Text)).
//! - Establecer bordes ([`Border`](crate::theme::class::Border)).
//! - Redondear las esquinas ([`Rounded`](crate::theme::class::Rounded)).
//!
//! ```rust,no_run
//! use pagetop::prelude::*;
//! use pagetop_bootsier::theme::*;
//!
//! let main = bs::Container::main()
//! .with_id("main-page")
//! .with_width(bs::container::Width::From(Breakpoint::Lg))
//! .with_prop(PropsOp::add_classes(class::Bg::with(BootsierColors::Light)))
//! .with_prop(PropsOp::add_classes(class::Text::with(BootsierColors::Dark)))
//! .with_prop(PropsOp::add_classes(class::Border::with(ScaleSize::One)))
//! .with_prop(PropsOp::add_classes(class::Rounded::new()));
//! ```
use pagetop::prelude::*;
use crate::theme::*;
pub use pagetop::base::component::container::{Container, Kind};
const EXTRA_WIDTH: &str = "bootsier.container.width";
/// Extensión de Bootsier para [`Container`].
///
/// Permite establecer el comportamiento del ancho del contenedor usando el método
/// [`with_width()`](Self::with_width).
///
/// También habilita al componente para aceptar clases predefinidas para:
///
/// - Modificar el color de fondo ([`Bg`](crate::theme::class::Bg)).
/// - Definir la apariencia del texto ([`Text`](crate::theme::class::Text)).
/// - Establecer bordes ([`Border`](crate::theme::class::Border)).
/// - Redondear las esquinas ([`Rounded`](crate::theme::class::Rounded)).
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_bootsier::theme::*;
///
/// let main = bs::Container::main()
/// .with_id("main-page")
/// .with_width(bs::container::Width::From(Breakpoint::Lg))
/// .with_prop(PropsOp::add_classes(class::Bg::with(BootsierColors::Light)))
/// .with_prop(PropsOp::add_classes(class::Text::with(BootsierColors::Dark)))
/// .with_prop(PropsOp::add_classes(class::Border::with(ScaleSize::One)))
/// .with_prop(PropsOp::add_classes(class::Rounded::new()));
/// ```
#[builder_impl]
pub trait ContainerBootsier {
/// Establece el comportamiento del ancho para el contenedor.
///
/// Determina si el contenedor aplica los anchos máximos predefinidos para cada punto de corte,
/// o si ocupa siempre el 100% del ancho disponible, o lo hace hasta un ancho máximo explícito.
/// Ver [`Width`] para las variantes disponibles.
fn with_width(self, width: Width) -> Self;
}
#[builder_impl]
impl ContainerBootsier for Container {
fn with_width(mut self, width: Width) -> Self {
self.alter_prop(PropsOp::set_extra(EXTRA_WIDTH, width));
self
}
}
// **< Width >**************************************************************************************
/// Define cómo se comporta el ancho de un contenedor ([`Container`]).
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum Width {
/// Comportamiento por defecto, aplica los anchos máximos predefinidos para cada punto de corte.
/// Por debajo del menor punto de corte ocupa el 100% del ancho disponible.
#[default]
Default,
/// Aplica los anchos máximos predefinidos a partir del punto de corte indicado. Por debajo de
/// ese punto de corte ocupa el 100% del ancho disponible.
From(Breakpoint),
/// Ocupa el 100% del ancho disponible siempre.
Fluid,
/// Ocupa el 100% del ancho disponible hasta un ancho máximo explícito.
FluidMax(UnitValue),
}
impl Width {
const CONTAINER: &str = "container";
/// Añade la clase asociada al ancho del contenedor a la cadena de clases.
///
/// El nombre del punto de corte se resuelve en el tema activo de `cx`.
#[inline]
pub fn push_to(self, cx: &Context, classes: &mut String) {
match self {
Self::Default => {
push_breakpoint_class(cx, Breakpoint::Xs, classes, Self::CONTAINER, "")
}
Self::From(bp) => push_breakpoint_class(cx, bp, classes, Self::CONTAINER, ""),
Self::Fluid | Self::FluidMax(_) => {
push_breakpoint_class(cx, Breakpoint::Xs, classes, Self::CONTAINER, "fluid")
}
}
}
/// Devuelve la clase asociada al ancho del contenedor.
pub fn to_class(self, cx: &Context) -> String {
let mut class = String::new();
self.push_to(cx, &mut class);
class
}
}
pub use pagetop::base::component::container::{Container, Kind, Width};
// **< Container SETUP >****************************************************************************
pub(crate) fn setup(container: &mut Container, cx: &Context) {
let width = container.props().extra_or(EXTRA_WIDTH, Width::default());
container.alter_prop(PropsOp::prepend_classes(width.to_class(cx)));
if let Width::FluidMax(w) = width
&& w.is_measurable()
{
container.alter_prop(PropsOp::add_style("max-width", w.to_string()));
pub(crate) fn setup(container: &mut Container) {
if container.width().is_none() {
container.alter_prop(PropsOp::prepend_classes("container"));
}
}

View file

@ -202,7 +202,7 @@ impl MenuPosition {
#[inline]
pub(crate) fn data_offset(self) -> Option<String> {
match self {
Self::Offset(x, y) => Some(format!("{x},{y}")),
Self::Offset(x, y) => Some(util::join!(x.to_string(), ",", y.to_string())),
_ => None,
}
}

View file

@ -1,7 +1,7 @@
use pagetop::prelude::*;
use crate::config;
use crate::theme::{ContainerBootsier, bs};
use crate::theme::bs;
use crate::{ADMINLTE_VERSION, LOCALES_BOOTSIER};
// Regiones de Bootsier: se renderiza sin el `<div role="region">` envolvente que aplica

View file

@ -1,5 +1,6 @@
// Verifies that Bootsier builds its breakpoint-based Bootstrap classes from the breakpoint names
// of `Bootsier::breakpoint_entry()`, and that `Xxxl` (absent in Bootstrap) resolves as `xxl`.
// Verifies that the breakpoint-based Bootstrap classes of `Container`, `Navbar`, `Dropdown` and
// `Offcanvas` use the breakpoint names of `Bootsier::breakpoint_entry()`, and that `Xxxl` (absent
// in Bootstrap) resolves as `xxl`.
use pagetop::prelude::*;
use pagetop_bootsier::theme::*;
@ -8,23 +9,43 @@ fn cx() -> Context {
Context::default().with_theme(&pagetop_bootsier::Bootsier)
}
async fn container_html(width: impl Into<Option<bs::container::Width>>) -> String {
let mut container = bs::Container::new()
.with_width(width)
.with_child(Html::with(|_| html! { p { "Content" } }));
container.render(&mut cx()).await.into_string()
}
#[pagetop::test]
async fn container_without_width_gets_the_bootstrap_default() {
assert!(container_html(None).await.contains(r#"class="container""#));
assert!(
container_html(bs::container::Width::Responsive)
.await
.contains(r#"class="container""#)
);
}
#[pagetop::test]
async fn container_width_uses_breakpoint_name() {
let cx = cx();
assert_eq!(bs::container::Width::Default.to_class(&cx), "container");
assert_eq!(bs::container::Width::Fluid.to_class(&cx), "container-fluid");
assert_eq!(
bs::container::Width::From(Breakpoint::Lg).to_class(&cx),
"container-lg"
assert!(
container_html(bs::container::Width::Fluid)
.await
.contains(r#"class="container-fluid""#)
);
assert!(
container_html(bs::container::Width::From(Breakpoint::Lg))
.await
.contains(r#"class="container-lg""#)
);
}
#[pagetop::test]
async fn container_width_xxxl_resolves_as_xxl() {
let cx = cx();
assert_eq!(
bs::container::Width::From(Breakpoint::Xxxl).to_class(&cx),
"container-xxl"
assert!(
container_html(bs::container::Width::From(Breakpoint::Xxxl))
.await
.contains(r#"class="container-xxl""#)
);
}

View file

@ -7,7 +7,7 @@
//! # Atributos estáticos en `html!`
//!
//! Para valores conocidos en tiempo de compilación, los atributos `hx-*` pueden escribirse
//! directamente en la macro `html!` sin necesidad de [`Props`](pagetop::html::Props):
//! directamente en la macro `html!` sin necesidad de [`Props`]:
//!
//! ```rust,no_run
//! use pagetop::prelude::*;
@ -17,10 +17,10 @@
//! };
//! ```
//!
//! # Atributos dinámicos con [`Props`](pagetop::html::Props)
//! # Atributos dinámicos con [`Props`]
//!
//! Cuando los valores se construyen en tiempo de ejecución o se inyectan desde una extensión,
//! puedes usar [`Props`](pagetop::html::Props) combinado con las constantes de este módulo:
//! puedes usar [`Props`] combinado con las constantes de este módulo:
//!
//! ```rust,no_run
//! # use pagetop::prelude::*;
@ -81,6 +81,8 @@
//! .with_prop(PropsOp::set(hx::on_htmx("after-swap"), "console.log('done')"));
//! ```
use pagetop::prelude::*;
// **< HTTP Methods >*******************************************************************************
/// Realiza una petición GET al servidor y aplica la respuesta al objetivo.
@ -356,7 +358,7 @@ pub const DISABLE: &str = "hx-disable";
/// .with_prop(PropsOp::set(hx::on("mouseenter"), "this.style.opacity='0.8'"));
/// ```
pub fn on(event: &str) -> String {
format!("hx-on:{event}")
util::join!("hx-on:", event)
}
/// Genera `hx-on::{event}` para escuchar eventos propios de HTMX en línea.
@ -372,7 +374,7 @@ pub fn on(event: &str) -> String {
/// .with_prop(PropsOp::set(hx::on_htmx("after-swap"), "initTooltips()"));
/// ```
pub fn on_htmx(event: &str) -> String {
format!("hx-on::{event}")
util::join!("hx-on::", event)
}
// **< HTMX Request Headers >***********************************************************************

View file

@ -14,6 +14,5 @@ authors.workspace = true
[dependencies]
pagetop.workspace = true
pagetop-seaorm.workspace = true
chrono.workspace = true
serde.workspace = true
thiserror.workspace = true

View file

@ -1,4 +1,3 @@
use pagetop::base::component::breadcrumb;
use pagetop::prelude::*;
use crate::tree::{MenuKey, MenuNode, TreeOptions, build_tree, try_resolve_menu_url};

View file

@ -1,7 +1,6 @@
use pagetop::prelude::*;
use pagetop_seaorm::db::*;
use chrono::NaiveDateTime;
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
#[sea_orm(table_name = "menus")]
pub struct Model {

View file

@ -1,7 +1,6 @@
use pagetop::prelude::*;
use pagetop_seaorm::db::*;
use chrono::NaiveDateTime;
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
#[sea_orm(table_name = "menu_items")]
pub struct Model {

View file

@ -1,8 +1,6 @@
//! Operaciones de base de datos para menús e ítems de menú.
use chrono::Utc;
use pagetop::locale::Locale;
use pagetop::prelude::*;
use pagetop_seaorm::db::{
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, EntityTrait, QueryFilter, Set, dbconn,
};

View file

@ -2,7 +2,6 @@
use std::collections::HashMap;
use pagetop::locale::{Locale, RequestLocale};
use pagetop::prelude::*;
use crate::cache::FlatMenu;

View file

@ -49,7 +49,7 @@
//! pub name: String,
//! }
//!
//! #[derive(Clone, Copy, Debug, EnumIter, DeriveRelation)]
//! #[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)]
//! pub enum Relation {}
//!
//! // `DeriveEntityModel` genera también `ActiveModel`, `Entity`, `Column` y `PrimaryKey`.
@ -468,7 +468,7 @@ impl<T> Paginated<T> {
/// pub email: String,
/// }
///
/// #[derive(Clone, Copy, Debug, EnumIter, DeriveRelation)]
/// #[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)]
/// pub enum Relation {}
///
/// impl ActiveModelBehavior for ActiveModel {}

View file

@ -4,7 +4,7 @@ use std::future::Future;
use std::pin::Pin;
use std::time::SystemTime;
use pagetop::trace::info;
use pagetop::prelude::*;
use sea_orm::sea_query::{
self, Alias, Expr, ExprTrait, ForeignKey, IntoIden, Order, Query, SelectStatement, SimpleExpr,
@ -182,10 +182,10 @@ pub trait MigratorTrait: Send {
{
Self::install(db).await?;
info!("Checking migration status");
trace::info!("Checking migration status");
for Migration { migration, status } in Self::get_migration_with_status(db).await? {
info!("Migration '{}'... {}", migration.name(), status);
trace::info!("Migration '{}'... {}", migration.name(), status);
}
Ok(())
@ -284,34 +284,35 @@ where
// Temporarily disable the foreign key check
if db_backend == DbBackend::Sqlite {
info!("Disabling foreign key check");
trace::info!("Disabling foreign key check");
db.execute(Statement::from_string(
db_backend,
"PRAGMA foreign_keys = OFF".to_owned(),
))
.await?;
info!("Foreign key check disabled");
trace::info!("Foreign key check disabled");
}
// Drop all foreign keys
if db_backend == DbBackend::MySql {
info!("Dropping all foreign keys");
trace::info!("Dropping all foreign keys");
let stmt = query_mysql_foreign_keys(db);
let rows = db.query_all(db_backend.build(&stmt)).await?;
for row in rows.into_iter() {
let constraint_name: String = row.try_get("", "CONSTRAINT_NAME")?;
let table_name: String = row.try_get("", "TABLE_NAME")?;
info!(
trace::info!(
"Dropping foreign key '{}' from table '{}'",
constraint_name, table_name
constraint_name,
table_name
);
let mut stmt = ForeignKey::drop();
stmt.table(Alias::new(table_name.as_str()))
.name(constraint_name.as_str());
db.execute(db_backend.build(&stmt)).await?;
info!("Foreign key '{}' has been dropped", constraint_name);
trace::info!("Foreign key '{}' has been dropped", constraint_name);
}
info!("All foreign keys dropped");
trace::info!("All foreign keys dropped");
}
// Drop all tables
@ -319,39 +320,39 @@ where
let rows = db.query_all(db_backend.build(&stmt)).await?;
for row in rows.into_iter() {
let table_name: String = row.try_get("", "table_name")?;
info!("Dropping table '{}'", table_name);
trace::info!("Dropping table '{}'", table_name);
let mut stmt = Table::drop();
stmt.table(Alias::new(table_name.as_str()))
.if_exists()
.cascade();
db.execute(db_backend.build(&stmt)).await?;
info!("Table '{}' has been dropped", table_name);
trace::info!("Table '{}' has been dropped", table_name);
}
// Drop all types
if db_backend == DbBackend::Postgres {
info!("Dropping all types");
trace::info!("Dropping all types");
let stmt = query_pg_types(db);
let rows = db.query_all(db_backend.build(&stmt)).await?;
for row in rows {
let type_name: String = row.try_get("", "typname")?;
info!("Dropping type '{}'", type_name);
trace::info!("Dropping type '{}'", type_name);
let mut stmt = Type::drop();
stmt.name(Alias::new(&type_name));
db.execute(db_backend.build(&stmt)).await?;
info!("Type '{}' has been dropped", type_name);
trace::info!("Type '{}' has been dropped", type_name);
}
}
// Restore the foreign key check
if db_backend == DbBackend::Sqlite {
info!("Restoring foreign key check");
trace::info!("Restoring foreign key check");
db.execute(Statement::from_string(
db_backend,
"PRAGMA foreign_keys = ON".to_owned(),
))
.await?;
info!("Foreign key check restored");
trace::info!("Foreign key check restored");
}
// Reapply all migrations
@ -367,15 +368,15 @@ where
M::install(db).await?;
/*
if let Some(steps) = steps {
info!("Applying {} pending migrations", steps);
trace::info!("Applying {} pending migrations", steps);
} else {
info!("Applying all pending migrations");
trace::info!("Applying all pending migrations");
}
*/
let migrations = M::get_pending_migrations(db).await?.into_iter();
/*
if migrations.len() == 0 {
info!("No pending migrations");
trace::info!("No pending migrations");
}
*/
for Migration { migration, .. } in migrations {
@ -385,9 +386,9 @@ where
}
*steps -= 1;
}
info!("Applying migration '{}'", migration.name());
trace::info!("Applying migration '{}'", migration.name());
migration.up(manager).await?;
info!("Migration '{}' has been applied", migration.name());
trace::info!("Migration '{}' has been applied", migration.name());
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("SystemTime before UNIX EPOCH!");
@ -412,14 +413,14 @@ where
M::install(db).await?;
if let Some(steps) = steps {
info!("Rolling back {} applied migrations", steps);
trace::info!("Rolling back {} applied migrations", steps);
} else {
info!("Rolling back all applied migrations");
trace::info!("Rolling back all applied migrations");
}
let migrations = M::get_applied_migrations(db).await?.into_iter().rev();
if migrations.len() == 0 {
info!("No applied migrations");
trace::info!("No applied migrations");
}
for Migration { migration, .. } in migrations {
if let Some(steps) = steps.as_mut() {
@ -428,9 +429,9 @@ where
}
*steps -= 1;
}
info!("Rolling back migration '{}'", migration.name());
trace::info!("Rolling back migration '{}'", migration.name());
migration.down(manager).await?;
info!("Migration '{}' has been rollbacked", migration.name());
trace::info!("Migration '{}' has been rollbacked", migration.name());
seaql_migrations::Entity::delete_many()
.filter(Expr::col(seaql_migrations::Column::Version).eq(migration.name()))
.table_name(M::migration_table_name())

View file

@ -1,6 +1,6 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[derive(Clone, Debug, DeriveEntityModel, Eq, PartialEq)]
// One should override the name of migration table via `MigratorTrait::migration_table_name` method
#[sea_orm(table_name = "seaql_migrations")]
pub struct Model {
@ -9,7 +9,7 @@ pub struct Model {
pub applied_at: i64,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -2,7 +2,7 @@
use std::collections::HashSet;
use pagetop::auth::PermissionRef;
use pagetop::prelude::*;
// **< UserStatus >*********************************************************************************
@ -56,9 +56,9 @@ impl PermissionSet {
/// Datos ricos del usuario autenticado inyectados por el middleware de sesión.
///
/// Se almacena en las extensiones de la petición HTTP durante la fase de middleware y se
/// accede desde los handlers o desde handlers de [`CheckPermission`](pagetop::auth::CheckPermission)
/// mediante [`HttpRequest::extension::<Account>()`](pagetop::web::HttpRequest::extension).
/// Se almacena en las extensiones de la petición HTTP durante la fase de middleware y se accede
/// desde los handlers o desde handlers de [`CheckPermission`] mediante
/// [`HttpRequest::extension::<Account>()`](pagetop::web::HttpRequest::extension).
#[derive(Clone, Debug)]
pub struct Account {
pub id: i32,

View file

@ -1,7 +1,6 @@
//! Lógica de autenticación: login, logout, registro, semilla inicial.
use pagetop::datetime::{Duration, NaiveDateTime, Utc};
use pagetop::prelude::*;
use pagetop_seaorm::db::{
ActiveModelTrait, ActiveValue, ColumnTrait, Condition, EntityTrait, PaginatorTrait,
QueryFilter, Set, dbconn,

View file

@ -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<Markup, ComponentError> {
let action = format!("{ADMIN_USERS_PATH}/{}/password", self.user_id());
let action = user_path(self.user_id(), "password");
let action = self.waypoint().append_to(cx.route(action));
let mut form = Form::new()

View file

@ -4,6 +4,7 @@ use pagetop::prelude::*;
use crate::ADMIN_ROLES_PATH;
use crate::LOCALES_USER;
use crate::role_path;
use crate::component::error_banner;
@ -35,13 +36,8 @@ impl Component for RoleForm {
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let action = match self.mode() {
RoleFormMode::New => format!("{ADMIN_ROLES_PATH}/new"),
RoleFormMode::Edit => {
format!(
"{ADMIN_ROLES_PATH}/{}/edit",
self.role_id().copied().unwrap_or_default()
)
}
RoleFormMode::New => util::join!(ADMIN_ROLES_PATH, "/new"),
RoleFormMode::Edit => role_path(self.role_id().copied().unwrap_or_default(), "edit"),
};
let action = self.waypoint().append_to(cx.route(action));

View file

@ -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<Markup, ComponentError> {
let action = format!("{ADMIN_ROLES_PATH}/{}/permissions", self.role_id());
let action = role_path(self.role_id(), "permissions");
let action = self.waypoint().append_to(cx.route(action));
let mut form = Form::new()
@ -37,12 +37,12 @@ impl Component for RolePermissionsForm {
for (idx, (group_label, perms)) in self.groups().iter().enumerate() {
let mut field = form::check::Field::new()
.with_id(format!("permission-group-{idx}"))
.with_id(util::join!("permission-group-", idx.to_string()))
.with_name("permission_keys");
for (key, label, checked) in perms {
let text = label.lookup(cx).unwrap_or_default();
field = field.with_item(
form::check::Item::new(key, Lc::n(format!("{text} ({key})")))
form::check::Item::new(key, Lc::n(util::join!(text, " (", key, ")")))
.with_checked(*checked),
);
}

View file

@ -1,13 +1,12 @@
//! 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)]
@ -92,7 +91,7 @@ impl Component for RoleTable {
};
table.alter_row(
Row::new()
table::Row::new()
.with_cell(role.machine_name.as_str())
.with_cell(label_cell(role, &waypoint))
.with_cell(Html::with(move |_cx| {
@ -108,7 +107,7 @@ impl Component for RoleTable {
);
}
let new_href = waypoint.append_to(cx.route(format!("{ADMIN_ROLES_PATH}/new")));
let new_href = waypoint.append_to(cx.route(util::join!(ADMIN_ROLES_PATH, "/new")));
Ok(html! {
div (self.props().unpack(cx)) {
@ -187,7 +186,12 @@ impl RoleTable {
// Construye la cabecera ordenable de una columna: el enlace ya funciona sin HTMX (navega con
// una petición normal); `pagetop_htmx::hx_table::sort_link()` añade aparte los atributos `hx-*`
// para que la tabla se actualice sin recargar la página cuando la extensión esté disponible.
fn sort_column(&self, cx: &Context, field: RoleSortField, label_key: &'static str) -> Column {
fn sort_column(
&self,
cx: &Context,
field: RoleSortField,
label_key: &'static str,
) -> table::Column {
let is_active = *self.sort() == field;
let active = is_active.then_some(*self.dir());
let next_dir = SortDir::next_for(active);
@ -196,7 +200,7 @@ impl RoleTable {
.alter_param("sort", field.as_str())
.alter_param("dir", next_dir);
Column::new(Lc::t(label_key, &LOCALES_USER)).with_sort(sort_link(
table::Column::new(Lc::t(label_key, &LOCALES_USER)).with_sort(sort_link(
route,
"#role-table-wrapper",
active,
@ -212,7 +216,7 @@ fn label_cell(role: &RoleListItem, waypoint: &Waypoint) -> Html {
let id = role.id;
let waypoint = waypoint.clone();
Html::with(move |cx| {
let view_href = waypoint.append_to(cx.route(format!("{ADMIN_ROLES_PATH}/{id}/view")));
let view_href = waypoint.append_to(cx.route(role_path(id, "view")));
html! {
a href=(view_href) { (label.as_str()) }
}
@ -234,8 +238,7 @@ async fn actions_cell(
let locked = role.locked;
let waypoint = waypoint.clone();
let permissions_href =
waypoint.append_to(cx.route(format!("{ADMIN_ROLES_PATH}/{id}/permissions")));
let permissions_href = waypoint.append_to(cx.route(role_path(id, "permissions")));
// Los botones se renderizan aquí, no dentro del `Html::with()` de abajo: necesitan pasar por
// su propio ciclo de renderizado (`.render().await`) para que el tema activo los estilice
@ -253,7 +256,7 @@ async fn actions_cell(
let (edit_button, delete_button) = if locked {
(None, None)
} else {
let edit_href = waypoint.append_to(cx.route(format!("{ADMIN_ROLES_PATH}/{id}/edit")));
let edit_href = waypoint.append_to(cx.route(role_path(id, "edit")));
let edit_button = Button::anchor(Lc::t("btn-edit", &LOCALES_USER), edit_href)
.with_style(button::Style::Solid(Intent::Primary))
.with_size(button::Size::Small)
@ -263,7 +266,7 @@ async fn actions_cell(
// Viaja como query string para que, tanto si el borrado falla como si tiene éxito, la
// tabla vuelva a mostrarse en la misma página/orden en que estaba, en vez de reiniciarse.
let confirm_href = cx
.route(format!("{ADMIN_ROLES_PATH}/{id}/delete/confirm"))
.route(role_path(id, "delete/confirm"))
.alter_param("sort", sort.as_str())
.alter_param("dir", dir)
.alter_param("page", page.to_string())

View file

@ -4,6 +4,7 @@ use pagetop::prelude::*;
use crate::ADMIN_USERS_PATH;
use crate::LOCALES_USER;
use crate::user_path;
use crate::component::{PasswordConfirm, error_banner};
@ -43,13 +44,8 @@ impl Component for UserForm {
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let action = match self.mode() {
UserFormMode::New => format!("{ADMIN_USERS_PATH}/new"),
UserFormMode::Edit => {
format!(
"{ADMIN_USERS_PATH}/{}/edit",
self.user_id().copied().unwrap_or_default()
)
}
UserFormMode::New => util::join!(ADMIN_USERS_PATH, "/new"),
UserFormMode::Edit => user_path(self.user_id().copied().unwrap_or_default(), "edit"),
};
let action = self.waypoint().append_to(cx.route(action));

View file

@ -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<Markup, ComponentError> {
let action = format!("{ADMIN_USERS_PATH}/{}/roles", self.user_id());
let action = user_path(self.user_id(), "roles");
let action = self.waypoint().append_to(cx.route(action));
let mut form = Form::new()

View file

@ -1,8 +1,6 @@
//! Tabla de usuarios: cabeceras ordenables, filas y paginación embebida.
use pagetop::prelude::*;
use pagetop::base::component::table::{Column, Row};
use pagetop_htmx::hx;
use pagetop_htmx::hx_table::sort_link;
@ -11,6 +9,7 @@ use crate::LOCALES_USER;
use crate::account::UserStatus;
use crate::permission::UserPermission;
use crate::service::user_admin::{UserListItem, UserSortField};
use crate::user_path;
#[derive(AutoDefault, Clone, Debug, Getters)]
pub(crate) struct UserTable {
@ -74,7 +73,7 @@ impl Component for UserTable {
let status = user.status;
table.alter_row(
Row::new()
table::Row::new()
.with_cell(username_cell(user, &waypoint))
.with_cell(user.email.as_str())
.with_cell(user.display_name.as_deref().unwrap_or("-"))
@ -84,7 +83,7 @@ impl Component for UserTable {
);
}
let new_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/new")));
let new_href = waypoint.append_to(cx.route(util::join!(ADMIN_USERS_PATH, "/new")));
Ok(html! {
div (self.props().unpack(cx)) {
@ -162,7 +161,12 @@ impl UserTable {
// Construye la cabecera ordenable de una columna: el enlace ya funciona sin HTMX (navega con
// una petición normal); `pagetop_htmx::hx_table::sort_link()` añade aparte los atributos `hx-*`
// para que la tabla se actualice sin recargar la página cuando la extensión esté disponible.
fn sort_column(&self, cx: &Context, field: UserSortField, label_key: &'static str) -> Column {
fn sort_column(
&self,
cx: &Context,
field: UserSortField,
label_key: &'static str,
) -> table::Column {
let is_active = *self.sort() == field;
let active = is_active.then_some(*self.dir());
let next_dir = SortDir::next_for(active);
@ -175,7 +179,7 @@ impl UserTable {
.alter_param("sort", field.as_str())
.alter_param("dir", next_dir);
Column::new(Lc::t(label_key, &LOCALES_USER)).with_sort(sort_link(
table::Column::new(Lc::t(label_key, &LOCALES_USER)).with_sort(sort_link(
route,
"#user-table-wrapper",
active,
@ -191,7 +195,7 @@ fn username_cell(user: &UserListItem, waypoint: &Waypoint) -> Html {
let id = user.id;
let waypoint = waypoint.clone();
Html::with(move |cx| {
let view_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{id}/view")));
let view_href = waypoint.append_to(cx.route(user_path(id, "view")));
html! {
a href=(view_href) { (username.as_str()) }
}
@ -208,7 +212,7 @@ async fn actions_cell(
cx: &mut Context,
) -> Html {
let id = user.id;
let edit_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{id}/edit")));
let edit_href = waypoint.append_to(cx.route(user_path(id, "edit")));
// El botón se renderiza aquí, no dentro del `Html::with()` de abajo: necesita pasar por su
// propio ciclo de renderizado (`.render().await`) para que el tema activo lo estilice igual
@ -220,7 +224,7 @@ async fn actions_cell(
.await;
let roles_button = if can_assign_roles {
let roles_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{id}/roles")));
let roles_href = waypoint.append_to(cx.route(user_path(id, "roles")));
Some(
Button::anchor(Lc::t("btn-manage-roles", &LOCALES_USER), roles_href)
.with_style(button::Style::Solid(Intent::Neutral))

View file

@ -4,8 +4,7 @@
//! `Extension::initialize()`, después de `auth::seed_initial_data()`. Si el rol
//! `demo_role_01` ya existe, la siembra se omite para no duplicar datos en reinicios.
use pagetop::datetime::Utc;
use pagetop::prelude::*;
use pagetop_seaorm::db::{ActiveValue, ColumnTrait, EntityTrait, QueryFilter, Set, dbconn};
use crate::account::UserStatus;

View file

@ -1,7 +1,6 @@
use pagetop::prelude::*;
use pagetop_seaorm::db::*;
use pagetop::datetime::NaiveDateTime;
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
#[sea_orm(table_name = "roles")]
pub struct Model {

View file

@ -1,7 +1,6 @@
use pagetop::prelude::*;
use pagetop_seaorm::db::*;
use pagetop::datetime::NaiveDateTime;
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
#[sea_orm(table_name = "role_permissions")]
pub struct Model {

View file

@ -1,7 +1,6 @@
use pagetop::prelude::*;
use pagetop_seaorm::db::*;
use pagetop::datetime::NaiveDateTime;
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
#[sea_orm(table_name = "sessions")]
pub struct Model {

View file

@ -1,7 +1,6 @@
use pagetop::prelude::*;
use pagetop_seaorm::db::*;
use pagetop::datetime::NaiveDateTime;
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
#[sea_orm(table_name = "users")]
pub struct Model {

View file

@ -1,7 +1,6 @@
use pagetop::prelude::*;
use pagetop_seaorm::db::*;
use pagetop::datetime::NaiveDateTime;
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
#[sea_orm(table_name = "user_tokens")]
pub struct Model {

View file

@ -1,6 +1,5 @@
//! Handler HTTP para el perfil del propio usuario autenticado.
use pagetop::base::component::table::Row;
use pagetop::prelude::*;
use crate::account::UserStatus;
@ -47,32 +46,32 @@ async fn profile_details(user: &user::Model, status: UserStatus, cx: &mut Contex
let mut table = Table::new()
.with_prop(PropsOp::add_classes("user-admin-table"))
.with_row(
Row::new()
table::Row::new()
.with_cell(Lc::t("field-username-admin", &LOCALES_USER))
.with_cell(user.username.as_str()),
)
.with_row(
Row::new()
table::Row::new()
.with_cell(Lc::t("field-email", &LOCALES_USER))
.with_cell(user.email.as_str()),
)
.with_row(
Row::new()
table::Row::new()
.with_cell(Lc::t("field-display-name", &LOCALES_USER))
.with_cell(user.display_name.as_deref().unwrap_or("-")),
)
.with_row(
Row::new()
table::Row::new()
.with_cell(Lc::t("field-language", &LOCALES_USER))
.with_cell(user.language.as_deref().unwrap_or("-")),
)
.with_row(
Row::new()
table::Row::new()
.with_cell(Lc::t("field-timezone", &LOCALES_USER))
.with_cell(user.timezone.as_deref().unwrap_or("-")),
)
.with_row(
Row::new()
table::Row::new()
.with_cell(Lc::t("col-status", &LOCALES_USER))
.with_cell(Lc::t(status_key(status), &LOCALES_USER)),
);
@ -83,7 +82,7 @@ async fn profile_details(user: &user::Model, status: UserStatus, cx: &mut Contex
.render(cx)
.await;
table = table.with_row(
Row::new()
table::Row::new()
.with_cell("")
.with_cell(Html::with(move |_| badge.clone())),
);

View file

@ -2,7 +2,6 @@
use serde::Deserialize;
use pagetop::base::component::table::Row;
use pagetop::prelude::*;
use pagetop_htmx::prelude::*;
@ -15,6 +14,7 @@ use crate::config::SETTINGS;
use crate::entity::role;
use crate::handlers::admin::{back_link, frame, map_auth_error};
use crate::permission::{self, UserPermission};
use crate::role_path;
use crate::service::role_admin::{self, RolePageParams, RoleSortField};
#[derive(Deserialize)]
@ -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(
Row::new()
table::Row::new()
.with_cell(Lc::t("field-machine-name", &LOCALES_USER))
.with_cell(role.machine_name.as_str()),
)
.with_row(
Row::new()
table::Row::new()
.with_cell(Lc::t("field-label", &LOCALES_USER))
.with_cell(role.label.as_str()),
)
.with_row(
Row::new()
table::Row::new()
.with_cell(Lc::t("field-description", &LOCALES_USER))
.with_cell(role.description.as_deref().unwrap_or("-")),
)
.with_row(
Row::new()
table::Row::new()
.with_cell(Lc::t("field-weight", &LOCALES_USER))
.with_cell(role.weight.to_string()),
);
@ -338,7 +338,7 @@ async fn role_view_details(role: &role::Model, cx: &mut Context) -> Block {
.render(cx)
.await;
table = table.with_row(
Row::new()
table::Row::new()
.with_cell("")
.with_cell(Html::with(move |_| badge.clone())),
);
@ -451,7 +451,7 @@ pub(crate) async fn delete_confirm_get(
let mut cx = Context::admin(request);
let delete_href = cx
.route(format!("{ADMIN_ROLES_PATH}/{id}/delete"))
.route(role_path(id, "delete"))
.alter_param(
"sort",
RoleSortField::from_query(query.sort.as_deref()).as_str(),

View file

@ -2,11 +2,9 @@
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;
@ -22,8 +20,10 @@ use crate::error::AuthError;
use crate::handlers::admin::{back_link, frame, map_auth_error};
use crate::password;
use crate::permission::UserPermission;
use crate::role_path;
use crate::service::role_admin;
use crate::service::user_admin::{self, UserListParams, UserSortField};
use crate::user_path;
#[derive(Deserialize)]
pub(crate) struct UsersQuery {
@ -341,12 +341,10 @@ fn edit_actions(
("true", "btn-grant-admin", "confirm-grant-admin")
};
let roles_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{user_id}/roles")));
let password_href =
waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{user_id}/password")));
let status_action =
waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{user_id}/status")));
let admin_action = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{user_id}/admin")));
let roles_href = waypoint.append_to(cx.route(user_path(user_id, "roles")));
let password_href = waypoint.append_to(cx.route(user_path(user_id, "password")));
let status_action = waypoint.append_to(cx.route(user_path(user_id, "status")));
let admin_action = waypoint.append_to(cx.route(user_path(user_id, "admin")));
let mut status_form = Form::new()
.with_action(status_action.clone())
@ -518,32 +516,32 @@ async fn user_view_details(user: &user::Model, status: UserStatus, cx: &mut Cont
let mut table = Table::new()
.with_prop(PropsOp::add_classes("user-admin-table"))
.with_row(
Row::new()
table::Row::new()
.with_cell(Lc::t("field-username-admin", &LOCALES_USER))
.with_cell(user.username.as_str()),
)
.with_row(
Row::new()
table::Row::new()
.with_cell(Lc::t("field-email", &LOCALES_USER))
.with_cell(user.email.as_str()),
)
.with_row(
Row::new()
table::Row::new()
.with_cell(Lc::t("field-display-name", &LOCALES_USER))
.with_cell(user.display_name.as_deref().unwrap_or("-")),
)
.with_row(
Row::new()
table::Row::new()
.with_cell(Lc::t("field-language", &LOCALES_USER))
.with_cell(user.language.as_deref().unwrap_or("-")),
)
.with_row(
Row::new()
table::Row::new()
.with_cell(Lc::t("field-timezone", &LOCALES_USER))
.with_cell(user.timezone.as_deref().unwrap_or("-")),
)
.with_row(
Row::new()
table::Row::new()
.with_cell(Lc::t("col-status", &LOCALES_USER))
.with_cell(Lc::t(status_key(status), &LOCALES_USER)),
);
@ -554,7 +552,7 @@ async fn user_view_details(user: &user::Model, status: UserStatus, cx: &mut Cont
.render(cx)
.await;
table = table.with_row(
Row::new()
table::Row::new()
.with_cell("")
.with_cell(Html::with(move |_| badge.clone())),
);
@ -593,9 +591,10 @@ async fn user_view_roles(roles: &[role::Model], cx: &mut Context) -> Block {
table.user-admin-table {
tbody {
@for (id, machine_name, label, system_badge) in &items {
@let href = cx.route(role_path(*id, "view")).to_string();
tr {
td {
a href=(cx.route(format!("{ADMIN_ROLES_PATH}/{id}/view")).to_string()) {
a href=(href) {
(label.as_str())
}
}
@ -744,7 +743,7 @@ pub(crate) async fn status_post(
match user_admin::set_user_status(id, new_status, account.id).await {
Ok(()) => {
let cx = Context::admin(request);
let edit_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{id}/edit")));
let edit_href = waypoint.append_to(cx.route(user_path(id, "edit")));
if is_htmx {
Ok(HtmxResponse::empty().redirect(edit_href).into_response())
} else {
@ -791,7 +790,7 @@ pub(crate) async fn admin_post(
match user_admin::set_user_admin(id, new_is_admin, account.id).await {
Ok(()) => {
let cx = Context::admin(request);
let edit_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{id}/edit")));
let edit_href = waypoint.append_to(cx.route(user_path(id, "edit")));
if is_htmx {
Ok(HtmxResponse::empty().redirect(edit_href).into_response())
} else {
@ -816,10 +815,7 @@ pub(crate) async fn password_get(
return Err(ErrorPage::NotFound(Some(request)));
}
let mut page = Page::admin(request);
let edit_href = waypoint.append_to(
page.context()
.route(format!("{ADMIN_USERS_PATH}/{id}/edit")),
);
let edit_href = waypoint.append_to(page.context().route(user_path(id, "edit")));
let title = Lc::t("title-admin-user-password", &LOCALES_USER);
Ok(page
.with_title(title.clone())
@ -861,15 +857,12 @@ pub(crate) async fn password_post(
match result {
Ok(()) => {
let cx = Context::admin(request);
let edit_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{id}/edit")));
let edit_href = waypoint.append_to(cx.route(user_path(id, "edit")));
Ok(Redirect::see_other(edit_href).into_response())
}
Err(err) => {
let mut page = Page::admin(request);
let edit_href = waypoint.append_to(
page.context()
.route(format!("{ADMIN_USERS_PATH}/{id}/edit")),
);
let edit_href = waypoint.append_to(page.context().route(user_path(id, "edit")));
let title = Lc::t("title-admin-user-password", &LOCALES_USER);
Ok(page
.with_title(title.clone())

View file

@ -2,7 +2,6 @@
use serde::Deserialize;
use pagetop::auth::CurrentUser;
use pagetop::prelude::*;
use crate::auth;

View file

@ -131,6 +131,16 @@ const ADMIN_ROLES_PATH: &str = "/admin/user/roles";
// Catálogo de permisos registrados, agrupado por extensión (solo lectura).
const ADMIN_PERMISSIONS_PATH: &str = "/admin/user/permissions";
// Ruta de una acción sobre el usuario `id`: `{ADMIN_USERS_PATH}/{id}/{tail}`.
fn user_path(id: i32, tail: &str) -> String {
util::join!(ADMIN_USERS_PATH, "/", id.to_string(), "/", tail)
}
// Ruta de una acción sobre el rol `id`: `{ADMIN_ROLES_PATH}/{id}/{tail}`.
fn role_path(id: i32, tail: &str) -> String {
util::join!(ADMIN_ROLES_PATH, "/", id.to_string(), "/", tail)
}
// **< Registro en pagetop-admin (fijo) >***********************************************************
// Registra las páginas de usuarios, roles y permisos en el portal de `pagetop-admin`, bajo la

View file

@ -5,9 +5,7 @@
//! si el usuario está autenticado, el [`crate::account::Account`] con sus datos ricos en las
//! extensiones de la petición HTTP.
use pagetop::auth::PermissionRef;
use pagetop::web::middleware::Next;
use pagetop::web::{Request, Response};
use pagetop::prelude::*;
use crate::account::Account;
use crate::session;
@ -17,7 +15,10 @@ use crate::session;
///
/// - Siempre inserta [`pagetop::auth::CurrentUser`] (anónimo o autenticado).
/// - Si hay sesión activa, inserta también el [`Account`] con roles y permisos.
pub(crate) async fn session_middleware(mut req: Request, next: Next) -> Response {
pub(crate) async fn session_middleware(
mut req: web::Request,
next: web::middleware::Next,
) -> Response {
let (current_user, maybe_account) = session::resolve_session(req.headers()).await;
req.extensions_mut().insert(current_user);

View file

@ -2,8 +2,7 @@
use std::collections::HashMap;
use pagetop::datetime::Utc;
use pagetop::html::SortDir;
use pagetop::prelude::*;
use pagetop_seaorm::db::{
ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, Order, Paginated, PaginatorTrait,
QueryFilter, QueryOrder, QuerySelect, Set, TransactionTrait, dbconn, flatten_txn_err, paginate,

View file

@ -2,9 +2,7 @@
use std::collections::HashMap;
use pagetop::datetime::Utc;
use pagetop::html::SortDir;
use pagetop::util;
use pagetop::prelude::*;
use pagetop_seaorm::db::{
ActiveModelTrait, ActiveValue, ColumnTrait, Condition, EntityTrait, Order, Paginated,
PaginatorTrait, QueryFilter, QueryOrder, Set, TransactionTrait, dbconn, flatten_txn_err,

View file

@ -1,8 +1,6 @@
//! Gestión de sesiones de usuario (creación, carga, destrucción).
use pagetop::auth::CurrentUser;
use pagetop::datetime::{Duration, Utc};
use pagetop::web::http::{HeaderMap, header};
use pagetop::prelude::*;
use pagetop_seaorm::db::{
ActiveModelTrait, ColumnTrait, DbErr, EntityTrait, QueryFilter, Set, dbconn,
};
@ -35,32 +33,33 @@ fn bytes_to_hex(bytes: &[u8]) -> String {
/// Construye el valor de la cabecera `Set-Cookie` para la cookie de sesión.
pub fn build_cookie(sid: &str, remember: bool) -> String {
let mut parts = vec![
format!("{}={}", SETTINGS.session_cookie_name, sid),
"HttpOnly".into(),
"SameSite=Lax".into(),
"Path=/".into(),
];
let mut cookie = util::join!(
&SETTINGS.session_cookie_name,
"=",
sid,
"; HttpOnly; SameSite=Lax; Path=/"
);
if SETTINGS.secure_cookie {
parts.push("Secure".into());
cookie.push_str("; Secure");
}
if remember {
parts.push(format!("Max-Age={}", SETTINGS.session_ttl_secs));
cookie.push_str("; Max-Age=");
cookie.push_str(&SETTINGS.session_ttl_secs.to_string());
}
parts.join("; ")
cookie
}
/// Construye la cookie de expiración (Max-Age=0) para borrar la sesión del navegador.
pub fn expiry_cookie() -> String {
format!(
"{}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0",
SETTINGS.session_cookie_name
util::join!(
&SETTINGS.session_cookie_name,
"=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0"
)
}
/// Extrae el session ID de las cabeceras HTTP de la petición, si existe.
pub fn extract_sid(headers: Option<&HeaderMap>) -> Option<String> {
let cookie_str = headers?.get(header::COOKIE)?.to_str().ok()?;
pub fn extract_sid(headers: Option<&web::http::HeaderMap>) -> Option<String> {
let cookie_str = headers?.get(web::http::header::COOKIE)?.to_str().ok()?;
let name = SETTINGS.session_cookie_name.as_str();
for part in cookie_str.split(';') {
@ -78,7 +77,7 @@ pub fn extract_sid(headers: Option<&HeaderMap>) -> Option<String> {
///
/// Si no hay cookie o la sesión ha expirado, devuelve `(CurrentUser::Anonymous, None)`.
/// Se llama desde el middleware de sesión, que es async.
pub async fn resolve_session(headers: &HeaderMap) -> (CurrentUser, Option<Account>) {
pub async fn resolve_session(headers: &web::http::HeaderMap) -> (CurrentUser, Option<Account>) {
let Some(sid) = extract_sid(Some(headers)) else {
return (CurrentUser::Anonymous, None);
};

View file

@ -1,9 +1,7 @@
//! Generación y verificación de tokens de un solo uso (reset de contraseña, verificación de
//! email...).
use pagetop::datetime::{Duration, Utc};
use sha2::{Digest, Sha256};
use pagetop::prelude::*;
use pagetop_seaorm::db::{
ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter, Set, dbconn,
};
@ -11,6 +9,8 @@ use pagetop_seaorm::db::{
use crate::entity::user_token;
use crate::error::AuthError;
use sha2::{Digest, Sha256};
// **< Generación de tokens >***********************************************************************
/// Genera un token URL-safe de 43 caracteres (32 bytes -> base64url sin padding).

View file

@ -16,6 +16,5 @@ license.workspace = true
authors.workspace = true
[dependencies]
concat-string.workspace = true
indoc.workspace = true
pastey.workspace = true

View file

@ -28,11 +28,6 @@ concatenación de cadenas y el uso rápido de colecciones clave-valor.
Las macros para texto multilínea **`indoc!`**, **`formatdoc!`** y **`concatdoc!`** se reexportan del
*crate* [indoc](https://crates.io/crates/indoc) de [David Tolnay](https://crates.io/users/dtolnay).
Las macros para la concatenación de cadenas **`join!`** y **`join_pair!`** se apoyan internamente en
el *crate* [concat-string](https://crates.io/crates/concat_string), desarrollado por
[FaultyRAM](https://crates.io/users/FaultyRAM), para evitar el formato de cadenas cuando la
eficiencia pueda ser relevante.
La macro para generar identificadores dinámicos **`paste!`** se reexporta del *crate*
[pastey](https://crates.io/crates/pastey), una implementación avanzada y soportada del popular
`paste!` de [David Tolnay](https://crates.io/users/dtolnay).

View file

@ -29,11 +29,6 @@ concatenación de cadenas y el uso rápido de colecciones clave-valor.
Las macros para texto multilínea **`indoc!`**, **`formatdoc!`** y **`concatdoc!`** se reexportan del
*crate* [indoc](https://crates.io/crates/indoc) de [David Tolnay](https://crates.io/users/dtolnay).
Las macros para la concatenación de cadenas **`join!`** y **`join_pair!`** se apoyan internamente en
el *crate* [concat-string](https://crates.io/crates/concat_string), desarrollado por
[FaultyRAM](https://crates.io/users/FaultyRAM), para evitar el formato de cadenas cuando la
eficiencia pueda ser relevante.
La macro para generar identificadores dinámicos **`paste!`** se reexporta del *crate*
[pastey](https://crates.io/crates/pastey), una implementación avanzada y soportada del popular
`paste!` de [David Tolnay](https://crates.io/users/dtolnay).
@ -43,9 +38,6 @@ La macro para generar identificadores dinámicos **`paste!`** se reexporta del *
html_favicon_url = "https://git.cillero.es/manuelcillero/pagetop/raw/branch/main/assets/favicon.ico"
)]
#[doc(hidden)]
pub use concat_string::concat_string;
pub use indoc::{concatdoc, formatdoc, indoc};
/// Permite *pegar* tokens y generar identificadores a partir de otros.
@ -60,11 +52,17 @@ pub use pastey::paste;
// La documentación anterior se copia en `pagetop::util::paste!` porque el *crate* original no la
// define y `pagetop` no la hereda automáticamente.
/// Concatena eficientemente varios fragmentos en un [`String`].
/// Concatena varios fragmentos en un [`String`] reservando una sola vez la memoria del resultado.
///
/// Esta macro exporta [`concat_string!`](https://docs.rs/concat-string). Acepta cualquier número de
/// fragmentos que implementen [`AsRef<str>`] y construye un [`String`] con el tamaño óptimo, de
/// forma eficiente y evitando el uso de cadenas de formato que penalicen el rendimiento.
/// Acepta uno o más fragmentos que implementen [`AsRef<str>`] (literales, `&str`, [`String`],
/// `Cow<str>`...) y construye el resultado con la capacidad exacta, sin pasar por el formateo. Es
/// la forma preferida de componer texto a partir de fragmentos, y suele ser más rápida que
/// `format!`.
///
/// Cada expresión se evalúa **una sola vez**, por lo que admite llamadas como `n.to_string()` o
/// cierres con efectos secundarios. Los valores que no son cadenas (números, tipos `Display`) se
/// convierten antes con `to_string()`. Si hace falta formato real (`{:02}`, `{:?}`, `{:.2}`...),
/// usar `format!`.
///
/// # Ejemplo
///
@ -81,11 +79,26 @@ pub use pastey::paste;
/// // Un único fragmento devuelve el mismo valor.
/// let single_result = join!("Hello");
/// assert_eq!(single_result, "Hello".to_string());
///
/// // Los fragmentos pueden ser de tipos distintos; los números se convierten antes.
/// let name = String::from("item");
/// let result_mixed = join!(&name, "-", 7.to_string());
/// assert_eq!(result_mixed, "item-7".to_string());
///
/// // Cada expresión se evalúa una sola vez.
/// let mut calls = 0;
/// let mut next = || {
/// calls += 1;
/// calls.to_string()
/// };
/// let result_once = join!("n", next());
/// assert_eq!(result_once, "n1".to_string());
/// assert_eq!(calls, 1);
/// ```
#[macro_export]
macro_rules! join {
($($arg:expr),+) => {
$crate::concat_string!($($arg),+)
($($arg:expr),+ $(,)?) => {
[$(::core::convert::AsRef::<str>::as_ref(&$arg)),+].concat()
};
}
@ -126,15 +139,15 @@ macro_rules! join_pair {
let second_val = $second;
let separator_val = $separator;
let first = AsRef::<str>::as_ref(&first_val);
let second = AsRef::<str>::as_ref(&second_val);
let first = ::core::convert::AsRef::<str>::as_ref(&first_val);
let second = ::core::convert::AsRef::<str>::as_ref(&second_val);
let separator = if first.is_empty() || second.is_empty() {
""
} else {
AsRef::<str>::as_ref(&separator_val)
::core::convert::AsRef::<str>::as_ref(&separator_val)
};
$crate::concat_string!(first, separator, second)
[first, separator, second].concat()
}};
}

View file

@ -47,8 +47,9 @@ pub mod image;
#[doc(inline)]
pub use image::Image;
mod intro;
pub use intro::{Intro, IntroOpening};
pub mod intro;
#[doc(inline)]
pub use intro::Intro;
mod messages;
pub use messages::Messages;

View file

@ -25,28 +25,61 @@ pub enum Kind {
Article,
}
// **< Width >**************************************************************************************
/// Define cómo se comporta el ancho de un contenedor ([`Container`]).
///
/// Cada variante se traduce en una clase CSS (`container`, `container-{nombre}` o
/// `container-fluid`), con el nombre que el tema activo da al punto de corte, y el tema decide qué
/// anchos máximos le corresponden. [`FluidMax`](Self::FluidMax) añade además un `max-width` en
/// línea.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Width {
/// Aplica los anchos máximos predefinidos por el tema para cada punto de corte. Por debajo del
/// menor punto de corte ocupa el 100% del ancho disponible.
Responsive,
/// Aplica los anchos máximos predefinidos a partir del punto de corte indicado. Por debajo de
/// ese punto de corte ocupa el 100% del ancho disponible.
From(Breakpoint),
/// Ocupa el 100% del ancho disponible siempre.
Fluid,
/// Ocupa el 100% del ancho disponible hasta un ancho máximo explícito.
FluidMax(UnitValue),
}
// **< Container >**********************************************************************************
/// Componente para crear un **contenedor de componentes**.
///
/// Envuelve un conjunto de componentes en un contenedor establecido que se crea aplicando uno de
/// los tipos definidos en [`Kind`].
/// los tipos definidos en [`Kind`]. Opcionalmente, su ancho se controla con [`Width`] mediante
/// [`with_width()`](Self::with_width); sin él, el contenedor no añade ninguna clase de ancho.
///
/// Si no contiene elementos, el componente **no se renderiza**.
///
/// # Ejemplo
/// # Ejemplos
///
/// ```rust,no_run
/// use pagetop::prelude::*;
///
/// let main = Container::main().with_id("main-page");
/// ```
///
/// Contenedor centrado que ocupa todo el ancho hasta un máximo explícito:
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// let page = Container::new().with_width(container::Width::FluidMax(UnitValue::RelRem(75.0)));
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Container {
/// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente.
props: Props,
/// Devuelve el tipo semántico del contenedor.
kind: Kind,
/// Devuelve el comportamiento del ancho, si se ha fijado.
#[getters(copy)]
width: Option<Width>,
/// Devuelve la lista de componentes (`children`) del contenedor.
children: Children,
}
@ -61,6 +94,25 @@ impl Component for Container {
self.props.get_id()
}
fn setup(&mut self, cx: &mut Context) {
if let Some(width) = self.width() {
let class: CowStr = match width {
Width::Responsive => "container".into(),
Width::From(bp) => match bp.resolved(cx) {
Some(entry) => util::join!("container-", entry.name).into(),
None => "container".into(),
},
Width::Fluid | Width::FluidMax(_) => "container-fluid".into(),
};
self.alter_prop(PropsOp::prepend_classes(class));
if let Width::FluidMax(max) = width
&& max.is_measurable()
{
self.alter_prop(PropsOp::add_style("max-width", max.to_string()));
}
}
}
#[rustfmt::skip]
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let output = self.children().render(cx).await;
@ -135,6 +187,15 @@ impl Container {
self
}
/// Define el comportamiento del ancho del contenedor. Con `None` no se añade ninguna clase de
/// ancho y cada tema decide cómo tratarlo.
///
/// Ver [`Width`] para las variantes disponibles.
pub fn with_width(mut self, width: impl Into<Option<Width>>) -> 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<ChildOp>) -> Self {

View file

@ -1,292 +1,7 @@
use crate::prelude::*;
//! Definiciones para crear introducciones ([`Intro`]).
/// Tipo de apertura que se mostrará en la introducción del componente [`Intro`].
///
/// Permite elegir entre una apertura con textos predefinidos sobre PageTop (como hace la página de
/// bienvenida [`Welcome`](crate::base::extension::Welcome)) o una introducción completamente
/// personalizada.
#[derive(AutoDefault, Copy, Clone, Debug, Eq, PartialEq)]
pub enum IntroOpening {
/// Modo por defecto. Muestra una introducción estándar de PageTop e incluye automáticamente
/// *badges* con información de la última versión liberada, fecha del último lanzamiento y
/// licencia de uso.
#[default]
PageTop,
/// Modo totalmente personalizado. No añade *badges* ni textos predefinidos. Usa la imagen de
/// PageTop pero el contenido lo define el propio desarrollador.
Custom,
}
mod props;
pub use props::{Kind, Width};
/// Componente para divulgar PageTop (como hace [`Welcome`](crate::base::extension::Welcome)), o
/// mostrar presentaciones.
///
/// Usa la imagen de PageTop para mostrar:
///
/// - Una **figura decorativa** (que incluye la *mascota* de PageTop) antecediendo al contenido.
/// - Una vista destacada del **título** de la página con un **eslogan** de presentación.
/// - Un **botón opcional** de llamada a la acción con texto y enlace configurables.
/// - Un **área para la presentación de contenidos**, con *badges* informativos de PageTop (si se
/// opta por [`IntroOpening::PageTop`]) y bloques ([`Block`](crate::base::component::Block)) de
/// contenido libre. Los párrafos que tengan la clase `.intro-text-lead` se mostrarán con una
/// tipografía ampliada, ideal para presentaciones breves e impactantes.
///
/// # Ejemplos
///
/// **Intro mínima por defecto**
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// let intro = Intro::default();
/// ```
///
/// **Título, eslogan y botón personalizados**
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// let intro = Intro::default()
/// .with_title(Lc::l("intro_custom_title"))
/// .with_slogan(Lc::l("intro_custom_slogan"))
/// .with_button(Some((
/// Lc::l("intro_learn_more"),
/// "/learn-more".into()
/// )));
/// ```
///
/// **Sin botón y en modo *Custom* (sin *badges* predefinidos)**
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// let intro = Intro::default()
/// .with_button(None::<(Lc, Route)>)
/// .with_opening(IntroOpening::Custom);
/// ```
///
/// **Añadir contenidos hijo**
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// let intro = Intro::default()
/// .with_child(
/// Block::new()
/// .with_title(Lc::l("intro_custom_block_title"))
/// .with_child(Html::with(move |cx| {
/// html! {
/// p class="intro-text-lead" {
/// (Lc::l("intro_custom_paragraph_1").using(cx))
/// }
/// p { (Lc::l("intro_custom_paragraph_2").using(cx)) }
/// }
/// })),
/// );
/// ```
#[derive(Clone, Debug, Getters)]
pub struct Intro {
/// Devuelve el título de entrada.
title: Lc,
/// Devuelve el eslogan de la entrada.
slogan: Lc,
/// Devuelve el botón de llamada a la acción, si existe.
button: Option<(Lc, Route)>,
/// Devuelve el modo de apertura configurado.
opening: IntroOpening,
/// Devuelve la lista de componentes hijo de la intro.
children: Children,
}
impl Default for Intro {
fn default() -> Self {
const BUTTON_LINK: &str = "https://pagetop.cillero.es";
Intro {
title: Lc::l("intro_default_title"),
slogan: Lc::l("intro_default_slogan").with_arg("app", &global::SETTINGS.app.name),
button: Some((Lc::l("intro_default_button"), BUTTON_LINK.into())),
opening: IntroOpening::default(),
children: Children::default(),
}
}
}
#[async_trait]
impl Component for Intro {
fn new() -> Self {
Self::default()
}
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
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<Option<(Lc, Route)>>) -> 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<ChildOp>) -> Self {
self.children.alter_child(op.into());
self
}
}
mod component;
pub use component::Intro;

View file

@ -0,0 +1,320 @@
use crate::prelude::*;
/// Componente para divulgar PageTop (como hace [`Welcome`](crate::base::extension::Welcome)), o
/// mostrar presentaciones.
///
/// Usa la imagen de PageTop para mostrar:
///
/// - Una **figura decorativa** (que incluye la *mascota* de PageTop) antecediendo al contenido.
/// - Una vista destacada del **título** de la página con un **eslogan** de presentación.
/// - Un **botón opcional** de llamada a la acción con texto y enlace configurables.
/// - Un **área para la presentación de contenidos**, con *badges* informativos de PageTop (si se
/// opta por [`intro::Kind::PageTop`]) y bloques ([`Block`](crate::base::component::Block)) de
/// contenido libre. Los párrafos que tengan la clase `.intro-text-lead` se mostrarán con una
/// tipografía ampliada, ideal para presentaciones breves e impactantes.
///
/// # Ejemplos
///
/// **Intro mínima por defecto**
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// let intro = Intro::default();
/// ```
///
/// **Título, eslogan y botón personalizados**
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// let intro = Intro::default()
/// .with_title(Lc::l("intro_custom_title"))
/// .with_slogan(Lc::l("intro_custom_slogan"))
/// .with_button(Some((
/// Lc::l("intro_learn_more"),
/// "/learn-more".into()
/// )));
/// ```
///
/// **Modo *Custom* con título y botón propios**
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// let intro = Intro::custom()
/// .with_title(Lc::l("intro_custom_title"))
/// .with_button(Some((Lc::l("intro_learn_more"), "/learn-more".into())));
/// ```
///
/// **Área de contenidos al ancho máximo**
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// let intro = Intro::default().with_width(intro::Width::Full);
/// ```
///
/// **Añadir contenidos hijo**
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// let intro = Intro::default()
/// .with_child(
/// Block::new()
/// .with_title(Lc::l("intro_custom_block_title"))
/// .with_child(Html::with(move |cx| {
/// html! {
/// p class="intro-text-lead" {
/// (Lc::l("intro_custom_paragraph_1").using(cx))
/// }
/// p { (Lc::l("intro_custom_paragraph_2").using(cx)) }
/// }
/// })),
/// );
/// ```
#[derive(Clone, Debug, Getters)]
pub struct Intro {
/// Devuelve el título de entrada.
title: Lc,
/// Devuelve el eslogan de la entrada.
slogan: Lc,
/// Devuelve el botón de llamada a la acción, si existe.
button: Option<(Lc, Route)>,
/// Devuelve el tipo de introducción, fijado al construirla.
kind: intro::Kind,
/// Devuelve el ancho máximo del área de contenidos.
width: intro::Width,
/// Devuelve la lista de componentes hijo de la intro.
children: Children,
}
impl Default for Intro {
fn default() -> Self {
const BUTTON_LINK: &str = "https://pagetop.cillero.es";
Intro {
title: Lc::l("intro_default_title"),
slogan: Lc::l("intro_default_slogan").with_arg("app", &global::SETTINGS.app.name),
button: Some((Lc::l("intro_default_button"), BUTTON_LINK.into())),
kind: intro::Kind::default(),
width: intro::Width::default(),
children: Children::default(),
}
}
}
#[async_trait]
impl Component for Intro {
fn new() -> Self {
Self::default()
}
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
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<Option<(Lc, Route)>>) -> 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<ChildOp>) -> Self {
self.children.alter_child(op.into());
self
}
}

View file

@ -0,0 +1,60 @@
use crate::prelude::*;
// **< Kind >***************************************************************************************
/// Tipo de introducción de un componente [`Intro`](super::Intro).
///
/// Distingue entre una presentación estándar sobre PageTop (la que usa, por ejemplo, la página de
/// bienvenida [`Welcome`]) y una introducción completamente personalizada.
///
/// Se fija al construir la introducción, con [`Intro::default()`] o [`Intro::new()`] para
/// [`Kind::PageTop`], o con [`Intro::custom()`] para [`Kind::Custom`], y se consulta con
/// [`Intro::kind()`].
///
/// [`Welcome`]: crate::base::extension::Welcome
/// [`Intro::default()`]: super::Intro::default
/// [`Intro::new()`]: super::Intro::new
/// [`Intro::custom()`]: super::Intro::custom
/// [`Intro::kind()`]: super::Intro::kind
#[derive(AutoDefault, Clone, Copy, Debug, Eq, PartialEq)]
pub enum Kind {
/// Presentación predeterminada. Muestra una introducción estándar de PageTop e incluye
/// automáticamente *badges* con información sobre la última versión liberada, fecha del último
/// lanzamiento y licencia de uso.
#[default]
PageTop,
/// Modo personalizado. Usa la imagen de PageTop pero sin contenido de ningún tipo.
Custom,
}
// **< Width >**************************************************************************************
/// Ancho máximo para el área de contenidos de un componente [`Intro`](super::Intro).
///
/// Sólo afecta a partir del punto de corte [`Breakpoint::Lg`], donde el área de contenidos se
/// muestra como un panel centrado. Por debajo ocupa siempre todo el ancho disponible. En pantallas
/// que no alcanzan el ancho máximo elegido, el panel se reduce para dejar un margen lateral.
#[derive(AutoDefault, Clone, Copy, Debug, Eq, PartialEq)]
pub enum Width {
/// Ancho estrecho.
Narrow,
/// Ancho por defecto.
#[default]
Normal,
/// Ancho amplio.
Wide,
/// Ancho más amplio de los disponibles.
Full,
}
impl Width {
// Clases CSS del área de contenidos: la base más la modificadora, si el ancho no es el normal.
pub(super) fn classes(self) -> &'static str {
match self {
Width::Narrow => "intro-text intro-text-narrow",
Width::Normal => "intro-text",
Width::Wide => "intro-text intro-text-wide",
Width::Full => "intro-text intro-text-full",
}
}
}

View file

@ -1,5 +1,5 @@
use crate::async_trait;
use crate::base::component::{Html, Intro, IntroOpening, layout};
use crate::base::component::{Html, Intro, layout};
use crate::core::component::{ChildOp, Component, ComponentError, ComponentRender};
use crate::core::component::{Context, Contextual};
use crate::core::extension::Extension;
@ -413,11 +413,9 @@ pub trait Theme: Extension + Send + Sync {
page.alter_title(title).alter_child_in(
&CoreRegions::Content,
ChildOp::Prepend(
Intro::new()
Intro::custom()
.with_title(Lc::l("error_code").with_arg("code", code.to_string()))
.with_slogan(Lc::n(code.to_string()))
.with_button(None)
.with_opening(IntroOpening::Custom)
.with_child(Html::with(move |cx| {
html! {
h1 { (alert.using(cx)) }

View file

@ -31,3 +31,83 @@ async fn main_kind_renders_a_main_element() {
assert!(html.starts_with("<main"));
assert!(html.ends_with("</main>"));
}
// **< Container::with_width >**********************************************************************
async fn container_html(width: impl Into<Option<container::Width>>) -> 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"));
}