♻️ (base): Reorganiza el componente Intro

This commit is contained in:
Manuel Cillero 2026-09-20 23:53:18 +02:00
parent 404739a3fd
commit 16eec316c4
12 changed files with 439 additions and 339 deletions

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

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

@ -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)) }