diff --git a/assets/css/intro.css b/assets/css/intro.css index 5f07b378..b7679c3e 100644 --- a/assets/css/intro.css +++ b/assets/css/intro.css @@ -22,6 +22,10 @@ body { overflow-x: clip; } +/* + * Media queries: 768px and 992px match the default `Md` and `Lg` breakpoints of the core. + */ + .intro { position: relative; color: var(--intro-color); @@ -44,20 +48,6 @@ body { text-align: center; } -.intro a { - color: currentColor; - text-decoration: underline; - transition: font-size 0.2s, text-decoration-color 0.2s; -} -.intro a:focus-visible { - outline: var(--intro-focus-outline); - outline-offset: var(--intro-focus-outline-offset); -} -.intro a:hover, -.intro a:hover:visited { - text-decoration-color: var(--intro-color-link); -} - /* * Intro Header */ @@ -117,7 +107,7 @@ body { width: 280px; height: 280px; } -@media (min-width: 64rem) { +@media (width >= 992px) { .intro-header { background-image: var(--intro-bg-img); background-image: var(--intro-bg-img-set); @@ -166,12 +156,12 @@ body { .intro-content-body:after { bottom: -1rem; } -@media (width <= 48rem) { +@media (width < 768px) { .intro-content-body { margin-top: -9.8rem; } } -@media (width >= 64rem) { +@media (width >= 992px) { .intro-content { margin-top: 0; } @@ -208,6 +198,10 @@ body { min-height: 7.6875rem; outline: none; } +.intro-button-link:focus-visible { + outline: var(--intro-focus-outline); + outline-offset: var(--intro-focus-outline-offset); +} .intro-button-link::before { content: ''; position: absolute; @@ -310,7 +304,7 @@ body { .intro-button-link:hover span { animation-play-state: paused; } -@media (width <= 48rem) { +@media (width < 768px) { .intro-header { padding-bottom: 9rem;; } @@ -324,7 +318,7 @@ body { padding-top: .5rem; } } -@media (width > 48rem) { +@media (width >= 768px) { .intro-button { position: absolute; top: 0; @@ -361,12 +355,12 @@ body { font-size: 1.5rem; margin: 0 0 1.5rem; } -@media (width > 48rem) { +@media (width >= 768px) { .intro-button + .intro-text-body { padding-top: 7rem; } } -@media (width >= 64rem) { +@media (width >= 992px) { .intro-header { padding-bottom: 9rem;; } @@ -376,9 +370,18 @@ body { } .intro-text { box-shadow: var(--intro-shadow); - max-width: 60rem; + max-width: min(60rem, 100vw - 4rem); margin: 0 auto 6rem; } + .intro-text-narrow { + max-width: min(50rem, 100vw - 4rem); + } + .intro-text-wide { + max-width: min(67rem, 100vw - 4rem); + } + .intro-text-full { + max-width: min(74rem, 100vw - 4rem); + } .intro-text-body { padding-left: 4.5rem; padding-right: 4.5rem; @@ -479,6 +482,20 @@ body { .intro-footer-body a:visited { color: var(--intro-color-gray); } +/* The footer has its own dark surface, so its links don't inherit the theme's link colors. */ +.intro-footer-links a { + color: currentColor; + text-decoration: underline; + transition: font-size 0.2s, text-decoration-color 0.2s; +} +.intro-footer-links a:focus-visible { + outline: var(--intro-focus-outline); + outline-offset: var(--intro-focus-outline-offset); +} +.intro-footer-links a:hover, +.intro-footer-links a:hover:visited { + text-decoration-color: var(--intro-color-link); +} .intro-footer-logo, .intro-footer-links { display: flex; @@ -496,12 +513,12 @@ body { flex-wrap: wrap; margin-top: 2rem; } -@media (width <= 48rem) { +@media (width < 768px) { .intro-footer-logo { display: none; } } -@media (width <= 64rem) { +@media (width < 992px) { .intro-footer-body { padding: 0 1rem; } diff --git a/examples/form-controls.rs b/examples/form-controls.rs index e7311b21..e2c7dc43 100644 --- a/examples/form-controls.rs +++ b/examples/form-controls.rs @@ -19,11 +19,9 @@ impl Extension for FormControls { async fn form_controls(request: HttpRequest) -> Result { Page::new(request) .with_child( - Intro::default() - .with_opening(IntroOpening::Custom) + Intro::custom() .with_title(Lc::t("title", &LOC)) .with_slogan(Lc::t("slogan", &LOC)) - .with_button(None::<(Lc, Route)>) // Bloque 1: casillas, interruptores y botones de opción. .with_child( Block::new() diff --git a/examples/intro-colors.rs b/examples/intro-colors.rs index cccc303f..2cea5c84 100644 --- a/examples/intro-colors.rs +++ b/examples/intro-colors.rs @@ -14,11 +14,9 @@ impl Extension for IntroColors { async fn intro_colors(request: HttpRequest) -> Result { Page::new(request) .with_child( - Intro::default() - .with_opening(IntroOpening::Custom) + Intro::custom() .with_title(Lc::n("PageTop")) .with_slogan(Lc::t("colors_slogan", &LOC)) - .with_button(None::<(Lc, Route)>) .with_child( Block::new() .with_title(Lc::t("colors_block", &LOC).with_arg("n", "1")) diff --git a/examples/intro-flex.rs b/examples/intro-flex.rs index 4e07a0bb..4f810f61 100644 --- a/examples/intro-flex.rs +++ b/examples/intro-flex.rs @@ -20,11 +20,9 @@ async fn intro_flex(request: HttpRequest) -> Result { .with_assets(demo_box_styles()) .with_assets(demo_row_styles()) .with_child( - Intro::default() - .with_opening(IntroOpening::Custom) + Intro::custom() .with_title(Lc::n("PageTop")) .with_slogan(Lc::t("flex_slogan", &LOC)) - .with_button(None::<(Lc, Route)>) .with_child(direction_block()) .with_child(justify_block()) .with_child(align_block()) diff --git a/examples/intro-grid.rs b/examples/intro-grid.rs index c22c3912..71d7b6c6 100644 --- a/examples/intro-grid.rs +++ b/examples/intro-grid.rs @@ -20,11 +20,9 @@ async fn intro_grid(request: HttpRequest) -> Result { .with_assets(demo_box_styles()) .with_assets(demo_row_styles()) .with_child( - Intro::default() - .with_opening(IntroOpening::Custom) + Intro::custom() .with_title(Lc::n("PageTop")) .with_slogan(Lc::t("grid_slogan", &LOC)) - .with_button(None::<(Lc, Route)>) .with_child(columns_block()) .with_child(rows_gap_block()) .with_child(placement_block()) @@ -472,7 +470,8 @@ fn layout_block() -> Block { // **< HELPERS >************************************************************************************ // Aspecto fijo de las cajas de muestra: esquinas rectas y borde claro, a diferencia de las cajas -// redondeadas de `intro-flex.rs`, para distinguir a simple vista los ejemplos de Grid de los de Flex. +// redondeadas de `intro-flex.rs`, para distinguir a simple vista los ejemplos de Grid de los de +// Flex. fn demo_box_styles() -> AssetsOp { AssetsOp::add_responsive_styles( None, diff --git a/examples/intro-responsive.rs b/examples/intro-responsive.rs index 6306a678..3b06df66 100644 --- a/examples/intro-responsive.rs +++ b/examples/intro-responsive.rs @@ -21,11 +21,9 @@ async fn intro_responsive(request: HttpRequest) -> Result { .with_assets(demo_row_styles()) .with_assets(demo_code_styles()) .with_child( - Intro::default() - .with_opening(IntroOpening::Custom) + Intro::custom() .with_title(Lc::n("PageTop")) .with_slogan(Lc::t("responsive_slogan", &LOC)) - .with_button(None::<(Lc, Route)>) .with_child(Html::with(|cx| { html! { p class="intro-text-lead" { diff --git a/examples/intro-spacing.rs b/examples/intro-spacing.rs index eed4c38f..a3b0d51a 100644 --- a/examples/intro-spacing.rs +++ b/examples/intro-spacing.rs @@ -21,11 +21,9 @@ async fn intro_spacing(request: HttpRequest) -> Result { .with_assets(demo_row_styles()) .with_assets(demo_code_styles()) .with_child( - Intro::default() - .with_opening(IntroOpening::Custom) + Intro::custom() .with_title(Lc::n("PageTop")) .with_slogan(Lc::t("spacing_slogan", &LOC)) - .with_button(None::<(Lc, Route)>) .with_child(Html::with(|cx| { html! { p class="intro-text-lead" { diff --git a/src/base/component.rs b/src/base/component.rs index 807c57e2..1365c704 100644 --- a/src/base/component.rs +++ b/src/base/component.rs @@ -47,8 +47,9 @@ pub mod image; #[doc(inline)] pub use image::Image; -mod intro; -pub use intro::{Intro, IntroOpening}; +pub mod intro; +#[doc(inline)] +pub use intro::Intro; mod messages; pub use messages::Messages; diff --git a/src/base/component/intro.rs b/src/base/component/intro.rs index 00fa866b..e7403eae 100644 --- a/src/base/component/intro.rs +++ b/src/base/component/intro.rs @@ -1,292 +1,7 @@ -use crate::prelude::*; +//! Definiciones para crear introducciones ([`Intro`]). -/// Tipo de apertura que se mostrará en la introducción del componente [`Intro`]. -/// -/// Permite elegir entre una apertura con textos predefinidos sobre PageTop (como hace la página de -/// bienvenida [`Welcome`](crate::base::extension::Welcome)) o una introducción completamente -/// personalizada. -#[derive(AutoDefault, Copy, Clone, Debug, Eq, PartialEq)] -pub enum IntroOpening { - /// Modo por defecto. Muestra una introducción estándar de PageTop e incluye automáticamente - /// *badges* con información de la última versión liberada, fecha del último lanzamiento y - /// licencia de uso. - #[default] - PageTop, - /// Modo totalmente personalizado. No añade *badges* ni textos predefinidos. Usa la imagen de - /// PageTop pero el contenido lo define el propio desarrollador. - Custom, -} +mod props; +pub use props::{Kind, Width}; -/// Componente para divulgar PageTop (como hace [`Welcome`](crate::base::extension::Welcome)), o -/// mostrar presentaciones. -/// -/// Usa la imagen de PageTop para mostrar: -/// -/// - Una **figura decorativa** (que incluye la *mascota* de PageTop) antecediendo al contenido. -/// - Una vista destacada del **título** de la página con un **eslogan** de presentación. -/// - Un **botón opcional** de llamada a la acción con texto y enlace configurables. -/// - Un **área para la presentación de contenidos**, con *badges* informativos de PageTop (si se -/// opta por [`IntroOpening::PageTop`]) y bloques ([`Block`](crate::base::component::Block)) de -/// contenido libre. Los párrafos que tengan la clase `.intro-text-lead` se mostrarán con una -/// tipografía ampliada, ideal para presentaciones breves e impactantes. -/// -/// # Ejemplos -/// -/// **Intro mínima por defecto** -/// -/// ```rust,no_run -/// # use pagetop::prelude::*; -/// let intro = Intro::default(); -/// ``` -/// -/// **Título, eslogan y botón personalizados** -/// -/// ```rust,no_run -/// # use pagetop::prelude::*; -/// let intro = Intro::default() -/// .with_title(Lc::l("intro_custom_title")) -/// .with_slogan(Lc::l("intro_custom_slogan")) -/// .with_button(Some(( -/// Lc::l("intro_learn_more"), -/// "/learn-more".into() -/// ))); -/// ``` -/// -/// **Sin botón y en modo *Custom* (sin *badges* predefinidos)** -/// -/// ```rust,no_run -/// # use pagetop::prelude::*; -/// let intro = Intro::default() -/// .with_button(None::<(Lc, Route)>) -/// .with_opening(IntroOpening::Custom); -/// ``` -/// -/// **Añadir contenidos hijo** -/// -/// ```rust,no_run -/// # use pagetop::prelude::*; -/// let intro = Intro::default() -/// .with_child( -/// Block::new() -/// .with_title(Lc::l("intro_custom_block_title")) -/// .with_child(Html::with(move |cx| { -/// html! { -/// p class="intro-text-lead" { -/// (Lc::l("intro_custom_paragraph_1").using(cx)) -/// } -/// p { (Lc::l("intro_custom_paragraph_2").using(cx)) } -/// } -/// })), -/// ); -/// ``` -#[derive(Clone, Debug, Getters)] -pub struct Intro { - /// Devuelve el título de entrada. - title: Lc, - /// Devuelve el eslogan de la entrada. - slogan: Lc, - /// Devuelve el botón de llamada a la acción, si existe. - button: Option<(Lc, Route)>, - /// Devuelve el modo de apertura configurado. - opening: IntroOpening, - /// Devuelve la lista de componentes hijo de la intro. - children: Children, -} - -impl Default for Intro { - fn default() -> Self { - const BUTTON_LINK: &str = "https://pagetop.cillero.es"; - - Intro { - title: Lc::l("intro_default_title"), - slogan: Lc::l("intro_default_slogan").with_arg("app", &global::SETTINGS.app.name), - button: Some((Lc::l("intro_default_button"), BUTTON_LINK.into())), - opening: IntroOpening::default(), - children: Children::default(), - } - } -} - -#[async_trait] -impl Component for Intro { - fn new() -> Self { - Self::default() - } - - async fn prepare(&self, cx: &mut Context) -> Result { - cx.alter_assets(StyleSheet::from("/pagetop/css/intro.css").with_version(PAGETOP_VERSION)); - if *self.opening() == IntroOpening::PageTop { - cx.alter_assets(JavaScript::on_load_async("intro-js", |cx| - util::indoc!(r#" - try { - const resp = await fetch("https://crates.io/api/v1/crates/pagetop"); - const data = await resp.json(); - const date = new Date(data.versions[0].created_at); - const formatted = date.toLocaleDateString("LANGID", { year: "numeric", month: "2-digit", day: "2-digit" }); - document.getElementById("intro-release").src = `https://img.shields.io/badge/Release%20date-${encodeURIComponent(formatted)}-blue?label=LABEL&style=for-the-badge`; - } catch (e) { - console.error("Failed to fetch release date from crates.io:", e); - } finally { - document.getElementById("intro-badges").style.visibility = "visible"; - } - "#) - .replace("LANGID", cx.langid().to_string().as_str()) - .replace("LABEL", Lc::l("intro_release_label").using(cx).as_str()) - )); - } - - Ok(html! { - div class="intro" { - div class="intro-header" { - section class="intro-header-body" { - h1 class="intro-header-title" { - span { (self.title().using(cx)) } - (self.slogan().using(cx)) - } - } - aside class="intro-header-img" aria-hidden="true" { - div class="intro-header-mascot" { - (PageTopSvg::Color.markup()) - } - } - } - div class="intro-content" { - section class="intro-content-body" { - div class="intro-text" { - @if let Some((txt, lnk)) = self.button() { - div class="intro-button" { - a - class="intro-button-link" - href=(lnk.resolve(cx)) - target="_blank" - rel="noopener noreferrer" - { - span {} span {} span {} - div class="intro-button-text" { - (txt.using(cx)) - } - } - } - } - div class="intro-text-body" { - @if *self.opening() == IntroOpening::PageTop { - p class="intro-text-lead" { - (Lc::l("intro_text1").using(cx)) - } - div id="intro-badges" { - img - src="https://img.shields.io/crates/v/pagetop.svg?label=PageTop&style=for-the-badge" - alt=[Lc::l("intro_pagetop_label").lookup(cx)] {} (" ") - img - id="intro-release" - alt=[Lc::l("intro_release_label").lookup(cx)] {} (" ") - img - src=(format!( - "https://img.shields.io/badge/license-MIT%2FApache-blue.svg?label={}&style=for-the-badge", - Lc::l("intro_license_label").lookup(cx).unwrap_or_default() - )) - alt=[Lc::l("intro_license_label").lookup(cx)] {} - } - p class="intro-text-lead" { - (Lc::l("intro_text2").using(cx)) - } - } - (self.children().render(cx).await) - } - } - } - } - div class="intro-footer" { - section class="intro-footer-body" { - div class="intro-footer-logo" { - (PageTopSvg::LineLight.markup()) - } - div class="intro-footer-links" { - a href="https://crates.io/crates/pagetop" target="_blank" rel="noopener noreferrer" { ("Crates.io") } - a href="https://docs.rs/pagetop" target="_blank" rel="noopener noreferrer" { ("Docs.rs") } - a href="https://git.cillero.es/manuelcillero/pagetop" target="_blank" rel="noopener noreferrer" { (Lc::l("intro_code").using(cx)) } - em { (Lc::l("intro_have_fun").using(cx)) } - } - } - } - } - }) - } -} - -#[builder_impl] -impl Intro { - // **< Intro BUILDER >************************************************************************** - - /// Establece el título de entrada. - /// - /// # Ejemplo - /// - /// ```rust,no_run - /// # use pagetop::prelude::*; - /// let intro = Intro::default().with_title(Lc::n("Intro title")); - /// ``` - pub fn with_title(mut self, title: Lc) -> Self { - self.title = title; - self - } - - /// Establece el eslogan de entrada (línea secundaria del título). - /// - /// # Ejemplo - /// - /// ```rust,no_run - /// # use pagetop::prelude::*; - /// let intro = Intro::default().with_slogan(Lc::n("A short slogan")); - /// ``` - pub fn with_slogan(mut self, slogan: Lc) -> Self { - self.slogan = slogan; - self - } - - /// Configura el botón opcional de llamada a la acción. - /// - /// - Usa `Some((texto, ruta))` para mostrarlo, donde [`Route`] resuelve la ruta o URL final al - /// pulsar el botón según el contexto de renderizado. - /// - Usa `None` para ocultarlo. - /// - /// # Ejemplo - /// - /// ```rust,no_run - /// # use pagetop::prelude::*; - /// // Define un botón con texto y una ruta interna (preserva `lang` si corresponde). - /// let intro = Intro::default().with_button(Some((Lc::n("Start"), "/start".into()))); - /// // Descarta el botón de la intro. - /// let intro_no_button = Intro::default().with_button(None); - /// ``` - pub fn with_button(mut self, button: impl Into>) -> Self { - self.button = button.into(); - self - } - - /// Selecciona el tipo de apertura: [`IntroOpening::PageTop`] (por defecto) o - /// [`IntroOpening::Custom`]. - /// - /// - `PageTop`: añade *badges* automáticos y una presentación de lo que es PageTop. - /// - `Custom`: introducción en blanco para añadir cualquier contenido. - /// - /// # Ejemplo - /// - /// ```rust,no_run - /// # use pagetop::prelude::*; - /// let intro = Intro::default().with_opening(IntroOpening::Custom); - /// ``` - pub fn with_opening(mut self, opening: IntroOpening) -> Self { - self.opening = opening; - self - } - - /// Añade un nuevo componente a la intro o modifica la lista de componentes (`children`) con una - /// operación [`ChildOp`]. - /// - /// Si se añade un bloque ([`Block`]) se aplicarán estilos específicos para destacarlo. - pub fn with_child(mut self, op: impl Into) -> Self { - self.children.alter_child(op.into()); - self - } -} +mod component; +pub use component::Intro; diff --git a/src/base/component/intro/component.rs b/src/base/component/intro/component.rs new file mode 100644 index 00000000..52a80439 --- /dev/null +++ b/src/base/component/intro/component.rs @@ -0,0 +1,320 @@ +use crate::prelude::*; + +/// Componente para divulgar PageTop (como hace [`Welcome`](crate::base::extension::Welcome)), o +/// mostrar presentaciones. +/// +/// Usa la imagen de PageTop para mostrar: +/// +/// - Una **figura decorativa** (que incluye la *mascota* de PageTop) antecediendo al contenido. +/// - Una vista destacada del **título** de la página con un **eslogan** de presentación. +/// - Un **botón opcional** de llamada a la acción con texto y enlace configurables. +/// - Un **área para la presentación de contenidos**, con *badges* informativos de PageTop (si se +/// opta por [`intro::Kind::PageTop`]) y bloques ([`Block`](crate::base::component::Block)) de +/// contenido libre. Los párrafos que tengan la clase `.intro-text-lead` se mostrarán con una +/// tipografía ampliada, ideal para presentaciones breves e impactantes. +/// +/// # Ejemplos +/// +/// **Intro mínima por defecto** +/// +/// ```rust,no_run +/// # use pagetop::prelude::*; +/// let intro = Intro::default(); +/// ``` +/// +/// **Título, eslogan y botón personalizados** +/// +/// ```rust,no_run +/// # use pagetop::prelude::*; +/// let intro = Intro::default() +/// .with_title(Lc::l("intro_custom_title")) +/// .with_slogan(Lc::l("intro_custom_slogan")) +/// .with_button(Some(( +/// Lc::l("intro_learn_more"), +/// "/learn-more".into() +/// ))); +/// ``` +/// +/// **Modo *Custom* con título y botón propios** +/// +/// ```rust,no_run +/// # use pagetop::prelude::*; +/// let intro = Intro::custom() +/// .with_title(Lc::l("intro_custom_title")) +/// .with_button(Some((Lc::l("intro_learn_more"), "/learn-more".into()))); +/// ``` +/// +/// **Área de contenidos al ancho máximo** +/// +/// ```rust,no_run +/// # use pagetop::prelude::*; +/// let intro = Intro::default().with_width(intro::Width::Full); +/// ``` +/// +/// **Añadir contenidos hijo** +/// +/// ```rust,no_run +/// # use pagetop::prelude::*; +/// let intro = Intro::default() +/// .with_child( +/// Block::new() +/// .with_title(Lc::l("intro_custom_block_title")) +/// .with_child(Html::with(move |cx| { +/// html! { +/// p class="intro-text-lead" { +/// (Lc::l("intro_custom_paragraph_1").using(cx)) +/// } +/// p { (Lc::l("intro_custom_paragraph_2").using(cx)) } +/// } +/// })), +/// ); +/// ``` +#[derive(Clone, Debug, Getters)] +pub struct Intro { + /// Devuelve el título de entrada. + title: Lc, + /// Devuelve el eslogan de la entrada. + slogan: Lc, + /// Devuelve el botón de llamada a la acción, si existe. + button: Option<(Lc, Route)>, + /// Devuelve el tipo de introducción, fijado al construirla. + kind: intro::Kind, + /// Devuelve el ancho máximo del área de contenidos. + width: intro::Width, + /// Devuelve la lista de componentes hijo de la intro. + children: Children, +} + +impl Default for Intro { + fn default() -> Self { + const BUTTON_LINK: &str = "https://pagetop.cillero.es"; + + Intro { + title: Lc::l("intro_default_title"), + slogan: Lc::l("intro_default_slogan").with_arg("app", &global::SETTINGS.app.name), + button: Some((Lc::l("intro_default_button"), BUTTON_LINK.into())), + kind: intro::Kind::default(), + width: intro::Width::default(), + children: Children::default(), + } + } +} + +#[async_trait] +impl Component for Intro { + fn new() -> Self { + Self::default() + } + + async fn prepare(&self, cx: &mut Context) -> Result { + cx.alter_assets(StyleSheet::from("/pagetop/css/intro.css").with_version(PAGETOP_VERSION)); + if *self.kind() == intro::Kind::PageTop { + cx.alter_assets(JavaScript::on_load_async("intro-js", |cx| + util::indoc!(r#" + try { + const resp = await fetch("https://crates.io/api/v1/crates/pagetop"); + const data = await resp.json(); + const date = new Date(data.versions[0].created_at); + const formatted = date.toLocaleDateString("LANGID", { year: "numeric", month: "2-digit", day: "2-digit" }); + document.getElementById("intro-release").src = `https://img.shields.io/badge/Release%20date-${encodeURIComponent(formatted)}-blue?label=LABEL&style=for-the-badge`; + } catch (e) { + console.error("Failed to fetch release date from crates.io:", e); + } finally { + document.getElementById("intro-badges").style.visibility = "visible"; + } + "#) + .replace("LANGID", cx.langid().to_string().as_str()) + .replace("LABEL", Lc::l("intro_release_label").using(cx).as_str()) + )); + } + + let title = self.title().using(cx); + let slogan = self.slogan().using(cx); + + Ok(html! { + div class="intro" { + div class="intro-header" { + @if !title.is_empty() || !slogan.is_empty() { + section class="intro-header-body" { + h1 class="intro-header-title" { + @if !title.is_empty() { + span { (title) } + } + (slogan) + } + } + } + aside class="intro-header-img" aria-hidden="true" { + div class="intro-header-mascot" { + (PageTopSvg::Color.markup()) + } + } + } + div class="intro-content" { + section class="intro-content-body" { + div class=(self.width().classes()) { + @if let Some((txt, lnk)) = self.button() { + div class="intro-button" { + a + class="intro-button-link" + href=(lnk.resolve(cx)) + target="_blank" + rel="noopener noreferrer" + { + span {} span {} span {} + div class="intro-button-text" { + (txt.using(cx)) + } + } + } + } + div class="intro-text-body" { + @if *self.kind() == intro::Kind::PageTop { + p class="intro-text-lead" { + (Lc::l("intro_text1").using(cx)) + } + div id="intro-badges" { + img + src="https://img.shields.io/crates/v/pagetop.svg?label=PageTop&style=for-the-badge" + alt=[Lc::l("intro_pagetop_label").lookup(cx)] {} (" ") + img + id="intro-release" + alt=[Lc::l("intro_release_label").lookup(cx)] {} (" ") + img + src=(util::join!( + "https://img.shields.io/badge/license-MIT%2FApache-blue.svg?label=", + Lc::l("intro_license_label").lookup(cx).unwrap_or_default(), + "&style=for-the-badge" + )) + alt=[Lc::l("intro_license_label").lookup(cx)] {} + } + p class="intro-text-lead" { + (Lc::l("intro_text2").using(cx)) + } + } + (self.children().render(cx).await) + } + } + } + } + div class="intro-footer" { + section class="intro-footer-body" { + div class="intro-footer-logo" { + (PageTopSvg::LineLight.markup()) + } + div class="intro-footer-links" { + a href="https://crates.io/crates/pagetop" target="_blank" rel="noopener noreferrer" { ("Crates.io") } + a href="https://docs.rs/pagetop" target="_blank" rel="noopener noreferrer" { ("Docs.rs") } + a href="https://git.cillero.es/manuelcillero/pagetop" target="_blank" rel="noopener noreferrer" { (Lc::l("intro_code").using(cx)) } + em { (Lc::l("intro_have_fun").using(cx)) } + } + } + } + } + }) + } +} + +#[builder_impl] +impl Intro { + // **< Intro BUILDER >************************************************************************** + + /// Crea una introducción de tipo [`intro::Kind::Custom`]. + /// + /// Parte de una introducción sin el contenido de presentación de PageTop (párrafos y *badges*), + /// sin título, sin eslogan y sin botón de llamada a la acción. Todo ello puede añadirse con + /// [`with_title()`], [`with_slogan()`] y [`with_button()`]. + /// + /// El tipo se fija al construirla y no se puede cambiar después; para la introducción estándar + /// de PageTop (tipo [`intro::Kind::PageTop`]) se usa [`Intro::default()`] o [`Intro::new()`]. + /// + /// # Ejemplo + /// + /// ```rust,no_run + /// # use pagetop::prelude::*; + /// let intro = Intro::custom().with_title(Lc::n("Intro title")); + /// ``` + /// + /// [`with_title()`]: Self::with_title + /// [`with_slogan()`]: Self::with_slogan + /// [`with_button()`]: Self::with_button + pub fn custom() -> Self { + Self { + kind: intro::Kind::Custom, + title: Lc::none(), + slogan: Lc::none(), + button: None, + ..Default::default() + } + } + + /// Establece el título de entrada. + /// + /// # Ejemplo + /// + /// ```rust,no_run + /// # use pagetop::prelude::*; + /// let intro = Intro::default().with_title(Lc::n("Intro title")); + /// ``` + pub fn with_title(mut self, title: Lc) -> Self { + self.title = title; + self + } + + /// Establece el eslogan de entrada (línea secundaria del título). + /// + /// # Ejemplo + /// + /// ```rust,no_run + /// # use pagetop::prelude::*; + /// let intro = Intro::default().with_slogan(Lc::n("A short slogan")); + /// ``` + pub fn with_slogan(mut self, slogan: Lc) -> Self { + self.slogan = slogan; + self + } + + /// Configura el botón opcional de llamada a la acción. + /// + /// - Usa `Some((texto, ruta))` para mostrarlo, donde [`Route`] resuelve la ruta o URL final al + /// pulsar el botón según el contexto de renderizado. + /// - Usa `None` para ocultarlo. + /// + /// # Ejemplo + /// + /// ```rust,no_run + /// # use pagetop::prelude::*; + /// // Define un botón con texto y una ruta interna (preserva `lang` si corresponde). + /// let intro = Intro::default().with_button(Some((Lc::n("Start"), "/start".into()))); + /// // Descarta el botón de la intro. + /// let intro_no_button = Intro::default().with_button(None); + /// ``` + pub fn with_button(mut self, button: impl Into>) -> Self { + self.button = button.into(); + self + } + + /// Establece el ancho máximo del área de contenidos. + /// + /// Ver [`intro::Width`] para las variantes disponibles. + /// + /// # Ejemplo + /// + /// ```rust,no_run + /// # use pagetop::prelude::*; + /// let intro = Intro::default().with_width(intro::Width::Wide); + /// ``` + pub fn with_width(mut self, width: intro::Width) -> Self { + self.width = width; + self + } + + /// Añade un nuevo componente a la intro o modifica la lista de componentes (`children`) con una + /// operación [`ChildOp`]. + /// + /// Si se añade un bloque ([`Block`]) se aplicarán estilos específicos para destacarlo. + pub fn with_child(mut self, op: impl Into) -> Self { + self.children.alter_child(op.into()); + self + } +} diff --git a/src/base/component/intro/props.rs b/src/base/component/intro/props.rs new file mode 100644 index 00000000..b5f5cad3 --- /dev/null +++ b/src/base/component/intro/props.rs @@ -0,0 +1,60 @@ +use crate::prelude::*; + +// **< Kind >*************************************************************************************** + +/// Tipo de introducción de un componente [`Intro`](super::Intro). +/// +/// Distingue entre una presentación estándar sobre PageTop (la que usa, por ejemplo, la página de +/// bienvenida [`Welcome`]) y una introducción completamente personalizada. +/// +/// Se fija al construir la introducción, con [`Intro::default()`] o [`Intro::new()`] para +/// [`Kind::PageTop`], o con [`Intro::custom()`] para [`Kind::Custom`], y se consulta con +/// [`Intro::kind()`]. +/// +/// [`Welcome`]: crate::base::extension::Welcome +/// [`Intro::default()`]: super::Intro::default +/// [`Intro::new()`]: super::Intro::new +/// [`Intro::custom()`]: super::Intro::custom +/// [`Intro::kind()`]: super::Intro::kind +#[derive(AutoDefault, Clone, Copy, Debug, Eq, PartialEq)] +pub enum Kind { + /// Presentación predeterminada. Muestra una introducción estándar de PageTop e incluye + /// automáticamente *badges* con información sobre la última versión liberada, fecha del último + /// lanzamiento y licencia de uso. + #[default] + PageTop, + /// Modo personalizado. Usa la imagen de PageTop pero sin contenido de ningún tipo. + Custom, +} + +// **< Width >************************************************************************************** + +/// Ancho máximo para el área de contenidos de un componente [`Intro`](super::Intro). +/// +/// Sólo afecta a partir del punto de corte [`Breakpoint::Lg`], donde el área de contenidos se +/// muestra como un panel centrado. Por debajo ocupa siempre todo el ancho disponible. En pantallas +/// que no alcanzan el ancho máximo elegido, el panel se reduce para dejar un margen lateral. +#[derive(AutoDefault, Clone, Copy, Debug, Eq, PartialEq)] +pub enum Width { + /// Ancho estrecho. + Narrow, + /// Ancho por defecto. + #[default] + Normal, + /// Ancho amplio. + Wide, + /// Ancho más amplio de los disponibles. + Full, +} + +impl Width { + // Clases CSS del área de contenidos: la base más la modificadora, si el ancho no es el normal. + pub(super) fn classes(self) -> &'static str { + match self { + Width::Narrow => "intro-text intro-text-narrow", + Width::Normal => "intro-text", + Width::Wide => "intro-text intro-text-wide", + Width::Full => "intro-text intro-text-full", + } + } +} diff --git a/src/core/theme/definition.rs b/src/core/theme/definition.rs index b951e52b..9792877a 100644 --- a/src/core/theme/definition.rs +++ b/src/core/theme/definition.rs @@ -1,5 +1,5 @@ use crate::async_trait; -use crate::base::component::{Html, Intro, IntroOpening, layout}; +use crate::base::component::{Html, Intro, layout}; use crate::core::component::{ChildOp, Component, ComponentError, ComponentRender}; use crate::core::component::{Context, Contextual}; use crate::core::extension::Extension; @@ -413,11 +413,9 @@ pub trait Theme: Extension + Send + Sync { page.alter_title(title).alter_child_in( &CoreRegions::Content, ChildOp::Prepend( - Intro::new() + Intro::custom() .with_title(Lc::l("error_code").with_arg("code", code.to_string())) .with_slogan(Lc::n(code.to_string())) - .with_button(None) - .with_opening(IntroOpening::Custom) .with_child(Html::with(move |cx| { html! { h1 { (alert.using(cx)) }