♻️ (base): Reorganiza el componente Intro
This commit is contained in:
parent
404739a3fd
commit
16eec316c4
12 changed files with 439 additions and 339 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
320
src/base/component/intro/component.rs
Normal file
320
src/base/component/intro/component.rs
Normal 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
|
||||
}
|
||||
}
|
||||
60
src/base/component/intro/props.rs
Normal file
60
src/base/component/intro/props.rs
Normal 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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)) }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue