Compare commits
4 commits
9143f1eb26
...
16eec316c4
| Author | SHA1 | Date | |
|---|---|---|---|
| 16eec316c4 | |||
| 404739a3fd | |||
| 8651b91ab2 | |||
| c6cd4fcd7d |
67 changed files with 965 additions and 728 deletions
8
Cargo.lock
generated
8
Cargo.lock
generated
|
|
@ -396,12 +396,6 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "concat-string"
|
|
||||||
version = "1.0.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7439becb5fafc780b6f4de382b1a7a3e70234afe783854a4702ee8adbb838609"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "concurrent-queue"
|
name = "concurrent-queue"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
|
|
@ -1881,7 +1875,6 @@ dependencies = [
|
||||||
name = "pagetop-menu"
|
name = "pagetop-menu"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
|
||||||
"pagetop",
|
"pagetop",
|
||||||
"pagetop-seaorm",
|
"pagetop-seaorm",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
@ -1892,7 +1885,6 @@ dependencies = [
|
||||||
name = "pagetop-minimal"
|
name = "pagetop-minimal"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"concat-string",
|
|
||||||
"indoc",
|
"indoc",
|
||||||
"pastey",
|
"pastey",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ base64ct = { version = "1.8", features = ["alloc"] }
|
||||||
change-detection = "1.2"
|
change-detection = "1.2"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
colored = "3.1"
|
colored = "3.1"
|
||||||
concat-string = "1.0"
|
|
||||||
config = { version = "0.15", default-features = false, features = ["toml"] }
|
config = { version = "0.15", default-features = false, features = ["toml"] }
|
||||||
figlet-rs = "1.0"
|
figlet-rs = "1.0"
|
||||||
fluent-templates = "0.14"
|
fluent-templates = "0.14"
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ body {
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header, Aside, Content y Footer se apilan en el mismo orden ocupando todo el ancho disponible. */
|
/* Header, Aside, Content and Footer are stacked in that order, using the full available width. */
|
||||||
.wrapper {
|
.wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -125,10 +125,10 @@ body {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Geometría base de .dropdown-button/.dropdown-toggle (ver "Dropdown component" más abajo). Se
|
/* Base geometry of .dropdown-button/.dropdown-toggle (see "Dropdown component" below). It is
|
||||||
* declara aquí, antes de "Buttons", para que las clases .button-sm/.button-lg/.button-* ganen el
|
* declared here, before "Buttons", so that the .button-sm/.button-lg/.button-* classes win the
|
||||||
* empate de especificidad frente a ella cuando se combinan en el mismo elemento (CSS resuelve un
|
* specificity tie against it when combined on the same element (CSS resolves a tie by order of
|
||||||
* empate por orden de aparición en el fichero, no por el orden de las clases en el HTML). */
|
* appearance in the file, not by the order of the classes in the HTML). */
|
||||||
.dropdown-button,
|
.dropdown-button,
|
||||||
.dropdown-toggle {
|
.dropdown-toggle {
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
|
|
@ -247,6 +247,79 @@ body {
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Container component
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Classes added by the `Container` component from `Width`. All of them are centered and fill the
|
||||||
|
* available width; `.container` and `.container-{name}` also get a maximum width from the
|
||||||
|
* breakpoint they name (`.container` from the first one). The minimum widths match the default
|
||||||
|
* `Theme::breakpoint_entry()` table, so a child theme that renames or resizes the breakpoints must
|
||||||
|
* also redefine these classes. */
|
||||||
|
.container,
|
||||||
|
.container-fluid,
|
||||||
|
.container-sm,
|
||||||
|
.container-md,
|
||||||
|
.container-lg,
|
||||||
|
.container-xl,
|
||||||
|
.container-xxl,
|
||||||
|
.container-xxxl {
|
||||||
|
width: 100%;
|
||||||
|
margin-inline: auto;
|
||||||
|
padding-inline: 1rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 576px) {
|
||||||
|
.container,
|
||||||
|
.container-sm {
|
||||||
|
max-width: 540px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.container,
|
||||||
|
.container-sm,
|
||||||
|
.container-md {
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
.container,
|
||||||
|
.container-sm,
|
||||||
|
.container-md,
|
||||||
|
.container-lg {
|
||||||
|
max-width: 960px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
.container,
|
||||||
|
.container-sm,
|
||||||
|
.container-md,
|
||||||
|
.container-lg,
|
||||||
|
.container-xl {
|
||||||
|
max-width: 1140px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 1400px) {
|
||||||
|
.container,
|
||||||
|
.container-sm,
|
||||||
|
.container-md,
|
||||||
|
.container-lg,
|
||||||
|
.container-xl,
|
||||||
|
.container-xxl {
|
||||||
|
max-width: 1320px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 1920px) {
|
||||||
|
.container,
|
||||||
|
.container-sm,
|
||||||
|
.container-md,
|
||||||
|
.container-lg,
|
||||||
|
.container-xl,
|
||||||
|
.container-xxl,
|
||||||
|
.container-xxxl {
|
||||||
|
max-width: 1800px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Dialog component
|
* Dialog component
|
||||||
*/
|
*/
|
||||||
|
|
@ -318,10 +391,10 @@ body {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* La geometría base de .dropdown-button/.dropdown-toggle vive junto a "Buttons" (antes de esta
|
/* The base geometry of .dropdown-button/.dropdown-toggle lives next to "Buttons" (before this
|
||||||
* sección), para que las clases .button-sm/.button-lg/.button-* ganen el empate de especificidad
|
* section), so that the .button-sm/.button-lg/.button-* classes win the specificity tie against it
|
||||||
* frente a ella (CSS resuelve un empate por orden de aparición en el fichero, no por el orden de
|
* (CSS resolves a tie by order of appearance in the file, not by the order of the classes in the
|
||||||
* las clases en el HTML). */
|
* HTML). */
|
||||||
.dropdown-toggle::after {
|
.dropdown-toggle::after {
|
||||||
content: "\25BC";
|
content: "\25BC";
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
@ -928,9 +1001,9 @@ input:disabled + label {
|
||||||
color: var(--val-color--text--muted);
|
color: var(--val-color--text--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* .page-link-icon muestra el texto de navegación de los botones anterior/siguiente. El texto
|
/* .page-link-icon shows the navigation text of the previous/next buttons. The full accessible text
|
||||||
accesible completo va aparte, en el aria-label del enlace. Por eso sustituir el texto visible por
|
goes separately, in the link's aria-label. So replacing the visible text with other content does
|
||||||
otro contenido no afecta a la accesibilidad, p. ej. para usar "‹"/"›" se puede aplicar:
|
not affect accessibility, e.g. to use "‹"/"›" you can apply:
|
||||||
|
|
||||||
.page-link-icon {
|
.page-link-icon {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,10 @@ body {
|
||||||
overflow-x: clip;
|
overflow-x: clip;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Media queries: 768px and 992px match the default `Md` and `Lg` breakpoints of the core.
|
||||||
|
*/
|
||||||
|
|
||||||
.intro {
|
.intro {
|
||||||
position: relative;
|
position: relative;
|
||||||
color: var(--intro-color);
|
color: var(--intro-color);
|
||||||
|
|
@ -44,20 +48,6 @@ body {
|
||||||
text-align: center;
|
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
|
* Intro Header
|
||||||
*/
|
*/
|
||||||
|
|
@ -117,7 +107,7 @@ body {
|
||||||
width: 280px;
|
width: 280px;
|
||||||
height: 280px;
|
height: 280px;
|
||||||
}
|
}
|
||||||
@media (min-width: 64rem) {
|
@media (width >= 992px) {
|
||||||
.intro-header {
|
.intro-header {
|
||||||
background-image: var(--intro-bg-img);
|
background-image: var(--intro-bg-img);
|
||||||
background-image: var(--intro-bg-img-set);
|
background-image: var(--intro-bg-img-set);
|
||||||
|
|
@ -166,12 +156,12 @@ body {
|
||||||
.intro-content-body:after {
|
.intro-content-body:after {
|
||||||
bottom: -1rem;
|
bottom: -1rem;
|
||||||
}
|
}
|
||||||
@media (width <= 48rem) {
|
@media (width < 768px) {
|
||||||
.intro-content-body {
|
.intro-content-body {
|
||||||
margin-top: -9.8rem;
|
margin-top: -9.8rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (width >= 64rem) {
|
@media (width >= 992px) {
|
||||||
.intro-content {
|
.intro-content {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -208,6 +198,10 @@ body {
|
||||||
min-height: 7.6875rem;
|
min-height: 7.6875rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
.intro-button-link:focus-visible {
|
||||||
|
outline: var(--intro-focus-outline);
|
||||||
|
outline-offset: var(--intro-focus-outline-offset);
|
||||||
|
}
|
||||||
.intro-button-link::before {
|
.intro-button-link::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
@ -310,7 +304,7 @@ body {
|
||||||
.intro-button-link:hover span {
|
.intro-button-link:hover span {
|
||||||
animation-play-state: paused;
|
animation-play-state: paused;
|
||||||
}
|
}
|
||||||
@media (width <= 48rem) {
|
@media (width < 768px) {
|
||||||
.intro-header {
|
.intro-header {
|
||||||
padding-bottom: 9rem;;
|
padding-bottom: 9rem;;
|
||||||
}
|
}
|
||||||
|
|
@ -324,7 +318,7 @@ body {
|
||||||
padding-top: .5rem;
|
padding-top: .5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (width > 48rem) {
|
@media (width >= 768px) {
|
||||||
.intro-button {
|
.intro-button {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|
@ -361,12 +355,12 @@ body {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
margin: 0 0 1.5rem;
|
margin: 0 0 1.5rem;
|
||||||
}
|
}
|
||||||
@media (width > 48rem) {
|
@media (width >= 768px) {
|
||||||
.intro-button + .intro-text-body {
|
.intro-button + .intro-text-body {
|
||||||
padding-top: 7rem;
|
padding-top: 7rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (width >= 64rem) {
|
@media (width >= 992px) {
|
||||||
.intro-header {
|
.intro-header {
|
||||||
padding-bottom: 9rem;;
|
padding-bottom: 9rem;;
|
||||||
}
|
}
|
||||||
|
|
@ -376,9 +370,18 @@ body {
|
||||||
}
|
}
|
||||||
.intro-text {
|
.intro-text {
|
||||||
box-shadow: var(--intro-shadow);
|
box-shadow: var(--intro-shadow);
|
||||||
max-width: 60rem;
|
max-width: min(60rem, 100vw - 4rem);
|
||||||
margin: 0 auto 6rem;
|
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 {
|
.intro-text-body {
|
||||||
padding-left: 4.5rem;
|
padding-left: 4.5rem;
|
||||||
padding-right: 4.5rem;
|
padding-right: 4.5rem;
|
||||||
|
|
@ -479,6 +482,20 @@ body {
|
||||||
.intro-footer-body a:visited {
|
.intro-footer-body a:visited {
|
||||||
color: var(--intro-color-gray);
|
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-logo,
|
||||||
.intro-footer-links {
|
.intro-footer-links {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -496,12 +513,12 @@ body {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
}
|
}
|
||||||
@media (width <= 48rem) {
|
@media (width < 768px) {
|
||||||
.intro-footer-logo {
|
.intro-footer-logo {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (width <= 64rem) {
|
@media (width < 992px) {
|
||||||
.intro-footer-body {
|
.intro-footer-body {
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,9 @@ impl Extension for FormControls {
|
||||||
async fn form_controls(request: HttpRequest) -> Result<Markup, ErrorPage> {
|
async fn form_controls(request: HttpRequest) -> Result<Markup, ErrorPage> {
|
||||||
Page::new(request)
|
Page::new(request)
|
||||||
.with_child(
|
.with_child(
|
||||||
Intro::default()
|
Intro::custom()
|
||||||
.with_opening(IntroOpening::Custom)
|
|
||||||
.with_title(Lc::t("title", &LOC))
|
.with_title(Lc::t("title", &LOC))
|
||||||
.with_slogan(Lc::t("slogan", &LOC))
|
.with_slogan(Lc::t("slogan", &LOC))
|
||||||
.with_button(None::<(Lc, Route)>)
|
|
||||||
// Bloque 1: casillas, interruptores y botones de opción.
|
// Bloque 1: casillas, interruptores y botones de opción.
|
||||||
.with_child(
|
.with_child(
|
||||||
Block::new()
|
Block::new()
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,9 @@ impl Extension for IntroColors {
|
||||||
async fn intro_colors(request: HttpRequest) -> Result<Markup, ErrorPage> {
|
async fn intro_colors(request: HttpRequest) -> Result<Markup, ErrorPage> {
|
||||||
Page::new(request)
|
Page::new(request)
|
||||||
.with_child(
|
.with_child(
|
||||||
Intro::default()
|
Intro::custom()
|
||||||
.with_opening(IntroOpening::Custom)
|
|
||||||
.with_title(Lc::n("PageTop"))
|
.with_title(Lc::n("PageTop"))
|
||||||
.with_slogan(Lc::t("colors_slogan", &LOC))
|
.with_slogan(Lc::t("colors_slogan", &LOC))
|
||||||
.with_button(None::<(Lc, Route)>)
|
|
||||||
.with_child(
|
.with_child(
|
||||||
Block::new()
|
Block::new()
|
||||||
.with_title(Lc::t("colors_block", &LOC).with_arg("n", "1"))
|
.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_box_styles())
|
||||||
.with_assets(demo_row_styles())
|
.with_assets(demo_row_styles())
|
||||||
.with_child(
|
.with_child(
|
||||||
Intro::default()
|
Intro::custom()
|
||||||
.with_opening(IntroOpening::Custom)
|
|
||||||
.with_title(Lc::n("PageTop"))
|
.with_title(Lc::n("PageTop"))
|
||||||
.with_slogan(Lc::t("flex_slogan", &LOC))
|
.with_slogan(Lc::t("flex_slogan", &LOC))
|
||||||
.with_button(None::<(Lc, Route)>)
|
|
||||||
.with_child(direction_block())
|
.with_child(direction_block())
|
||||||
.with_child(justify_block())
|
.with_child(justify_block())
|
||||||
.with_child(align_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_box_styles())
|
||||||
.with_assets(demo_row_styles())
|
.with_assets(demo_row_styles())
|
||||||
.with_child(
|
.with_child(
|
||||||
Intro::default()
|
Intro::custom()
|
||||||
.with_opening(IntroOpening::Custom)
|
|
||||||
.with_title(Lc::n("PageTop"))
|
.with_title(Lc::n("PageTop"))
|
||||||
.with_slogan(Lc::t("grid_slogan", &LOC))
|
.with_slogan(Lc::t("grid_slogan", &LOC))
|
||||||
.with_button(None::<(Lc, Route)>)
|
|
||||||
.with_child(columns_block())
|
.with_child(columns_block())
|
||||||
.with_child(rows_gap_block())
|
.with_child(rows_gap_block())
|
||||||
.with_child(placement_block())
|
.with_child(placement_block())
|
||||||
|
|
@ -472,7 +470,8 @@ fn layout_block() -> Block {
|
||||||
// **< HELPERS >************************************************************************************
|
// **< HELPERS >************************************************************************************
|
||||||
|
|
||||||
// Aspecto fijo de las cajas de muestra: esquinas rectas y borde claro, a diferencia de las cajas
|
// 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 {
|
fn demo_box_styles() -> AssetsOp {
|
||||||
AssetsOp::add_responsive_styles(
|
AssetsOp::add_responsive_styles(
|
||||||
None,
|
None,
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,9 @@ async fn intro_responsive(request: HttpRequest) -> Result<Markup, ErrorPage> {
|
||||||
.with_assets(demo_row_styles())
|
.with_assets(demo_row_styles())
|
||||||
.with_assets(demo_code_styles())
|
.with_assets(demo_code_styles())
|
||||||
.with_child(
|
.with_child(
|
||||||
Intro::default()
|
Intro::custom()
|
||||||
.with_opening(IntroOpening::Custom)
|
|
||||||
.with_title(Lc::n("PageTop"))
|
.with_title(Lc::n("PageTop"))
|
||||||
.with_slogan(Lc::t("responsive_slogan", &LOC))
|
.with_slogan(Lc::t("responsive_slogan", &LOC))
|
||||||
.with_button(None::<(Lc, Route)>)
|
|
||||||
.with_child(Html::with(|cx| {
|
.with_child(Html::with(|cx| {
|
||||||
html! {
|
html! {
|
||||||
p class="intro-text-lead" {
|
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_row_styles())
|
||||||
.with_assets(demo_code_styles())
|
.with_assets(demo_code_styles())
|
||||||
.with_child(
|
.with_child(
|
||||||
Intro::default()
|
Intro::custom()
|
||||||
.with_opening(IntroOpening::Custom)
|
|
||||||
.with_title(Lc::n("PageTop"))
|
.with_title(Lc::n("PageTop"))
|
||||||
.with_slogan(Lc::t("spacing_slogan", &LOC))
|
.with_slogan(Lc::t("spacing_slogan", &LOC))
|
||||||
.with_button(None::<(Lc, Route)>)
|
|
||||||
.with_child(Html::with(|cx| {
|
.with_child(Html::with(|cx| {
|
||||||
html! {
|
html! {
|
||||||
p class="intro-text-lead" {
|
p class="intro-text-lead" {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
menus_item_label = Etiqueta
|
menus_item_label = Etiqueta
|
||||||
menus_item_link = Enlace
|
menus_item_link = Enlace
|
||||||
menus_item_blank = Enlace externo
|
menus_item_blank = Externo
|
||||||
menus_item_disabled = Enlace deshabilitado
|
menus_item_disabled = Deshabilitado
|
||||||
|
|
||||||
menus_test_title = Desplegable
|
menus_test_title = Desplegable
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
use pagetop::prelude::*;
|
use pagetop::prelude::*;
|
||||||
use pagetop_bootsier::theme::*;
|
|
||||||
|
|
||||||
include_locales!(LOC from "examples/locale");
|
include_locales!(LOC from "examples/locale");
|
||||||
|
|
||||||
|
|
@ -16,87 +15,84 @@ impl Extension for SuperMenu {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn initialize(&self) {
|
async fn initialize(&self) {
|
||||||
let navbar_menu = bs::Navbar::brand_left(bs::navbar::Brand::new())
|
let navbar_menu = Navbar::brand_left(Brand::new())
|
||||||
.with_expand(BreakPoint::LG)
|
.with_expand(Breakpoint::Lg)
|
||||||
.with_item(bs::navbar::Item::nav(
|
.with_item(navbar::Item::nav(
|
||||||
bs::Nav::new()
|
Nav::new()
|
||||||
.with_item(bs::nav::Item::link(Lc::t("menus_item_link", &LOC), "/"))
|
.with_item(nav::Item::link(Lc::t("menus_item_link", &LOC), "/"))
|
||||||
.with_item(bs::nav::Item::link_blank(
|
.with_item(nav::Item::link_blank(
|
||||||
Lc::t("menus_item_blank", &LOC),
|
Lc::t("menus_item_blank", &LOC),
|
||||||
"https://docs.rs/pagetop",
|
"https://docs.rs/pagetop",
|
||||||
))
|
))
|
||||||
.with_item(bs::nav::Item::dropdown(
|
.with_item(nav::Item::dropdown(
|
||||||
bs::Dropdown::new()
|
Dropdown::new()
|
||||||
.with_title(Lc::t("menus_test_title", &LOC))
|
.with_title(Lc::t("menus_test_title", &LOC))
|
||||||
.with_item(bs::dropdown::Item::header(Lc::t("menus_dev_header", &LOC)))
|
.with_item(dropdown::Item::header(Lc::t("menus_dev_header", &LOC)))
|
||||||
.with_item(bs::dropdown::Item::link(
|
.with_item(dropdown::Item::link(
|
||||||
Lc::t("menus_dev_getting_started", &LOC),
|
Lc::t("menus_dev_getting_started", &LOC),
|
||||||
"/dev/getting-started",
|
"/dev/getting-started",
|
||||||
))
|
))
|
||||||
.with_item(bs::dropdown::Item::link(
|
.with_item(dropdown::Item::link(
|
||||||
Lc::t("menus_dev_guides", &LOC),
|
Lc::t("menus_dev_guides", &LOC),
|
||||||
"/dev/guides",
|
"/dev/guides",
|
||||||
))
|
))
|
||||||
.with_item(bs::dropdown::Item::link_blank(
|
.with_item(dropdown::Item::link_blank(
|
||||||
Lc::t("menus_dev_forum", &LOC),
|
Lc::t("menus_dev_forum", &LOC),
|
||||||
"https://forum.example.dev",
|
"https://forum.example.dev",
|
||||||
))
|
))
|
||||||
.with_item(bs::dropdown::Item::divider())
|
.with_item(dropdown::Item::divider())
|
||||||
.with_item(bs::dropdown::Item::header(Lc::t("menus_sdk_header", &LOC)))
|
.with_item(dropdown::Item::header(Lc::t("menus_sdk_header", &LOC)))
|
||||||
.with_item(bs::dropdown::Item::link(
|
.with_item(dropdown::Item::link(
|
||||||
Lc::t("menus_sdk_rust", &LOC),
|
Lc::t("menus_sdk_rust", &LOC),
|
||||||
"/dev/sdks/rust",
|
"/dev/sdks/rust",
|
||||||
))
|
))
|
||||||
.with_item(bs::dropdown::Item::link(
|
.with_item(dropdown::Item::link(
|
||||||
Lc::t("menus_sdk_js", &LOC),
|
Lc::t("menus_sdk_js", &LOC),
|
||||||
"/dev/sdks/js",
|
"/dev/sdks/js",
|
||||||
))
|
))
|
||||||
.with_item(bs::dropdown::Item::link(
|
.with_item(dropdown::Item::link(
|
||||||
Lc::t("menus_sdk_python", &LOC),
|
Lc::t("menus_sdk_python", &LOC),
|
||||||
"/dev/sdks/python",
|
"/dev/sdks/python",
|
||||||
))
|
))
|
||||||
.with_item(bs::dropdown::Item::divider())
|
.with_item(dropdown::Item::divider())
|
||||||
.with_item(bs::dropdown::Item::header(Lc::t(
|
.with_item(dropdown::Item::header(Lc::t("menus_plugin_header", &LOC)))
|
||||||
"menus_plugin_header",
|
.with_item(dropdown::Item::link(
|
||||||
&LOC,
|
|
||||||
)))
|
|
||||||
.with_item(bs::dropdown::Item::link(
|
|
||||||
Lc::t("menus_plugin_auth", &LOC),
|
Lc::t("menus_plugin_auth", &LOC),
|
||||||
"/dev/sdks/rust/plugins/auth",
|
"/dev/sdks/rust/plugins/auth",
|
||||||
))
|
))
|
||||||
.with_item(bs::dropdown::Item::link(
|
.with_item(dropdown::Item::link(
|
||||||
Lc::t("menus_plugin_cache", &LOC),
|
Lc::t("menus_plugin_cache", &LOC),
|
||||||
"/dev/sdks/rust/plugins/cache",
|
"/dev/sdks/rust/plugins/cache",
|
||||||
))
|
))
|
||||||
.with_item(bs::dropdown::Item::divider())
|
.with_item(dropdown::Item::divider())
|
||||||
.with_item(bs::dropdown::Item::label(Lc::t("menus_item_label", &LOC)))
|
.with_item(dropdown::Item::label(Lc::t("menus_item_label", &LOC)))
|
||||||
.with_item(bs::dropdown::Item::link_disabled(
|
.with_item(dropdown::Item::link_disabled(
|
||||||
Lc::t("menus_item_disabled", &LOC),
|
Lc::t("menus_item_disabled", &LOC),
|
||||||
"#",
|
"#",
|
||||||
)),
|
)),
|
||||||
))
|
))
|
||||||
.with_item(bs::nav::Item::link_disabled(
|
.with_item(nav::Item::link_disabled(
|
||||||
Lc::t("menus_item_disabled", &LOC),
|
Lc::t("menus_item_disabled", &LOC),
|
||||||
"#",
|
"#",
|
||||||
)),
|
)),
|
||||||
))
|
))
|
||||||
.with_item(bs::navbar::Item::nav(
|
.with_item(navbar::Item::nav(
|
||||||
bs::Nav::new()
|
Nav::new()
|
||||||
// Empuja este menú (y lo que le siga) al extremo final de la barra.
|
// Empuja este menú (y lo que le siga) al extremo final de la barra.
|
||||||
.with_prop(FlexItem::push_end())
|
.with_prop(FlexItem::push_end())
|
||||||
.with_item(bs::nav::Item::link(
|
.with_item(nav::Item::link(
|
||||||
Lc::t("menus_item_sign_up", &LOC),
|
Lc::t("menus_item_sign_up", &LOC),
|
||||||
"/auth/sign-up",
|
"/auth/sign-up",
|
||||||
))
|
))
|
||||||
.with_item(bs::nav::Item::link(
|
.with_item(nav::Item::link(
|
||||||
Lc::t("menus_item_login", &LOC),
|
Lc::t("menus_item_login", &LOC),
|
||||||
"/auth/login",
|
"/auth/login",
|
||||||
)),
|
)),
|
||||||
));
|
));
|
||||||
|
|
||||||
InRegion::Global(&CoreRegions::Header).add(
|
InRegion::Global(&CoreRegions::Header).add(
|
||||||
bs::Container::new()
|
Container::new()
|
||||||
.with_width(bs::container::Width::FluidMax(UnitValue::RelRem(75.0)))
|
.with_width(container::Width::FluidMax(UnitValue::RelRem(75.0)))
|
||||||
.with_child(navbar_menu),
|
.with_child(navbar_menu),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
use pagetop::base::component::breadcrumb;
|
|
||||||
use pagetop::prelude::*;
|
use pagetop::prelude::*;
|
||||||
|
|
||||||
use crate::LOCALES_ADMIN;
|
use crate::LOCALES_ADMIN;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
|
use pagetop::prelude::*;
|
||||||
use pagetop_seaorm::db::*;
|
use pagetop_seaorm::db::*;
|
||||||
|
|
||||||
use pagetop::datetime::NaiveDateTime;
|
|
||||||
|
|
||||||
/// Entidad SeaORM para la tabla `settings`.
|
/// Entidad SeaORM para la tabla `settings`.
|
||||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||||
#[sea_orm(table_name = "settings")]
|
#[sea_orm(table_name = "settings")]
|
||||||
|
|
@ -14,7 +13,7 @@ pub struct Model {
|
||||||
pub updated_by: Option<i32>,
|
pub updated_by: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)]
|
||||||
pub enum Relation {}
|
pub enum Relation {}
|
||||||
|
|
||||||
impl ActiveModelBehavior for ActiveModel {}
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,7 @@
|
||||||
//!
|
//!
|
||||||
//! Proporciona una API async para leer y escribir valores JSON en la tabla `settings`.
|
//! Proporciona una API async para leer y escribir valores JSON en la tabla `settings`.
|
||||||
|
|
||||||
use pagetop::datetime::Utc;
|
use pagetop::prelude::*;
|
||||||
use pagetop::{Getters, builder_impl};
|
|
||||||
use pagetop_seaorm::db::{
|
use pagetop_seaorm::db::{
|
||||||
ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter, dbconn,
|
ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter, dbconn,
|
||||||
};
|
};
|
||||||
|
|
@ -231,6 +230,6 @@ impl SettingsSchema {
|
||||||
|
|
||||||
/// Devuelve la clave completa de un campo: `"{scope}.{field_name}"`.
|
/// Devuelve la clave completa de un campo: `"{scope}.{field_name}"`.
|
||||||
pub fn key_for(&self, field_name: &str) -> String {
|
pub fn key_for(&self, field_name: &str) -> String {
|
||||||
format!("{}.{}", self.scope, field_name)
|
util::join!(&self.scope, ".", field_name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -146,12 +146,12 @@ impl Theme for Bootsier {
|
||||||
theme::BootsierColors::from(intent).as_str()
|
theme::BootsierColors::from(intent).as_str()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn setup_component(&self, component: &mut dyn Component, cx: &mut Context) {
|
fn setup_component(&self, component: &mut dyn Component, _cx: &mut Context) {
|
||||||
setup_component!(component, {
|
setup_component!(component, {
|
||||||
Badge => |c| theme::bs::badge::setup(c),
|
Badge => |c| theme::bs::badge::setup(c),
|
||||||
Brand => |c| theme::bs::brand::setup(c),
|
Brand => |c| theme::bs::brand::setup(c),
|
||||||
Button => |c| theme::bs::button::setup(c),
|
Button => |c| theme::bs::button::setup(c),
|
||||||
Container => |c| theme::bs::container::setup(c, cx),
|
Container => |c| theme::bs::container::setup(c),
|
||||||
Dialog => |c| theme::bs::dialog::setup(c),
|
Dialog => |c| theme::bs::dialog::setup(c),
|
||||||
Dropdown => |c| theme::bs::dropdown::setup(c),
|
Dropdown => |c| theme::bs::dropdown::setup(c),
|
||||||
Image => |c| theme::bs::image::setup(c),
|
Image => |c| theme::bs::image::setup(c),
|
||||||
|
|
|
||||||
|
|
@ -103,8 +103,6 @@ pub use bs::badge::BadgeBootsier;
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
pub use bs::button::ButtonBootsier;
|
pub use bs::button::ButtonBootsier;
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
pub use bs::container::ContainerBootsier;
|
|
||||||
#[doc(hidden)]
|
|
||||||
pub use bs::dropdown::DropdownBootsier;
|
pub use bs::dropdown::DropdownBootsier;
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
pub use bs::form::input::InputBootsier;
|
pub use bs::form::input::InputBootsier;
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,6 @@ pub use button::{Button, ButtonBootsier};
|
||||||
pub mod container;
|
pub mod container;
|
||||||
#[doc(inline)]
|
#[doc(inline)]
|
||||||
pub use container::Container;
|
pub use container::Container;
|
||||||
#[doc(inline)]
|
|
||||||
pub use container::ContainerBootsier;
|
|
||||||
|
|
||||||
// Dialog.
|
// Dialog.
|
||||||
pub mod dialog;
|
pub mod dialog;
|
||||||
|
|
|
||||||
|
|
@ -1,108 +1,37 @@
|
||||||
//! Definiciones para crear contenedores de componentes ([`Container`]).
|
//! Definiciones para crear contenedores de componentes ([`Container`]).
|
||||||
|
//!
|
||||||
|
//! El comportamiento del ancho del contenedor se establece con [`Container::with_width()`], usando
|
||||||
|
//! [`Width`]. Sin él, Bootsier aplica los anchos máximos predefinidos de Bootstrap para cada punto
|
||||||
|
//! de corte ([`Width::Responsive`]).
|
||||||
|
//!
|
||||||
|
//! También se pueden aplicar al componente clases predefinidas para:
|
||||||
|
//!
|
||||||
|
//! - Modificar el color de fondo ([`Bg`](crate::theme::class::Bg)).
|
||||||
|
//! - Definir la apariencia del texto ([`Text`](crate::theme::class::Text)).
|
||||||
|
//! - Establecer bordes ([`Border`](crate::theme::class::Border)).
|
||||||
|
//! - Redondear las esquinas ([`Rounded`](crate::theme::class::Rounded)).
|
||||||
|
//!
|
||||||
|
//! ```rust,no_run
|
||||||
|
//! use pagetop::prelude::*;
|
||||||
|
//! use pagetop_bootsier::theme::*;
|
||||||
|
//!
|
||||||
|
//! let main = bs::Container::main()
|
||||||
|
//! .with_id("main-page")
|
||||||
|
//! .with_width(bs::container::Width::From(Breakpoint::Lg))
|
||||||
|
//! .with_prop(PropsOp::add_classes(class::Bg::with(BootsierColors::Light)))
|
||||||
|
//! .with_prop(PropsOp::add_classes(class::Text::with(BootsierColors::Dark)))
|
||||||
|
//! .with_prop(PropsOp::add_classes(class::Border::with(ScaleSize::One)))
|
||||||
|
//! .with_prop(PropsOp::add_classes(class::Rounded::new()));
|
||||||
|
//! ```
|
||||||
|
|
||||||
use pagetop::prelude::*;
|
use pagetop::prelude::*;
|
||||||
|
|
||||||
use crate::theme::*;
|
pub use pagetop::base::component::container::{Container, Kind, Width};
|
||||||
|
|
||||||
pub use pagetop::base::component::container::{Container, Kind};
|
|
||||||
|
|
||||||
const EXTRA_WIDTH: &str = "bootsier.container.width";
|
|
||||||
|
|
||||||
/// Extensión de Bootsier para [`Container`].
|
|
||||||
///
|
|
||||||
/// Permite establecer el comportamiento del ancho del contenedor usando el método
|
|
||||||
/// [`with_width()`](Self::with_width).
|
|
||||||
///
|
|
||||||
/// También habilita al componente para aceptar clases predefinidas para:
|
|
||||||
///
|
|
||||||
/// - Modificar el color de fondo ([`Bg`](crate::theme::class::Bg)).
|
|
||||||
/// - Definir la apariencia del texto ([`Text`](crate::theme::class::Text)).
|
|
||||||
/// - Establecer bordes ([`Border`](crate::theme::class::Border)).
|
|
||||||
/// - Redondear las esquinas ([`Rounded`](crate::theme::class::Rounded)).
|
|
||||||
///
|
|
||||||
/// ```rust,no_run
|
|
||||||
/// use pagetop::prelude::*;
|
|
||||||
/// use pagetop_bootsier::theme::*;
|
|
||||||
///
|
|
||||||
/// let main = bs::Container::main()
|
|
||||||
/// .with_id("main-page")
|
|
||||||
/// .with_width(bs::container::Width::From(Breakpoint::Lg))
|
|
||||||
/// .with_prop(PropsOp::add_classes(class::Bg::with(BootsierColors::Light)))
|
|
||||||
/// .with_prop(PropsOp::add_classes(class::Text::with(BootsierColors::Dark)))
|
|
||||||
/// .with_prop(PropsOp::add_classes(class::Border::with(ScaleSize::One)))
|
|
||||||
/// .with_prop(PropsOp::add_classes(class::Rounded::new()));
|
|
||||||
/// ```
|
|
||||||
#[builder_impl]
|
|
||||||
pub trait ContainerBootsier {
|
|
||||||
/// Establece el comportamiento del ancho para el contenedor.
|
|
||||||
///
|
|
||||||
/// Determina si el contenedor aplica los anchos máximos predefinidos para cada punto de corte,
|
|
||||||
/// o si ocupa siempre el 100% del ancho disponible, o lo hace hasta un ancho máximo explícito.
|
|
||||||
/// Ver [`Width`] para las variantes disponibles.
|
|
||||||
fn with_width(self, width: Width) -> Self;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[builder_impl]
|
|
||||||
impl ContainerBootsier for Container {
|
|
||||||
fn with_width(mut self, width: Width) -> Self {
|
|
||||||
self.alter_prop(PropsOp::set_extra(EXTRA_WIDTH, width));
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **< Width >**************************************************************************************
|
|
||||||
|
|
||||||
/// Define cómo se comporta el ancho de un contenedor ([`Container`]).
|
|
||||||
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
|
|
||||||
pub enum Width {
|
|
||||||
/// Comportamiento por defecto, aplica los anchos máximos predefinidos para cada punto de corte.
|
|
||||||
/// Por debajo del menor punto de corte ocupa el 100% del ancho disponible.
|
|
||||||
#[default]
|
|
||||||
Default,
|
|
||||||
/// Aplica los anchos máximos predefinidos a partir del punto de corte indicado. Por debajo de
|
|
||||||
/// ese punto de corte ocupa el 100% del ancho disponible.
|
|
||||||
From(Breakpoint),
|
|
||||||
/// Ocupa el 100% del ancho disponible siempre.
|
|
||||||
Fluid,
|
|
||||||
/// Ocupa el 100% del ancho disponible hasta un ancho máximo explícito.
|
|
||||||
FluidMax(UnitValue),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Width {
|
|
||||||
const CONTAINER: &str = "container";
|
|
||||||
|
|
||||||
/// Añade la clase asociada al ancho del contenedor a la cadena de clases.
|
|
||||||
///
|
|
||||||
/// El nombre del punto de corte se resuelve en el tema activo de `cx`.
|
|
||||||
#[inline]
|
|
||||||
pub fn push_to(self, cx: &Context, classes: &mut String) {
|
|
||||||
match self {
|
|
||||||
Self::Default => {
|
|
||||||
push_breakpoint_class(cx, Breakpoint::Xs, classes, Self::CONTAINER, "")
|
|
||||||
}
|
|
||||||
Self::From(bp) => push_breakpoint_class(cx, bp, classes, Self::CONTAINER, ""),
|
|
||||||
Self::Fluid | Self::FluidMax(_) => {
|
|
||||||
push_breakpoint_class(cx, Breakpoint::Xs, classes, Self::CONTAINER, "fluid")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Devuelve la clase asociada al ancho del contenedor.
|
|
||||||
pub fn to_class(self, cx: &Context) -> String {
|
|
||||||
let mut class = String::new();
|
|
||||||
self.push_to(cx, &mut class);
|
|
||||||
class
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **< Container SETUP >****************************************************************************
|
// **< Container SETUP >****************************************************************************
|
||||||
|
|
||||||
pub(crate) fn setup(container: &mut Container, cx: &Context) {
|
pub(crate) fn setup(container: &mut Container) {
|
||||||
let width = container.props().extra_or(EXTRA_WIDTH, Width::default());
|
if container.width().is_none() {
|
||||||
container.alter_prop(PropsOp::prepend_classes(width.to_class(cx)));
|
container.alter_prop(PropsOp::prepend_classes("container"));
|
||||||
if let Width::FluidMax(w) = width
|
|
||||||
&& w.is_measurable()
|
|
||||||
{
|
|
||||||
container.alter_prop(PropsOp::add_style("max-width", w.to_string()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -202,7 +202,7 @@ impl MenuPosition {
|
||||||
#[inline]
|
#[inline]
|
||||||
pub(crate) fn data_offset(self) -> Option<String> {
|
pub(crate) fn data_offset(self) -> Option<String> {
|
||||||
match self {
|
match self {
|
||||||
Self::Offset(x, y) => Some(format!("{x},{y}")),
|
Self::Offset(x, y) => Some(util::join!(x.to_string(), ",", y.to_string())),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use pagetop::prelude::*;
|
use pagetop::prelude::*;
|
||||||
|
|
||||||
use crate::config;
|
use crate::config;
|
||||||
use crate::theme::{ContainerBootsier, bs};
|
use crate::theme::bs;
|
||||||
use crate::{ADMINLTE_VERSION, LOCALES_BOOTSIER};
|
use crate::{ADMINLTE_VERSION, LOCALES_BOOTSIER};
|
||||||
|
|
||||||
// Regiones de Bootsier: se renderiza sin el `<div role="region">` envolvente que aplica
|
// Regiones de Bootsier: se renderiza sin el `<div role="region">` envolvente que aplica
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
// Verifies that Bootsier builds its breakpoint-based Bootstrap classes from the breakpoint names
|
// Verifies that the breakpoint-based Bootstrap classes of `Container`, `Navbar`, `Dropdown` and
|
||||||
// of `Bootsier::breakpoint_entry()`, and that `Xxxl` (absent in Bootstrap) resolves as `xxl`.
|
// `Offcanvas` use the breakpoint names of `Bootsier::breakpoint_entry()`, and that `Xxxl` (absent
|
||||||
|
// in Bootstrap) resolves as `xxl`.
|
||||||
|
|
||||||
use pagetop::prelude::*;
|
use pagetop::prelude::*;
|
||||||
use pagetop_bootsier::theme::*;
|
use pagetop_bootsier::theme::*;
|
||||||
|
|
@ -8,23 +9,43 @@ fn cx() -> Context {
|
||||||
Context::default().with_theme(&pagetop_bootsier::Bootsier)
|
Context::default().with_theme(&pagetop_bootsier::Bootsier)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn container_html(width: impl Into<Option<bs::container::Width>>) -> String {
|
||||||
|
let mut container = bs::Container::new()
|
||||||
|
.with_width(width)
|
||||||
|
.with_child(Html::with(|_| html! { p { "Content" } }));
|
||||||
|
container.render(&mut cx()).await.into_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn container_without_width_gets_the_bootstrap_default() {
|
||||||
|
assert!(container_html(None).await.contains(r#"class="container""#));
|
||||||
|
assert!(
|
||||||
|
container_html(bs::container::Width::Responsive)
|
||||||
|
.await
|
||||||
|
.contains(r#"class="container""#)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[pagetop::test]
|
#[pagetop::test]
|
||||||
async fn container_width_uses_breakpoint_name() {
|
async fn container_width_uses_breakpoint_name() {
|
||||||
let cx = cx();
|
assert!(
|
||||||
assert_eq!(bs::container::Width::Default.to_class(&cx), "container");
|
container_html(bs::container::Width::Fluid)
|
||||||
assert_eq!(bs::container::Width::Fluid.to_class(&cx), "container-fluid");
|
.await
|
||||||
assert_eq!(
|
.contains(r#"class="container-fluid""#)
|
||||||
bs::container::Width::From(Breakpoint::Lg).to_class(&cx),
|
);
|
||||||
"container-lg"
|
assert!(
|
||||||
|
container_html(bs::container::Width::From(Breakpoint::Lg))
|
||||||
|
.await
|
||||||
|
.contains(r#"class="container-lg""#)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[pagetop::test]
|
#[pagetop::test]
|
||||||
async fn container_width_xxxl_resolves_as_xxl() {
|
async fn container_width_xxxl_resolves_as_xxl() {
|
||||||
let cx = cx();
|
assert!(
|
||||||
assert_eq!(
|
container_html(bs::container::Width::From(Breakpoint::Xxxl))
|
||||||
bs::container::Width::From(Breakpoint::Xxxl).to_class(&cx),
|
.await
|
||||||
"container-xxl"
|
.contains(r#"class="container-xxl""#)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
//! # Atributos estáticos en `html!`
|
//! # Atributos estáticos en `html!`
|
||||||
//!
|
//!
|
||||||
//! Para valores conocidos en tiempo de compilación, los atributos `hx-*` pueden escribirse
|
//! Para valores conocidos en tiempo de compilación, los atributos `hx-*` pueden escribirse
|
||||||
//! directamente en la macro `html!` sin necesidad de [`Props`](pagetop::html::Props):
|
//! directamente en la macro `html!` sin necesidad de [`Props`]:
|
||||||
//!
|
//!
|
||||||
//! ```rust,no_run
|
//! ```rust,no_run
|
||||||
//! use pagetop::prelude::*;
|
//! use pagetop::prelude::*;
|
||||||
|
|
@ -17,10 +17,10 @@
|
||||||
//! };
|
//! };
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! # Atributos dinámicos con [`Props`](pagetop::html::Props)
|
//! # Atributos dinámicos con [`Props`]
|
||||||
//!
|
//!
|
||||||
//! Cuando los valores se construyen en tiempo de ejecución o se inyectan desde una extensión,
|
//! Cuando los valores se construyen en tiempo de ejecución o se inyectan desde una extensión,
|
||||||
//! puedes usar [`Props`](pagetop::html::Props) combinado con las constantes de este módulo:
|
//! puedes usar [`Props`] combinado con las constantes de este módulo:
|
||||||
//!
|
//!
|
||||||
//! ```rust,no_run
|
//! ```rust,no_run
|
||||||
//! # use pagetop::prelude::*;
|
//! # use pagetop::prelude::*;
|
||||||
|
|
@ -81,6 +81,8 @@
|
||||||
//! .with_prop(PropsOp::set(hx::on_htmx("after-swap"), "console.log('done')"));
|
//! .with_prop(PropsOp::set(hx::on_htmx("after-swap"), "console.log('done')"));
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
|
use pagetop::prelude::*;
|
||||||
|
|
||||||
// **< HTTP Methods >*******************************************************************************
|
// **< HTTP Methods >*******************************************************************************
|
||||||
|
|
||||||
/// Realiza una petición GET al servidor y aplica la respuesta al objetivo.
|
/// Realiza una petición GET al servidor y aplica la respuesta al objetivo.
|
||||||
|
|
@ -356,7 +358,7 @@ pub const DISABLE: &str = "hx-disable";
|
||||||
/// .with_prop(PropsOp::set(hx::on("mouseenter"), "this.style.opacity='0.8'"));
|
/// .with_prop(PropsOp::set(hx::on("mouseenter"), "this.style.opacity='0.8'"));
|
||||||
/// ```
|
/// ```
|
||||||
pub fn on(event: &str) -> String {
|
pub fn on(event: &str) -> String {
|
||||||
format!("hx-on:{event}")
|
util::join!("hx-on:", event)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Genera `hx-on::{event}` para escuchar eventos propios de HTMX en línea.
|
/// Genera `hx-on::{event}` para escuchar eventos propios de HTMX en línea.
|
||||||
|
|
@ -372,7 +374,7 @@ pub fn on(event: &str) -> String {
|
||||||
/// .with_prop(PropsOp::set(hx::on_htmx("after-swap"), "initTooltips()"));
|
/// .with_prop(PropsOp::set(hx::on_htmx("after-swap"), "initTooltips()"));
|
||||||
/// ```
|
/// ```
|
||||||
pub fn on_htmx(event: &str) -> String {
|
pub fn on_htmx(event: &str) -> String {
|
||||||
format!("hx-on::{event}")
|
util::join!("hx-on::", event)
|
||||||
}
|
}
|
||||||
|
|
||||||
// **< HTMX Request Headers >***********************************************************************
|
// **< HTMX Request Headers >***********************************************************************
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,5 @@ authors.workspace = true
|
||||||
[dependencies]
|
[dependencies]
|
||||||
pagetop.workspace = true
|
pagetop.workspace = true
|
||||||
pagetop-seaorm.workspace = true
|
pagetop-seaorm.workspace = true
|
||||||
chrono.workspace = true
|
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
use pagetop::base::component::breadcrumb;
|
|
||||||
use pagetop::prelude::*;
|
use pagetop::prelude::*;
|
||||||
|
|
||||||
use crate::tree::{MenuKey, MenuNode, TreeOptions, build_tree, try_resolve_menu_url};
|
use crate::tree::{MenuKey, MenuNode, TreeOptions, build_tree, try_resolve_menu_url};
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
|
use pagetop::prelude::*;
|
||||||
use pagetop_seaorm::db::*;
|
use pagetop_seaorm::db::*;
|
||||||
|
|
||||||
use chrono::NaiveDateTime;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
|
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
|
||||||
#[sea_orm(table_name = "menus")]
|
#[sea_orm(table_name = "menus")]
|
||||||
pub struct Model {
|
pub struct Model {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
|
use pagetop::prelude::*;
|
||||||
use pagetop_seaorm::db::*;
|
use pagetop_seaorm::db::*;
|
||||||
|
|
||||||
use chrono::NaiveDateTime;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
|
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
|
||||||
#[sea_orm(table_name = "menu_items")]
|
#[sea_orm(table_name = "menu_items")]
|
||||||
pub struct Model {
|
pub struct Model {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
//! Operaciones de base de datos para menús e ítems de menú.
|
//! Operaciones de base de datos para menús e ítems de menú.
|
||||||
|
|
||||||
use chrono::Utc;
|
use pagetop::prelude::*;
|
||||||
|
|
||||||
use pagetop::locale::Locale;
|
|
||||||
use pagetop_seaorm::db::{
|
use pagetop_seaorm::db::{
|
||||||
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, EntityTrait, QueryFilter, Set, dbconn,
|
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, EntityTrait, QueryFilter, Set, dbconn,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use pagetop::locale::{Locale, RequestLocale};
|
|
||||||
use pagetop::prelude::*;
|
use pagetop::prelude::*;
|
||||||
|
|
||||||
use crate::cache::FlatMenu;
|
use crate::cache::FlatMenu;
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@
|
||||||
//! pub name: String,
|
//! pub name: String,
|
||||||
//! }
|
//! }
|
||||||
//!
|
//!
|
||||||
//! #[derive(Clone, Copy, Debug, EnumIter, DeriveRelation)]
|
//! #[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)]
|
||||||
//! pub enum Relation {}
|
//! pub enum Relation {}
|
||||||
//!
|
//!
|
||||||
//! // `DeriveEntityModel` genera también `ActiveModel`, `Entity`, `Column` y `PrimaryKey`.
|
//! // `DeriveEntityModel` genera también `ActiveModel`, `Entity`, `Column` y `PrimaryKey`.
|
||||||
|
|
@ -468,7 +468,7 @@ impl<T> Paginated<T> {
|
||||||
/// pub email: String,
|
/// pub email: String,
|
||||||
/// }
|
/// }
|
||||||
///
|
///
|
||||||
/// #[derive(Clone, Copy, Debug, EnumIter, DeriveRelation)]
|
/// #[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)]
|
||||||
/// pub enum Relation {}
|
/// pub enum Relation {}
|
||||||
///
|
///
|
||||||
/// impl ActiveModelBehavior for ActiveModel {}
|
/// impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ use std::future::Future;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
|
|
||||||
use pagetop::trace::info;
|
use pagetop::prelude::*;
|
||||||
|
|
||||||
use sea_orm::sea_query::{
|
use sea_orm::sea_query::{
|
||||||
self, Alias, Expr, ExprTrait, ForeignKey, IntoIden, Order, Query, SelectStatement, SimpleExpr,
|
self, Alias, Expr, ExprTrait, ForeignKey, IntoIden, Order, Query, SelectStatement, SimpleExpr,
|
||||||
|
|
@ -182,10 +182,10 @@ pub trait MigratorTrait: Send {
|
||||||
{
|
{
|
||||||
Self::install(db).await?;
|
Self::install(db).await?;
|
||||||
|
|
||||||
info!("Checking migration status");
|
trace::info!("Checking migration status");
|
||||||
|
|
||||||
for Migration { migration, status } in Self::get_migration_with_status(db).await? {
|
for Migration { migration, status } in Self::get_migration_with_status(db).await? {
|
||||||
info!("Migration '{}'... {}", migration.name(), status);
|
trace::info!("Migration '{}'... {}", migration.name(), status);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -284,34 +284,35 @@ where
|
||||||
|
|
||||||
// Temporarily disable the foreign key check
|
// Temporarily disable the foreign key check
|
||||||
if db_backend == DbBackend::Sqlite {
|
if db_backend == DbBackend::Sqlite {
|
||||||
info!("Disabling foreign key check");
|
trace::info!("Disabling foreign key check");
|
||||||
db.execute(Statement::from_string(
|
db.execute(Statement::from_string(
|
||||||
db_backend,
|
db_backend,
|
||||||
"PRAGMA foreign_keys = OFF".to_owned(),
|
"PRAGMA foreign_keys = OFF".to_owned(),
|
||||||
))
|
))
|
||||||
.await?;
|
.await?;
|
||||||
info!("Foreign key check disabled");
|
trace::info!("Foreign key check disabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drop all foreign keys
|
// Drop all foreign keys
|
||||||
if db_backend == DbBackend::MySql {
|
if db_backend == DbBackend::MySql {
|
||||||
info!("Dropping all foreign keys");
|
trace::info!("Dropping all foreign keys");
|
||||||
let stmt = query_mysql_foreign_keys(db);
|
let stmt = query_mysql_foreign_keys(db);
|
||||||
let rows = db.query_all(db_backend.build(&stmt)).await?;
|
let rows = db.query_all(db_backend.build(&stmt)).await?;
|
||||||
for row in rows.into_iter() {
|
for row in rows.into_iter() {
|
||||||
let constraint_name: String = row.try_get("", "CONSTRAINT_NAME")?;
|
let constraint_name: String = row.try_get("", "CONSTRAINT_NAME")?;
|
||||||
let table_name: String = row.try_get("", "TABLE_NAME")?;
|
let table_name: String = row.try_get("", "TABLE_NAME")?;
|
||||||
info!(
|
trace::info!(
|
||||||
"Dropping foreign key '{}' from table '{}'",
|
"Dropping foreign key '{}' from table '{}'",
|
||||||
constraint_name, table_name
|
constraint_name,
|
||||||
|
table_name
|
||||||
);
|
);
|
||||||
let mut stmt = ForeignKey::drop();
|
let mut stmt = ForeignKey::drop();
|
||||||
stmt.table(Alias::new(table_name.as_str()))
|
stmt.table(Alias::new(table_name.as_str()))
|
||||||
.name(constraint_name.as_str());
|
.name(constraint_name.as_str());
|
||||||
db.execute(db_backend.build(&stmt)).await?;
|
db.execute(db_backend.build(&stmt)).await?;
|
||||||
info!("Foreign key '{}' has been dropped", constraint_name);
|
trace::info!("Foreign key '{}' has been dropped", constraint_name);
|
||||||
}
|
}
|
||||||
info!("All foreign keys dropped");
|
trace::info!("All foreign keys dropped");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drop all tables
|
// Drop all tables
|
||||||
|
|
@ -319,39 +320,39 @@ where
|
||||||
let rows = db.query_all(db_backend.build(&stmt)).await?;
|
let rows = db.query_all(db_backend.build(&stmt)).await?;
|
||||||
for row in rows.into_iter() {
|
for row in rows.into_iter() {
|
||||||
let table_name: String = row.try_get("", "table_name")?;
|
let table_name: String = row.try_get("", "table_name")?;
|
||||||
info!("Dropping table '{}'", table_name);
|
trace::info!("Dropping table '{}'", table_name);
|
||||||
let mut stmt = Table::drop();
|
let mut stmt = Table::drop();
|
||||||
stmt.table(Alias::new(table_name.as_str()))
|
stmt.table(Alias::new(table_name.as_str()))
|
||||||
.if_exists()
|
.if_exists()
|
||||||
.cascade();
|
.cascade();
|
||||||
db.execute(db_backend.build(&stmt)).await?;
|
db.execute(db_backend.build(&stmt)).await?;
|
||||||
info!("Table '{}' has been dropped", table_name);
|
trace::info!("Table '{}' has been dropped", table_name);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drop all types
|
// Drop all types
|
||||||
if db_backend == DbBackend::Postgres {
|
if db_backend == DbBackend::Postgres {
|
||||||
info!("Dropping all types");
|
trace::info!("Dropping all types");
|
||||||
let stmt = query_pg_types(db);
|
let stmt = query_pg_types(db);
|
||||||
let rows = db.query_all(db_backend.build(&stmt)).await?;
|
let rows = db.query_all(db_backend.build(&stmt)).await?;
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let type_name: String = row.try_get("", "typname")?;
|
let type_name: String = row.try_get("", "typname")?;
|
||||||
info!("Dropping type '{}'", type_name);
|
trace::info!("Dropping type '{}'", type_name);
|
||||||
let mut stmt = Type::drop();
|
let mut stmt = Type::drop();
|
||||||
stmt.name(Alias::new(&type_name));
|
stmt.name(Alias::new(&type_name));
|
||||||
db.execute(db_backend.build(&stmt)).await?;
|
db.execute(db_backend.build(&stmt)).await?;
|
||||||
info!("Type '{}' has been dropped", type_name);
|
trace::info!("Type '{}' has been dropped", type_name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore the foreign key check
|
// Restore the foreign key check
|
||||||
if db_backend == DbBackend::Sqlite {
|
if db_backend == DbBackend::Sqlite {
|
||||||
info!("Restoring foreign key check");
|
trace::info!("Restoring foreign key check");
|
||||||
db.execute(Statement::from_string(
|
db.execute(Statement::from_string(
|
||||||
db_backend,
|
db_backend,
|
||||||
"PRAGMA foreign_keys = ON".to_owned(),
|
"PRAGMA foreign_keys = ON".to_owned(),
|
||||||
))
|
))
|
||||||
.await?;
|
.await?;
|
||||||
info!("Foreign key check restored");
|
trace::info!("Foreign key check restored");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reapply all migrations
|
// Reapply all migrations
|
||||||
|
|
@ -367,15 +368,15 @@ where
|
||||||
M::install(db).await?;
|
M::install(db).await?;
|
||||||
/*
|
/*
|
||||||
if let Some(steps) = steps {
|
if let Some(steps) = steps {
|
||||||
info!("Applying {} pending migrations", steps);
|
trace::info!("Applying {} pending migrations", steps);
|
||||||
} else {
|
} else {
|
||||||
info!("Applying all pending migrations");
|
trace::info!("Applying all pending migrations");
|
||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
let migrations = M::get_pending_migrations(db).await?.into_iter();
|
let migrations = M::get_pending_migrations(db).await?.into_iter();
|
||||||
/*
|
/*
|
||||||
if migrations.len() == 0 {
|
if migrations.len() == 0 {
|
||||||
info!("No pending migrations");
|
trace::info!("No pending migrations");
|
||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
for Migration { migration, .. } in migrations {
|
for Migration { migration, .. } in migrations {
|
||||||
|
|
@ -385,9 +386,9 @@ where
|
||||||
}
|
}
|
||||||
*steps -= 1;
|
*steps -= 1;
|
||||||
}
|
}
|
||||||
info!("Applying migration '{}'", migration.name());
|
trace::info!("Applying migration '{}'", migration.name());
|
||||||
migration.up(manager).await?;
|
migration.up(manager).await?;
|
||||||
info!("Migration '{}' has been applied", migration.name());
|
trace::info!("Migration '{}' has been applied", migration.name());
|
||||||
let now = SystemTime::now()
|
let now = SystemTime::now()
|
||||||
.duration_since(SystemTime::UNIX_EPOCH)
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
.expect("SystemTime before UNIX EPOCH!");
|
.expect("SystemTime before UNIX EPOCH!");
|
||||||
|
|
@ -412,14 +413,14 @@ where
|
||||||
M::install(db).await?;
|
M::install(db).await?;
|
||||||
|
|
||||||
if let Some(steps) = steps {
|
if let Some(steps) = steps {
|
||||||
info!("Rolling back {} applied migrations", steps);
|
trace::info!("Rolling back {} applied migrations", steps);
|
||||||
} else {
|
} else {
|
||||||
info!("Rolling back all applied migrations");
|
trace::info!("Rolling back all applied migrations");
|
||||||
}
|
}
|
||||||
|
|
||||||
let migrations = M::get_applied_migrations(db).await?.into_iter().rev();
|
let migrations = M::get_applied_migrations(db).await?.into_iter().rev();
|
||||||
if migrations.len() == 0 {
|
if migrations.len() == 0 {
|
||||||
info!("No applied migrations");
|
trace::info!("No applied migrations");
|
||||||
}
|
}
|
||||||
for Migration { migration, .. } in migrations {
|
for Migration { migration, .. } in migrations {
|
||||||
if let Some(steps) = steps.as_mut() {
|
if let Some(steps) = steps.as_mut() {
|
||||||
|
|
@ -428,9 +429,9 @@ where
|
||||||
}
|
}
|
||||||
*steps -= 1;
|
*steps -= 1;
|
||||||
}
|
}
|
||||||
info!("Rolling back migration '{}'", migration.name());
|
trace::info!("Rolling back migration '{}'", migration.name());
|
||||||
migration.down(manager).await?;
|
migration.down(manager).await?;
|
||||||
info!("Migration '{}' has been rollbacked", migration.name());
|
trace::info!("Migration '{}' has been rollbacked", migration.name());
|
||||||
seaql_migrations::Entity::delete_many()
|
seaql_migrations::Entity::delete_many()
|
||||||
.filter(Expr::col(seaql_migrations::Column::Version).eq(migration.name()))
|
.filter(Expr::col(seaql_migrations::Column::Version).eq(migration.name()))
|
||||||
.table_name(M::migration_table_name())
|
.table_name(M::migration_table_name())
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use sea_orm::entity::prelude::*;
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
#[derive(Clone, Debug, DeriveEntityModel, Eq, PartialEq)]
|
||||||
// One should override the name of migration table via `MigratorTrait::migration_table_name` method
|
// One should override the name of migration table via `MigratorTrait::migration_table_name` method
|
||||||
#[sea_orm(table_name = "seaql_migrations")]
|
#[sea_orm(table_name = "seaql_migrations")]
|
||||||
pub struct Model {
|
pub struct Model {
|
||||||
|
|
@ -9,7 +9,7 @@ pub struct Model {
|
||||||
pub applied_at: i64,
|
pub applied_at: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)]
|
||||||
pub enum Relation {}
|
pub enum Relation {}
|
||||||
|
|
||||||
impl ActiveModelBehavior for ActiveModel {}
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use pagetop::auth::PermissionRef;
|
use pagetop::prelude::*;
|
||||||
|
|
||||||
// **< UserStatus >*********************************************************************************
|
// **< UserStatus >*********************************************************************************
|
||||||
|
|
||||||
|
|
@ -56,9 +56,9 @@ impl PermissionSet {
|
||||||
|
|
||||||
/// Datos ricos del usuario autenticado inyectados por el middleware de sesión.
|
/// Datos ricos del usuario autenticado inyectados por el middleware de sesión.
|
||||||
///
|
///
|
||||||
/// Se almacena en las extensiones de la petición HTTP durante la fase de middleware y se
|
/// Se almacena en las extensiones de la petición HTTP durante la fase de middleware y se accede
|
||||||
/// accede desde los handlers o desde handlers de [`CheckPermission`](pagetop::auth::CheckPermission)
|
/// desde los handlers o desde handlers de [`CheckPermission`] mediante
|
||||||
/// mediante [`HttpRequest::extension::<Account>()`](pagetop::web::HttpRequest::extension).
|
/// [`HttpRequest::extension::<Account>()`](pagetop::web::HttpRequest::extension).
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct Account {
|
pub struct Account {
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
//! Lógica de autenticación: login, logout, registro, semilla inicial.
|
//! Lógica de autenticación: login, logout, registro, semilla inicial.
|
||||||
|
|
||||||
use pagetop::datetime::{Duration, NaiveDateTime, Utc};
|
use pagetop::prelude::*;
|
||||||
|
|
||||||
use pagetop_seaorm::db::{
|
use pagetop_seaorm::db::{
|
||||||
ActiveModelTrait, ActiveValue, ColumnTrait, Condition, EntityTrait, PaginatorTrait,
|
ActiveModelTrait, ActiveValue, ColumnTrait, Condition, EntityTrait, PaginatorTrait,
|
||||||
QueryFilter, Set, dbconn,
|
QueryFilter, Set, dbconn,
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
|
|
||||||
use pagetop::prelude::*;
|
use pagetop::prelude::*;
|
||||||
|
|
||||||
use crate::ADMIN_USERS_PATH;
|
|
||||||
use crate::LOCALES_USER;
|
use crate::LOCALES_USER;
|
||||||
|
use crate::user_path;
|
||||||
|
|
||||||
use crate::component::{PasswordConfirm, error_banner};
|
use crate::component::{PasswordConfirm, error_banner};
|
||||||
|
|
||||||
|
|
@ -22,7 +22,7 @@ impl Component for AdminPasswordForm {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||||
let action = format!("{ADMIN_USERS_PATH}/{}/password", self.user_id());
|
let action = user_path(self.user_id(), "password");
|
||||||
let action = self.waypoint().append_to(cx.route(action));
|
let action = self.waypoint().append_to(cx.route(action));
|
||||||
|
|
||||||
let mut form = Form::new()
|
let mut form = Form::new()
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ use pagetop::prelude::*;
|
||||||
|
|
||||||
use crate::ADMIN_ROLES_PATH;
|
use crate::ADMIN_ROLES_PATH;
|
||||||
use crate::LOCALES_USER;
|
use crate::LOCALES_USER;
|
||||||
|
use crate::role_path;
|
||||||
|
|
||||||
use crate::component::error_banner;
|
use crate::component::error_banner;
|
||||||
|
|
||||||
|
|
@ -35,13 +36,8 @@ impl Component for RoleForm {
|
||||||
|
|
||||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||||
let action = match self.mode() {
|
let action = match self.mode() {
|
||||||
RoleFormMode::New => format!("{ADMIN_ROLES_PATH}/new"),
|
RoleFormMode::New => util::join!(ADMIN_ROLES_PATH, "/new"),
|
||||||
RoleFormMode::Edit => {
|
RoleFormMode::Edit => role_path(self.role_id().copied().unwrap_or_default(), "edit"),
|
||||||
format!(
|
|
||||||
"{ADMIN_ROLES_PATH}/{}/edit",
|
|
||||||
self.role_id().copied().unwrap_or_default()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let action = self.waypoint().append_to(cx.route(action));
|
let action = self.waypoint().append_to(cx.route(action));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@
|
||||||
|
|
||||||
use pagetop::prelude::*;
|
use pagetop::prelude::*;
|
||||||
|
|
||||||
use crate::ADMIN_ROLES_PATH;
|
|
||||||
use crate::LOCALES_USER;
|
use crate::LOCALES_USER;
|
||||||
|
use crate::role_path;
|
||||||
|
|
||||||
use crate::component::admin::PermissionGroups;
|
use crate::component::admin::PermissionGroups;
|
||||||
use crate::component::error_banner;
|
use crate::component::error_banner;
|
||||||
|
|
@ -26,7 +26,7 @@ impl Component for RolePermissionsForm {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||||
let action = format!("{ADMIN_ROLES_PATH}/{}/permissions", self.role_id());
|
let action = role_path(self.role_id(), "permissions");
|
||||||
let action = self.waypoint().append_to(cx.route(action));
|
let action = self.waypoint().append_to(cx.route(action));
|
||||||
|
|
||||||
let mut form = Form::new()
|
let mut form = Form::new()
|
||||||
|
|
@ -37,12 +37,12 @@ impl Component for RolePermissionsForm {
|
||||||
|
|
||||||
for (idx, (group_label, perms)) in self.groups().iter().enumerate() {
|
for (idx, (group_label, perms)) in self.groups().iter().enumerate() {
|
||||||
let mut field = form::check::Field::new()
|
let mut field = form::check::Field::new()
|
||||||
.with_id(format!("permission-group-{idx}"))
|
.with_id(util::join!("permission-group-", idx.to_string()))
|
||||||
.with_name("permission_keys");
|
.with_name("permission_keys");
|
||||||
for (key, label, checked) in perms {
|
for (key, label, checked) in perms {
|
||||||
let text = label.lookup(cx).unwrap_or_default();
|
let text = label.lookup(cx).unwrap_or_default();
|
||||||
field = field.with_item(
|
field = field.with_item(
|
||||||
form::check::Item::new(key, Lc::n(format!("{text} ({key})")))
|
form::check::Item::new(key, Lc::n(util::join!(text, " (", key, ")")))
|
||||||
.with_checked(*checked),
|
.with_checked(*checked),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
//! Tabla de roles: cabeceras ordenables y filas con acciones.
|
//! Tabla de roles: cabeceras ordenables y filas con acciones.
|
||||||
|
|
||||||
use pagetop::prelude::*;
|
use pagetop::prelude::*;
|
||||||
|
|
||||||
use pagetop::base::component::table::{Column, Row};
|
|
||||||
use pagetop_htmx::hx;
|
use pagetop_htmx::hx;
|
||||||
use pagetop_htmx::hx_table::sort_link;
|
use pagetop_htmx::hx_table::sort_link;
|
||||||
|
|
||||||
use crate::ADMIN_ROLES_PATH;
|
use crate::ADMIN_ROLES_PATH;
|
||||||
use crate::LOCALES_USER;
|
use crate::LOCALES_USER;
|
||||||
|
use crate::role_path;
|
||||||
use crate::service::role_admin::{RoleListItem, RoleSortField};
|
use crate::service::role_admin::{RoleListItem, RoleSortField};
|
||||||
|
|
||||||
#[derive(AutoDefault, Clone, Debug, Getters)]
|
#[derive(AutoDefault, Clone, Debug, Getters)]
|
||||||
|
|
@ -92,7 +91,7 @@ impl Component for RoleTable {
|
||||||
};
|
};
|
||||||
|
|
||||||
table.alter_row(
|
table.alter_row(
|
||||||
Row::new()
|
table::Row::new()
|
||||||
.with_cell(role.machine_name.as_str())
|
.with_cell(role.machine_name.as_str())
|
||||||
.with_cell(label_cell(role, &waypoint))
|
.with_cell(label_cell(role, &waypoint))
|
||||||
.with_cell(Html::with(move |_cx| {
|
.with_cell(Html::with(move |_cx| {
|
||||||
|
|
@ -108,7 +107,7 @@ impl Component for RoleTable {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let new_href = waypoint.append_to(cx.route(format!("{ADMIN_ROLES_PATH}/new")));
|
let new_href = waypoint.append_to(cx.route(util::join!(ADMIN_ROLES_PATH, "/new")));
|
||||||
|
|
||||||
Ok(html! {
|
Ok(html! {
|
||||||
div (self.props().unpack(cx)) {
|
div (self.props().unpack(cx)) {
|
||||||
|
|
@ -187,7 +186,12 @@ impl RoleTable {
|
||||||
// Construye la cabecera ordenable de una columna: el enlace ya funciona sin HTMX (navega con
|
// Construye la cabecera ordenable de una columna: el enlace ya funciona sin HTMX (navega con
|
||||||
// una petición normal); `pagetop_htmx::hx_table::sort_link()` añade aparte los atributos `hx-*`
|
// una petición normal); `pagetop_htmx::hx_table::sort_link()` añade aparte los atributos `hx-*`
|
||||||
// para que la tabla se actualice sin recargar la página cuando la extensión esté disponible.
|
// para que la tabla se actualice sin recargar la página cuando la extensión esté disponible.
|
||||||
fn sort_column(&self, cx: &Context, field: RoleSortField, label_key: &'static str) -> Column {
|
fn sort_column(
|
||||||
|
&self,
|
||||||
|
cx: &Context,
|
||||||
|
field: RoleSortField,
|
||||||
|
label_key: &'static str,
|
||||||
|
) -> table::Column {
|
||||||
let is_active = *self.sort() == field;
|
let is_active = *self.sort() == field;
|
||||||
let active = is_active.then_some(*self.dir());
|
let active = is_active.then_some(*self.dir());
|
||||||
let next_dir = SortDir::next_for(active);
|
let next_dir = SortDir::next_for(active);
|
||||||
|
|
@ -196,7 +200,7 @@ impl RoleTable {
|
||||||
.alter_param("sort", field.as_str())
|
.alter_param("sort", field.as_str())
|
||||||
.alter_param("dir", next_dir);
|
.alter_param("dir", next_dir);
|
||||||
|
|
||||||
Column::new(Lc::t(label_key, &LOCALES_USER)).with_sort(sort_link(
|
table::Column::new(Lc::t(label_key, &LOCALES_USER)).with_sort(sort_link(
|
||||||
route,
|
route,
|
||||||
"#role-table-wrapper",
|
"#role-table-wrapper",
|
||||||
active,
|
active,
|
||||||
|
|
@ -212,7 +216,7 @@ fn label_cell(role: &RoleListItem, waypoint: &Waypoint) -> Html {
|
||||||
let id = role.id;
|
let id = role.id;
|
||||||
let waypoint = waypoint.clone();
|
let waypoint = waypoint.clone();
|
||||||
Html::with(move |cx| {
|
Html::with(move |cx| {
|
||||||
let view_href = waypoint.append_to(cx.route(format!("{ADMIN_ROLES_PATH}/{id}/view")));
|
let view_href = waypoint.append_to(cx.route(role_path(id, "view")));
|
||||||
html! {
|
html! {
|
||||||
a href=(view_href) { (label.as_str()) }
|
a href=(view_href) { (label.as_str()) }
|
||||||
}
|
}
|
||||||
|
|
@ -234,8 +238,7 @@ async fn actions_cell(
|
||||||
let locked = role.locked;
|
let locked = role.locked;
|
||||||
let waypoint = waypoint.clone();
|
let waypoint = waypoint.clone();
|
||||||
|
|
||||||
let permissions_href =
|
let permissions_href = waypoint.append_to(cx.route(role_path(id, "permissions")));
|
||||||
waypoint.append_to(cx.route(format!("{ADMIN_ROLES_PATH}/{id}/permissions")));
|
|
||||||
|
|
||||||
// Los botones se renderizan aquí, no dentro del `Html::with()` de abajo: necesitan pasar por
|
// Los botones se renderizan aquí, no dentro del `Html::with()` de abajo: necesitan pasar por
|
||||||
// su propio ciclo de renderizado (`.render().await`) para que el tema activo los estilice
|
// su propio ciclo de renderizado (`.render().await`) para que el tema activo los estilice
|
||||||
|
|
@ -253,7 +256,7 @@ async fn actions_cell(
|
||||||
let (edit_button, delete_button) = if locked {
|
let (edit_button, delete_button) = if locked {
|
||||||
(None, None)
|
(None, None)
|
||||||
} else {
|
} else {
|
||||||
let edit_href = waypoint.append_to(cx.route(format!("{ADMIN_ROLES_PATH}/{id}/edit")));
|
let edit_href = waypoint.append_to(cx.route(role_path(id, "edit")));
|
||||||
let edit_button = Button::anchor(Lc::t("btn-edit", &LOCALES_USER), edit_href)
|
let edit_button = Button::anchor(Lc::t("btn-edit", &LOCALES_USER), edit_href)
|
||||||
.with_style(button::Style::Solid(Intent::Primary))
|
.with_style(button::Style::Solid(Intent::Primary))
|
||||||
.with_size(button::Size::Small)
|
.with_size(button::Size::Small)
|
||||||
|
|
@ -263,7 +266,7 @@ async fn actions_cell(
|
||||||
// Viaja como query string para que, tanto si el borrado falla como si tiene éxito, la
|
// Viaja como query string para que, tanto si el borrado falla como si tiene éxito, la
|
||||||
// tabla vuelva a mostrarse en la misma página/orden en que estaba, en vez de reiniciarse.
|
// tabla vuelva a mostrarse en la misma página/orden en que estaba, en vez de reiniciarse.
|
||||||
let confirm_href = cx
|
let confirm_href = cx
|
||||||
.route(format!("{ADMIN_ROLES_PATH}/{id}/delete/confirm"))
|
.route(role_path(id, "delete/confirm"))
|
||||||
.alter_param("sort", sort.as_str())
|
.alter_param("sort", sort.as_str())
|
||||||
.alter_param("dir", dir)
|
.alter_param("dir", dir)
|
||||||
.alter_param("page", page.to_string())
|
.alter_param("page", page.to_string())
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ use pagetop::prelude::*;
|
||||||
|
|
||||||
use crate::ADMIN_USERS_PATH;
|
use crate::ADMIN_USERS_PATH;
|
||||||
use crate::LOCALES_USER;
|
use crate::LOCALES_USER;
|
||||||
|
use crate::user_path;
|
||||||
|
|
||||||
use crate::component::{PasswordConfirm, error_banner};
|
use crate::component::{PasswordConfirm, error_banner};
|
||||||
|
|
||||||
|
|
@ -43,13 +44,8 @@ impl Component for UserForm {
|
||||||
|
|
||||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||||
let action = match self.mode() {
|
let action = match self.mode() {
|
||||||
UserFormMode::New => format!("{ADMIN_USERS_PATH}/new"),
|
UserFormMode::New => util::join!(ADMIN_USERS_PATH, "/new"),
|
||||||
UserFormMode::Edit => {
|
UserFormMode::Edit => user_path(self.user_id().copied().unwrap_or_default(), "edit"),
|
||||||
format!(
|
|
||||||
"{ADMIN_USERS_PATH}/{}/edit",
|
|
||||||
self.user_id().copied().unwrap_or_default()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let action = self.waypoint().append_to(cx.route(action));
|
let action = self.waypoint().append_to(cx.route(action));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
|
|
||||||
use pagetop::prelude::*;
|
use pagetop::prelude::*;
|
||||||
|
|
||||||
use crate::ADMIN_USERS_PATH;
|
|
||||||
use crate::LOCALES_USER;
|
use crate::LOCALES_USER;
|
||||||
|
use crate::user_path;
|
||||||
|
|
||||||
use crate::component::error_banner;
|
use crate::component::error_banner;
|
||||||
|
|
||||||
|
|
@ -25,7 +25,7 @@ impl Component for UserRolesForm {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||||
let action = format!("{ADMIN_USERS_PATH}/{}/roles", self.user_id());
|
let action = user_path(self.user_id(), "roles");
|
||||||
let action = self.waypoint().append_to(cx.route(action));
|
let action = self.waypoint().append_to(cx.route(action));
|
||||||
|
|
||||||
let mut form = Form::new()
|
let mut form = Form::new()
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
//! Tabla de usuarios: cabeceras ordenables, filas y paginación embebida.
|
//! Tabla de usuarios: cabeceras ordenables, filas y paginación embebida.
|
||||||
|
|
||||||
use pagetop::prelude::*;
|
use pagetop::prelude::*;
|
||||||
|
|
||||||
use pagetop::base::component::table::{Column, Row};
|
|
||||||
use pagetop_htmx::hx;
|
use pagetop_htmx::hx;
|
||||||
use pagetop_htmx::hx_table::sort_link;
|
use pagetop_htmx::hx_table::sort_link;
|
||||||
|
|
||||||
|
|
@ -11,6 +9,7 @@ use crate::LOCALES_USER;
|
||||||
use crate::account::UserStatus;
|
use crate::account::UserStatus;
|
||||||
use crate::permission::UserPermission;
|
use crate::permission::UserPermission;
|
||||||
use crate::service::user_admin::{UserListItem, UserSortField};
|
use crate::service::user_admin::{UserListItem, UserSortField};
|
||||||
|
use crate::user_path;
|
||||||
|
|
||||||
#[derive(AutoDefault, Clone, Debug, Getters)]
|
#[derive(AutoDefault, Clone, Debug, Getters)]
|
||||||
pub(crate) struct UserTable {
|
pub(crate) struct UserTable {
|
||||||
|
|
@ -74,7 +73,7 @@ impl Component for UserTable {
|
||||||
let status = user.status;
|
let status = user.status;
|
||||||
|
|
||||||
table.alter_row(
|
table.alter_row(
|
||||||
Row::new()
|
table::Row::new()
|
||||||
.with_cell(username_cell(user, &waypoint))
|
.with_cell(username_cell(user, &waypoint))
|
||||||
.with_cell(user.email.as_str())
|
.with_cell(user.email.as_str())
|
||||||
.with_cell(user.display_name.as_deref().unwrap_or("-"))
|
.with_cell(user.display_name.as_deref().unwrap_or("-"))
|
||||||
|
|
@ -84,7 +83,7 @@ impl Component for UserTable {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let new_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/new")));
|
let new_href = waypoint.append_to(cx.route(util::join!(ADMIN_USERS_PATH, "/new")));
|
||||||
|
|
||||||
Ok(html! {
|
Ok(html! {
|
||||||
div (self.props().unpack(cx)) {
|
div (self.props().unpack(cx)) {
|
||||||
|
|
@ -162,7 +161,12 @@ impl UserTable {
|
||||||
// Construye la cabecera ordenable de una columna: el enlace ya funciona sin HTMX (navega con
|
// Construye la cabecera ordenable de una columna: el enlace ya funciona sin HTMX (navega con
|
||||||
// una petición normal); `pagetop_htmx::hx_table::sort_link()` añade aparte los atributos `hx-*`
|
// una petición normal); `pagetop_htmx::hx_table::sort_link()` añade aparte los atributos `hx-*`
|
||||||
// para que la tabla se actualice sin recargar la página cuando la extensión esté disponible.
|
// para que la tabla se actualice sin recargar la página cuando la extensión esté disponible.
|
||||||
fn sort_column(&self, cx: &Context, field: UserSortField, label_key: &'static str) -> Column {
|
fn sort_column(
|
||||||
|
&self,
|
||||||
|
cx: &Context,
|
||||||
|
field: UserSortField,
|
||||||
|
label_key: &'static str,
|
||||||
|
) -> table::Column {
|
||||||
let is_active = *self.sort() == field;
|
let is_active = *self.sort() == field;
|
||||||
let active = is_active.then_some(*self.dir());
|
let active = is_active.then_some(*self.dir());
|
||||||
let next_dir = SortDir::next_for(active);
|
let next_dir = SortDir::next_for(active);
|
||||||
|
|
@ -175,7 +179,7 @@ impl UserTable {
|
||||||
.alter_param("sort", field.as_str())
|
.alter_param("sort", field.as_str())
|
||||||
.alter_param("dir", next_dir);
|
.alter_param("dir", next_dir);
|
||||||
|
|
||||||
Column::new(Lc::t(label_key, &LOCALES_USER)).with_sort(sort_link(
|
table::Column::new(Lc::t(label_key, &LOCALES_USER)).with_sort(sort_link(
|
||||||
route,
|
route,
|
||||||
"#user-table-wrapper",
|
"#user-table-wrapper",
|
||||||
active,
|
active,
|
||||||
|
|
@ -191,7 +195,7 @@ fn username_cell(user: &UserListItem, waypoint: &Waypoint) -> Html {
|
||||||
let id = user.id;
|
let id = user.id;
|
||||||
let waypoint = waypoint.clone();
|
let waypoint = waypoint.clone();
|
||||||
Html::with(move |cx| {
|
Html::with(move |cx| {
|
||||||
let view_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{id}/view")));
|
let view_href = waypoint.append_to(cx.route(user_path(id, "view")));
|
||||||
html! {
|
html! {
|
||||||
a href=(view_href) { (username.as_str()) }
|
a href=(view_href) { (username.as_str()) }
|
||||||
}
|
}
|
||||||
|
|
@ -208,7 +212,7 @@ async fn actions_cell(
|
||||||
cx: &mut Context,
|
cx: &mut Context,
|
||||||
) -> Html {
|
) -> Html {
|
||||||
let id = user.id;
|
let id = user.id;
|
||||||
let edit_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{id}/edit")));
|
let edit_href = waypoint.append_to(cx.route(user_path(id, "edit")));
|
||||||
|
|
||||||
// El botón se renderiza aquí, no dentro del `Html::with()` de abajo: necesita pasar por su
|
// El botón se renderiza aquí, no dentro del `Html::with()` de abajo: necesita pasar por su
|
||||||
// propio ciclo de renderizado (`.render().await`) para que el tema activo lo estilice igual
|
// propio ciclo de renderizado (`.render().await`) para que el tema activo lo estilice igual
|
||||||
|
|
@ -220,7 +224,7 @@ async fn actions_cell(
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let roles_button = if can_assign_roles {
|
let roles_button = if can_assign_roles {
|
||||||
let roles_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{id}/roles")));
|
let roles_href = waypoint.append_to(cx.route(user_path(id, "roles")));
|
||||||
Some(
|
Some(
|
||||||
Button::anchor(Lc::t("btn-manage-roles", &LOCALES_USER), roles_href)
|
Button::anchor(Lc::t("btn-manage-roles", &LOCALES_USER), roles_href)
|
||||||
.with_style(button::Style::Solid(Intent::Neutral))
|
.with_style(button::Style::Solid(Intent::Neutral))
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,7 @@
|
||||||
//! `Extension::initialize()`, después de `auth::seed_initial_data()`. Si el rol
|
//! `Extension::initialize()`, después de `auth::seed_initial_data()`. Si el rol
|
||||||
//! `demo_role_01` ya existe, la siembra se omite para no duplicar datos en reinicios.
|
//! `demo_role_01` ya existe, la siembra se omite para no duplicar datos en reinicios.
|
||||||
|
|
||||||
use pagetop::datetime::Utc;
|
use pagetop::prelude::*;
|
||||||
|
|
||||||
use pagetop_seaorm::db::{ActiveValue, ColumnTrait, EntityTrait, QueryFilter, Set, dbconn};
|
use pagetop_seaorm::db::{ActiveValue, ColumnTrait, EntityTrait, QueryFilter, Set, dbconn};
|
||||||
|
|
||||||
use crate::account::UserStatus;
|
use crate::account::UserStatus;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
|
use pagetop::prelude::*;
|
||||||
use pagetop_seaorm::db::*;
|
use pagetop_seaorm::db::*;
|
||||||
|
|
||||||
use pagetop::datetime::NaiveDateTime;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
|
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
|
||||||
#[sea_orm(table_name = "roles")]
|
#[sea_orm(table_name = "roles")]
|
||||||
pub struct Model {
|
pub struct Model {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
|
use pagetop::prelude::*;
|
||||||
use pagetop_seaorm::db::*;
|
use pagetop_seaorm::db::*;
|
||||||
|
|
||||||
use pagetop::datetime::NaiveDateTime;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
|
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
|
||||||
#[sea_orm(table_name = "role_permissions")]
|
#[sea_orm(table_name = "role_permissions")]
|
||||||
pub struct Model {
|
pub struct Model {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
|
use pagetop::prelude::*;
|
||||||
use pagetop_seaorm::db::*;
|
use pagetop_seaorm::db::*;
|
||||||
|
|
||||||
use pagetop::datetime::NaiveDateTime;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
|
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
|
||||||
#[sea_orm(table_name = "sessions")]
|
#[sea_orm(table_name = "sessions")]
|
||||||
pub struct Model {
|
pub struct Model {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
|
use pagetop::prelude::*;
|
||||||
use pagetop_seaorm::db::*;
|
use pagetop_seaorm::db::*;
|
||||||
|
|
||||||
use pagetop::datetime::NaiveDateTime;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
|
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
|
||||||
#[sea_orm(table_name = "users")]
|
#[sea_orm(table_name = "users")]
|
||||||
pub struct Model {
|
pub struct Model {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
|
use pagetop::prelude::*;
|
||||||
use pagetop_seaorm::db::*;
|
use pagetop_seaorm::db::*;
|
||||||
|
|
||||||
use pagetop::datetime::NaiveDateTime;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
|
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
|
||||||
#[sea_orm(table_name = "user_tokens")]
|
#[sea_orm(table_name = "user_tokens")]
|
||||||
pub struct Model {
|
pub struct Model {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
//! Handler HTTP para el perfil del propio usuario autenticado.
|
//! Handler HTTP para el perfil del propio usuario autenticado.
|
||||||
|
|
||||||
use pagetop::base::component::table::Row;
|
|
||||||
use pagetop::prelude::*;
|
use pagetop::prelude::*;
|
||||||
|
|
||||||
use crate::account::UserStatus;
|
use crate::account::UserStatus;
|
||||||
|
|
@ -47,32 +46,32 @@ async fn profile_details(user: &user::Model, status: UserStatus, cx: &mut Contex
|
||||||
let mut table = Table::new()
|
let mut table = Table::new()
|
||||||
.with_prop(PropsOp::add_classes("user-admin-table"))
|
.with_prop(PropsOp::add_classes("user-admin-table"))
|
||||||
.with_row(
|
.with_row(
|
||||||
Row::new()
|
table::Row::new()
|
||||||
.with_cell(Lc::t("field-username-admin", &LOCALES_USER))
|
.with_cell(Lc::t("field-username-admin", &LOCALES_USER))
|
||||||
.with_cell(user.username.as_str()),
|
.with_cell(user.username.as_str()),
|
||||||
)
|
)
|
||||||
.with_row(
|
.with_row(
|
||||||
Row::new()
|
table::Row::new()
|
||||||
.with_cell(Lc::t("field-email", &LOCALES_USER))
|
.with_cell(Lc::t("field-email", &LOCALES_USER))
|
||||||
.with_cell(user.email.as_str()),
|
.with_cell(user.email.as_str()),
|
||||||
)
|
)
|
||||||
.with_row(
|
.with_row(
|
||||||
Row::new()
|
table::Row::new()
|
||||||
.with_cell(Lc::t("field-display-name", &LOCALES_USER))
|
.with_cell(Lc::t("field-display-name", &LOCALES_USER))
|
||||||
.with_cell(user.display_name.as_deref().unwrap_or("-")),
|
.with_cell(user.display_name.as_deref().unwrap_or("-")),
|
||||||
)
|
)
|
||||||
.with_row(
|
.with_row(
|
||||||
Row::new()
|
table::Row::new()
|
||||||
.with_cell(Lc::t("field-language", &LOCALES_USER))
|
.with_cell(Lc::t("field-language", &LOCALES_USER))
|
||||||
.with_cell(user.language.as_deref().unwrap_or("-")),
|
.with_cell(user.language.as_deref().unwrap_or("-")),
|
||||||
)
|
)
|
||||||
.with_row(
|
.with_row(
|
||||||
Row::new()
|
table::Row::new()
|
||||||
.with_cell(Lc::t("field-timezone", &LOCALES_USER))
|
.with_cell(Lc::t("field-timezone", &LOCALES_USER))
|
||||||
.with_cell(user.timezone.as_deref().unwrap_or("-")),
|
.with_cell(user.timezone.as_deref().unwrap_or("-")),
|
||||||
)
|
)
|
||||||
.with_row(
|
.with_row(
|
||||||
Row::new()
|
table::Row::new()
|
||||||
.with_cell(Lc::t("col-status", &LOCALES_USER))
|
.with_cell(Lc::t("col-status", &LOCALES_USER))
|
||||||
.with_cell(Lc::t(status_key(status), &LOCALES_USER)),
|
.with_cell(Lc::t(status_key(status), &LOCALES_USER)),
|
||||||
);
|
);
|
||||||
|
|
@ -83,7 +82,7 @@ async fn profile_details(user: &user::Model, status: UserStatus, cx: &mut Contex
|
||||||
.render(cx)
|
.render(cx)
|
||||||
.await;
|
.await;
|
||||||
table = table.with_row(
|
table = table.with_row(
|
||||||
Row::new()
|
table::Row::new()
|
||||||
.with_cell("")
|
.with_cell("")
|
||||||
.with_cell(Html::with(move |_| badge.clone())),
|
.with_cell(Html::with(move |_| badge.clone())),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use pagetop::base::component::table::Row;
|
|
||||||
use pagetop::prelude::*;
|
use pagetop::prelude::*;
|
||||||
use pagetop_htmx::prelude::*;
|
use pagetop_htmx::prelude::*;
|
||||||
|
|
||||||
|
|
@ -15,6 +14,7 @@ use crate::config::SETTINGS;
|
||||||
use crate::entity::role;
|
use crate::entity::role;
|
||||||
use crate::handlers::admin::{back_link, frame, map_auth_error};
|
use crate::handlers::admin::{back_link, frame, map_auth_error};
|
||||||
use crate::permission::{self, UserPermission};
|
use crate::permission::{self, UserPermission};
|
||||||
|
use crate::role_path;
|
||||||
use crate::service::role_admin::{self, RolePageParams, RoleSortField};
|
use crate::service::role_admin::{self, RolePageParams, RoleSortField};
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|
@ -312,22 +312,22 @@ async fn role_view_details(role: &role::Model, cx: &mut Context) -> Block {
|
||||||
let mut table = Table::new()
|
let mut table = Table::new()
|
||||||
.with_prop(PropsOp::add_classes("user-admin-table"))
|
.with_prop(PropsOp::add_classes("user-admin-table"))
|
||||||
.with_row(
|
.with_row(
|
||||||
Row::new()
|
table::Row::new()
|
||||||
.with_cell(Lc::t("field-machine-name", &LOCALES_USER))
|
.with_cell(Lc::t("field-machine-name", &LOCALES_USER))
|
||||||
.with_cell(role.machine_name.as_str()),
|
.with_cell(role.machine_name.as_str()),
|
||||||
)
|
)
|
||||||
.with_row(
|
.with_row(
|
||||||
Row::new()
|
table::Row::new()
|
||||||
.with_cell(Lc::t("field-label", &LOCALES_USER))
|
.with_cell(Lc::t("field-label", &LOCALES_USER))
|
||||||
.with_cell(role.label.as_str()),
|
.with_cell(role.label.as_str()),
|
||||||
)
|
)
|
||||||
.with_row(
|
.with_row(
|
||||||
Row::new()
|
table::Row::new()
|
||||||
.with_cell(Lc::t("field-description", &LOCALES_USER))
|
.with_cell(Lc::t("field-description", &LOCALES_USER))
|
||||||
.with_cell(role.description.as_deref().unwrap_or("-")),
|
.with_cell(role.description.as_deref().unwrap_or("-")),
|
||||||
)
|
)
|
||||||
.with_row(
|
.with_row(
|
||||||
Row::new()
|
table::Row::new()
|
||||||
.with_cell(Lc::t("field-weight", &LOCALES_USER))
|
.with_cell(Lc::t("field-weight", &LOCALES_USER))
|
||||||
.with_cell(role.weight.to_string()),
|
.with_cell(role.weight.to_string()),
|
||||||
);
|
);
|
||||||
|
|
@ -338,7 +338,7 @@ async fn role_view_details(role: &role::Model, cx: &mut Context) -> Block {
|
||||||
.render(cx)
|
.render(cx)
|
||||||
.await;
|
.await;
|
||||||
table = table.with_row(
|
table = table.with_row(
|
||||||
Row::new()
|
table::Row::new()
|
||||||
.with_cell("")
|
.with_cell("")
|
||||||
.with_cell(Html::with(move |_| badge.clone())),
|
.with_cell(Html::with(move |_| badge.clone())),
|
||||||
);
|
);
|
||||||
|
|
@ -451,7 +451,7 @@ pub(crate) async fn delete_confirm_get(
|
||||||
|
|
||||||
let mut cx = Context::admin(request);
|
let mut cx = Context::admin(request);
|
||||||
let delete_href = cx
|
let delete_href = cx
|
||||||
.route(format!("{ADMIN_ROLES_PATH}/{id}/delete"))
|
.route(role_path(id, "delete"))
|
||||||
.alter_param(
|
.alter_param(
|
||||||
"sort",
|
"sort",
|
||||||
RoleSortField::from_query(query.sort.as_deref()).as_str(),
|
RoleSortField::from_query(query.sort.as_deref()).as_str(),
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,9 @@
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use pagetop::base::component::table::Row;
|
|
||||||
use pagetop::prelude::*;
|
use pagetop::prelude::*;
|
||||||
use pagetop_htmx::prelude::*;
|
use pagetop_htmx::prelude::*;
|
||||||
|
|
||||||
use crate::ADMIN_ROLES_PATH;
|
|
||||||
use crate::ADMIN_USERS_PATH;
|
use crate::ADMIN_USERS_PATH;
|
||||||
use crate::ANONYMOUS_ROLE_ID;
|
use crate::ANONYMOUS_ROLE_ID;
|
||||||
use crate::AUTHENTICATED_ROLE_ID;
|
use crate::AUTHENTICATED_ROLE_ID;
|
||||||
|
|
@ -22,8 +20,10 @@ use crate::error::AuthError;
|
||||||
use crate::handlers::admin::{back_link, frame, map_auth_error};
|
use crate::handlers::admin::{back_link, frame, map_auth_error};
|
||||||
use crate::password;
|
use crate::password;
|
||||||
use crate::permission::UserPermission;
|
use crate::permission::UserPermission;
|
||||||
|
use crate::role_path;
|
||||||
use crate::service::role_admin;
|
use crate::service::role_admin;
|
||||||
use crate::service::user_admin::{self, UserListParams, UserSortField};
|
use crate::service::user_admin::{self, UserListParams, UserSortField};
|
||||||
|
use crate::user_path;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub(crate) struct UsersQuery {
|
pub(crate) struct UsersQuery {
|
||||||
|
|
@ -341,12 +341,10 @@ fn edit_actions(
|
||||||
("true", "btn-grant-admin", "confirm-grant-admin")
|
("true", "btn-grant-admin", "confirm-grant-admin")
|
||||||
};
|
};
|
||||||
|
|
||||||
let roles_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{user_id}/roles")));
|
let roles_href = waypoint.append_to(cx.route(user_path(user_id, "roles")));
|
||||||
let password_href =
|
let password_href = waypoint.append_to(cx.route(user_path(user_id, "password")));
|
||||||
waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{user_id}/password")));
|
let status_action = waypoint.append_to(cx.route(user_path(user_id, "status")));
|
||||||
let status_action =
|
let admin_action = waypoint.append_to(cx.route(user_path(user_id, "admin")));
|
||||||
waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{user_id}/status")));
|
|
||||||
let admin_action = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{user_id}/admin")));
|
|
||||||
|
|
||||||
let mut status_form = Form::new()
|
let mut status_form = Form::new()
|
||||||
.with_action(status_action.clone())
|
.with_action(status_action.clone())
|
||||||
|
|
@ -518,32 +516,32 @@ async fn user_view_details(user: &user::Model, status: UserStatus, cx: &mut Cont
|
||||||
let mut table = Table::new()
|
let mut table = Table::new()
|
||||||
.with_prop(PropsOp::add_classes("user-admin-table"))
|
.with_prop(PropsOp::add_classes("user-admin-table"))
|
||||||
.with_row(
|
.with_row(
|
||||||
Row::new()
|
table::Row::new()
|
||||||
.with_cell(Lc::t("field-username-admin", &LOCALES_USER))
|
.with_cell(Lc::t("field-username-admin", &LOCALES_USER))
|
||||||
.with_cell(user.username.as_str()),
|
.with_cell(user.username.as_str()),
|
||||||
)
|
)
|
||||||
.with_row(
|
.with_row(
|
||||||
Row::new()
|
table::Row::new()
|
||||||
.with_cell(Lc::t("field-email", &LOCALES_USER))
|
.with_cell(Lc::t("field-email", &LOCALES_USER))
|
||||||
.with_cell(user.email.as_str()),
|
.with_cell(user.email.as_str()),
|
||||||
)
|
)
|
||||||
.with_row(
|
.with_row(
|
||||||
Row::new()
|
table::Row::new()
|
||||||
.with_cell(Lc::t("field-display-name", &LOCALES_USER))
|
.with_cell(Lc::t("field-display-name", &LOCALES_USER))
|
||||||
.with_cell(user.display_name.as_deref().unwrap_or("-")),
|
.with_cell(user.display_name.as_deref().unwrap_or("-")),
|
||||||
)
|
)
|
||||||
.with_row(
|
.with_row(
|
||||||
Row::new()
|
table::Row::new()
|
||||||
.with_cell(Lc::t("field-language", &LOCALES_USER))
|
.with_cell(Lc::t("field-language", &LOCALES_USER))
|
||||||
.with_cell(user.language.as_deref().unwrap_or("-")),
|
.with_cell(user.language.as_deref().unwrap_or("-")),
|
||||||
)
|
)
|
||||||
.with_row(
|
.with_row(
|
||||||
Row::new()
|
table::Row::new()
|
||||||
.with_cell(Lc::t("field-timezone", &LOCALES_USER))
|
.with_cell(Lc::t("field-timezone", &LOCALES_USER))
|
||||||
.with_cell(user.timezone.as_deref().unwrap_or("-")),
|
.with_cell(user.timezone.as_deref().unwrap_or("-")),
|
||||||
)
|
)
|
||||||
.with_row(
|
.with_row(
|
||||||
Row::new()
|
table::Row::new()
|
||||||
.with_cell(Lc::t("col-status", &LOCALES_USER))
|
.with_cell(Lc::t("col-status", &LOCALES_USER))
|
||||||
.with_cell(Lc::t(status_key(status), &LOCALES_USER)),
|
.with_cell(Lc::t(status_key(status), &LOCALES_USER)),
|
||||||
);
|
);
|
||||||
|
|
@ -554,7 +552,7 @@ async fn user_view_details(user: &user::Model, status: UserStatus, cx: &mut Cont
|
||||||
.render(cx)
|
.render(cx)
|
||||||
.await;
|
.await;
|
||||||
table = table.with_row(
|
table = table.with_row(
|
||||||
Row::new()
|
table::Row::new()
|
||||||
.with_cell("")
|
.with_cell("")
|
||||||
.with_cell(Html::with(move |_| badge.clone())),
|
.with_cell(Html::with(move |_| badge.clone())),
|
||||||
);
|
);
|
||||||
|
|
@ -593,9 +591,10 @@ async fn user_view_roles(roles: &[role::Model], cx: &mut Context) -> Block {
|
||||||
table.user-admin-table {
|
table.user-admin-table {
|
||||||
tbody {
|
tbody {
|
||||||
@for (id, machine_name, label, system_badge) in &items {
|
@for (id, machine_name, label, system_badge) in &items {
|
||||||
|
@let href = cx.route(role_path(*id, "view")).to_string();
|
||||||
tr {
|
tr {
|
||||||
td {
|
td {
|
||||||
a href=(cx.route(format!("{ADMIN_ROLES_PATH}/{id}/view")).to_string()) {
|
a href=(href) {
|
||||||
(label.as_str())
|
(label.as_str())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -744,7 +743,7 @@ pub(crate) async fn status_post(
|
||||||
match user_admin::set_user_status(id, new_status, account.id).await {
|
match user_admin::set_user_status(id, new_status, account.id).await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let cx = Context::admin(request);
|
let cx = Context::admin(request);
|
||||||
let edit_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{id}/edit")));
|
let edit_href = waypoint.append_to(cx.route(user_path(id, "edit")));
|
||||||
if is_htmx {
|
if is_htmx {
|
||||||
Ok(HtmxResponse::empty().redirect(edit_href).into_response())
|
Ok(HtmxResponse::empty().redirect(edit_href).into_response())
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -791,7 +790,7 @@ pub(crate) async fn admin_post(
|
||||||
match user_admin::set_user_admin(id, new_is_admin, account.id).await {
|
match user_admin::set_user_admin(id, new_is_admin, account.id).await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let cx = Context::admin(request);
|
let cx = Context::admin(request);
|
||||||
let edit_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{id}/edit")));
|
let edit_href = waypoint.append_to(cx.route(user_path(id, "edit")));
|
||||||
if is_htmx {
|
if is_htmx {
|
||||||
Ok(HtmxResponse::empty().redirect(edit_href).into_response())
|
Ok(HtmxResponse::empty().redirect(edit_href).into_response())
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -816,10 +815,7 @@ pub(crate) async fn password_get(
|
||||||
return Err(ErrorPage::NotFound(Some(request)));
|
return Err(ErrorPage::NotFound(Some(request)));
|
||||||
}
|
}
|
||||||
let mut page = Page::admin(request);
|
let mut page = Page::admin(request);
|
||||||
let edit_href = waypoint.append_to(
|
let edit_href = waypoint.append_to(page.context().route(user_path(id, "edit")));
|
||||||
page.context()
|
|
||||||
.route(format!("{ADMIN_USERS_PATH}/{id}/edit")),
|
|
||||||
);
|
|
||||||
let title = Lc::t("title-admin-user-password", &LOCALES_USER);
|
let title = Lc::t("title-admin-user-password", &LOCALES_USER);
|
||||||
Ok(page
|
Ok(page
|
||||||
.with_title(title.clone())
|
.with_title(title.clone())
|
||||||
|
|
@ -861,15 +857,12 @@ pub(crate) async fn password_post(
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let cx = Context::admin(request);
|
let cx = Context::admin(request);
|
||||||
let edit_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{id}/edit")));
|
let edit_href = waypoint.append_to(cx.route(user_path(id, "edit")));
|
||||||
Ok(Redirect::see_other(edit_href).into_response())
|
Ok(Redirect::see_other(edit_href).into_response())
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let mut page = Page::admin(request);
|
let mut page = Page::admin(request);
|
||||||
let edit_href = waypoint.append_to(
|
let edit_href = waypoint.append_to(page.context().route(user_path(id, "edit")));
|
||||||
page.context()
|
|
||||||
.route(format!("{ADMIN_USERS_PATH}/{id}/edit")),
|
|
||||||
);
|
|
||||||
let title = Lc::t("title-admin-user-password", &LOCALES_USER);
|
let title = Lc::t("title-admin-user-password", &LOCALES_USER);
|
||||||
Ok(page
|
Ok(page
|
||||||
.with_title(title.clone())
|
.with_title(title.clone())
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use pagetop::auth::CurrentUser;
|
|
||||||
use pagetop::prelude::*;
|
use pagetop::prelude::*;
|
||||||
|
|
||||||
use crate::auth;
|
use crate::auth;
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,16 @@ const ADMIN_ROLES_PATH: &str = "/admin/user/roles";
|
||||||
// Catálogo de permisos registrados, agrupado por extensión (solo lectura).
|
// Catálogo de permisos registrados, agrupado por extensión (solo lectura).
|
||||||
const ADMIN_PERMISSIONS_PATH: &str = "/admin/user/permissions";
|
const ADMIN_PERMISSIONS_PATH: &str = "/admin/user/permissions";
|
||||||
|
|
||||||
|
// Ruta de una acción sobre el usuario `id`: `{ADMIN_USERS_PATH}/{id}/{tail}`.
|
||||||
|
fn user_path(id: i32, tail: &str) -> String {
|
||||||
|
util::join!(ADMIN_USERS_PATH, "/", id.to_string(), "/", tail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ruta de una acción sobre el rol `id`: `{ADMIN_ROLES_PATH}/{id}/{tail}`.
|
||||||
|
fn role_path(id: i32, tail: &str) -> String {
|
||||||
|
util::join!(ADMIN_ROLES_PATH, "/", id.to_string(), "/", tail)
|
||||||
|
}
|
||||||
|
|
||||||
// **< Registro en pagetop-admin (fijo) >***********************************************************
|
// **< Registro en pagetop-admin (fijo) >***********************************************************
|
||||||
|
|
||||||
// Registra las páginas de usuarios, roles y permisos en el portal de `pagetop-admin`, bajo la
|
// Registra las páginas de usuarios, roles y permisos en el portal de `pagetop-admin`, bajo la
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,7 @@
|
||||||
//! si el usuario está autenticado, el [`crate::account::Account`] con sus datos ricos en las
|
//! si el usuario está autenticado, el [`crate::account::Account`] con sus datos ricos en las
|
||||||
//! extensiones de la petición HTTP.
|
//! extensiones de la petición HTTP.
|
||||||
|
|
||||||
use pagetop::auth::PermissionRef;
|
use pagetop::prelude::*;
|
||||||
use pagetop::web::middleware::Next;
|
|
||||||
use pagetop::web::{Request, Response};
|
|
||||||
|
|
||||||
use crate::account::Account;
|
use crate::account::Account;
|
||||||
use crate::session;
|
use crate::session;
|
||||||
|
|
@ -17,7 +15,10 @@ use crate::session;
|
||||||
///
|
///
|
||||||
/// - Siempre inserta [`pagetop::auth::CurrentUser`] (anónimo o autenticado).
|
/// - Siempre inserta [`pagetop::auth::CurrentUser`] (anónimo o autenticado).
|
||||||
/// - Si hay sesión activa, inserta también el [`Account`] con roles y permisos.
|
/// - Si hay sesión activa, inserta también el [`Account`] con roles y permisos.
|
||||||
pub(crate) async fn session_middleware(mut req: Request, next: Next) -> Response {
|
pub(crate) async fn session_middleware(
|
||||||
|
mut req: web::Request,
|
||||||
|
next: web::middleware::Next,
|
||||||
|
) -> Response {
|
||||||
let (current_user, maybe_account) = session::resolve_session(req.headers()).await;
|
let (current_user, maybe_account) = session::resolve_session(req.headers()).await;
|
||||||
|
|
||||||
req.extensions_mut().insert(current_user);
|
req.extensions_mut().insert(current_user);
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,7 @@
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use pagetop::datetime::Utc;
|
use pagetop::prelude::*;
|
||||||
use pagetop::html::SortDir;
|
|
||||||
use pagetop_seaorm::db::{
|
use pagetop_seaorm::db::{
|
||||||
ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, Order, Paginated, PaginatorTrait,
|
ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, Order, Paginated, PaginatorTrait,
|
||||||
QueryFilter, QueryOrder, QuerySelect, Set, TransactionTrait, dbconn, flatten_txn_err, paginate,
|
QueryFilter, QueryOrder, QuerySelect, Set, TransactionTrait, dbconn, flatten_txn_err, paginate,
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,7 @@
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use pagetop::datetime::Utc;
|
use pagetop::prelude::*;
|
||||||
use pagetop::html::SortDir;
|
|
||||||
use pagetop::util;
|
|
||||||
use pagetop_seaorm::db::{
|
use pagetop_seaorm::db::{
|
||||||
ActiveModelTrait, ActiveValue, ColumnTrait, Condition, EntityTrait, Order, Paginated,
|
ActiveModelTrait, ActiveValue, ColumnTrait, Condition, EntityTrait, Order, Paginated,
|
||||||
PaginatorTrait, QueryFilter, QueryOrder, Set, TransactionTrait, dbconn, flatten_txn_err,
|
PaginatorTrait, QueryFilter, QueryOrder, Set, TransactionTrait, dbconn, flatten_txn_err,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
//! Gestión de sesiones de usuario (creación, carga, destrucción).
|
//! Gestión de sesiones de usuario (creación, carga, destrucción).
|
||||||
|
|
||||||
use pagetop::auth::CurrentUser;
|
use pagetop::prelude::*;
|
||||||
use pagetop::datetime::{Duration, Utc};
|
|
||||||
use pagetop::web::http::{HeaderMap, header};
|
|
||||||
use pagetop_seaorm::db::{
|
use pagetop_seaorm::db::{
|
||||||
ActiveModelTrait, ColumnTrait, DbErr, EntityTrait, QueryFilter, Set, dbconn,
|
ActiveModelTrait, ColumnTrait, DbErr, EntityTrait, QueryFilter, Set, dbconn,
|
||||||
};
|
};
|
||||||
|
|
@ -35,32 +33,33 @@ fn bytes_to_hex(bytes: &[u8]) -> String {
|
||||||
|
|
||||||
/// Construye el valor de la cabecera `Set-Cookie` para la cookie de sesión.
|
/// Construye el valor de la cabecera `Set-Cookie` para la cookie de sesión.
|
||||||
pub fn build_cookie(sid: &str, remember: bool) -> String {
|
pub fn build_cookie(sid: &str, remember: bool) -> String {
|
||||||
let mut parts = vec![
|
let mut cookie = util::join!(
|
||||||
format!("{}={}", SETTINGS.session_cookie_name, sid),
|
&SETTINGS.session_cookie_name,
|
||||||
"HttpOnly".into(),
|
"=",
|
||||||
"SameSite=Lax".into(),
|
sid,
|
||||||
"Path=/".into(),
|
"; HttpOnly; SameSite=Lax; Path=/"
|
||||||
];
|
);
|
||||||
if SETTINGS.secure_cookie {
|
if SETTINGS.secure_cookie {
|
||||||
parts.push("Secure".into());
|
cookie.push_str("; Secure");
|
||||||
}
|
}
|
||||||
if remember {
|
if remember {
|
||||||
parts.push(format!("Max-Age={}", SETTINGS.session_ttl_secs));
|
cookie.push_str("; Max-Age=");
|
||||||
|
cookie.push_str(&SETTINGS.session_ttl_secs.to_string());
|
||||||
}
|
}
|
||||||
parts.join("; ")
|
cookie
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Construye la cookie de expiración (Max-Age=0) para borrar la sesión del navegador.
|
/// Construye la cookie de expiración (Max-Age=0) para borrar la sesión del navegador.
|
||||||
pub fn expiry_cookie() -> String {
|
pub fn expiry_cookie() -> String {
|
||||||
format!(
|
util::join!(
|
||||||
"{}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0",
|
&SETTINGS.session_cookie_name,
|
||||||
SETTINGS.session_cookie_name
|
"=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extrae el session ID de las cabeceras HTTP de la petición, si existe.
|
/// Extrae el session ID de las cabeceras HTTP de la petición, si existe.
|
||||||
pub fn extract_sid(headers: Option<&HeaderMap>) -> Option<String> {
|
pub fn extract_sid(headers: Option<&web::http::HeaderMap>) -> Option<String> {
|
||||||
let cookie_str = headers?.get(header::COOKIE)?.to_str().ok()?;
|
let cookie_str = headers?.get(web::http::header::COOKIE)?.to_str().ok()?;
|
||||||
|
|
||||||
let name = SETTINGS.session_cookie_name.as_str();
|
let name = SETTINGS.session_cookie_name.as_str();
|
||||||
for part in cookie_str.split(';') {
|
for part in cookie_str.split(';') {
|
||||||
|
|
@ -78,7 +77,7 @@ pub fn extract_sid(headers: Option<&HeaderMap>) -> Option<String> {
|
||||||
///
|
///
|
||||||
/// Si no hay cookie o la sesión ha expirado, devuelve `(CurrentUser::Anonymous, None)`.
|
/// Si no hay cookie o la sesión ha expirado, devuelve `(CurrentUser::Anonymous, None)`.
|
||||||
/// Se llama desde el middleware de sesión, que es async.
|
/// Se llama desde el middleware de sesión, que es async.
|
||||||
pub async fn resolve_session(headers: &HeaderMap) -> (CurrentUser, Option<Account>) {
|
pub async fn resolve_session(headers: &web::http::HeaderMap) -> (CurrentUser, Option<Account>) {
|
||||||
let Some(sid) = extract_sid(Some(headers)) else {
|
let Some(sid) = extract_sid(Some(headers)) else {
|
||||||
return (CurrentUser::Anonymous, None);
|
return (CurrentUser::Anonymous, None);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
//! Generación y verificación de tokens de un solo uso (reset de contraseña, verificación de
|
//! Generación y verificación de tokens de un solo uso (reset de contraseña, verificación de
|
||||||
//! email...).
|
//! email...).
|
||||||
|
|
||||||
use pagetop::datetime::{Duration, Utc};
|
use pagetop::prelude::*;
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
|
|
||||||
use pagetop_seaorm::db::{
|
use pagetop_seaorm::db::{
|
||||||
ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter, Set, dbconn,
|
ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter, Set, dbconn,
|
||||||
};
|
};
|
||||||
|
|
@ -11,6 +9,8 @@ use pagetop_seaorm::db::{
|
||||||
use crate::entity::user_token;
|
use crate::entity::user_token;
|
||||||
use crate::error::AuthError;
|
use crate::error::AuthError;
|
||||||
|
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
// **< Generación de tokens >***********************************************************************
|
// **< Generación de tokens >***********************************************************************
|
||||||
|
|
||||||
/// Genera un token URL-safe de 43 caracteres (32 bytes -> base64url sin padding).
|
/// Genera un token URL-safe de 43 caracteres (32 bytes -> base64url sin padding).
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,5 @@ license.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
concat-string.workspace = true
|
|
||||||
indoc.workspace = true
|
indoc.workspace = true
|
||||||
pastey.workspace = true
|
pastey.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -28,11 +28,6 @@ concatenación de cadenas y el uso rápido de colecciones clave-valor.
|
||||||
Las macros para texto multilínea **`indoc!`**, **`formatdoc!`** y **`concatdoc!`** se reexportan del
|
Las macros para texto multilínea **`indoc!`**, **`formatdoc!`** y **`concatdoc!`** se reexportan del
|
||||||
*crate* [indoc](https://crates.io/crates/indoc) de [David Tolnay](https://crates.io/users/dtolnay).
|
*crate* [indoc](https://crates.io/crates/indoc) de [David Tolnay](https://crates.io/users/dtolnay).
|
||||||
|
|
||||||
Las macros para la concatenación de cadenas **`join!`** y **`join_pair!`** se apoyan internamente en
|
|
||||||
el *crate* [concat-string](https://crates.io/crates/concat_string), desarrollado por
|
|
||||||
[FaultyRAM](https://crates.io/users/FaultyRAM), para evitar el formato de cadenas cuando la
|
|
||||||
eficiencia pueda ser relevante.
|
|
||||||
|
|
||||||
La macro para generar identificadores dinámicos **`paste!`** se reexporta del *crate*
|
La macro para generar identificadores dinámicos **`paste!`** se reexporta del *crate*
|
||||||
[pastey](https://crates.io/crates/pastey), una implementación avanzada y soportada del popular
|
[pastey](https://crates.io/crates/pastey), una implementación avanzada y soportada del popular
|
||||||
`paste!` de [David Tolnay](https://crates.io/users/dtolnay).
|
`paste!` de [David Tolnay](https://crates.io/users/dtolnay).
|
||||||
|
|
|
||||||
|
|
@ -29,11 +29,6 @@ concatenación de cadenas y el uso rápido de colecciones clave-valor.
|
||||||
Las macros para texto multilínea **`indoc!`**, **`formatdoc!`** y **`concatdoc!`** se reexportan del
|
Las macros para texto multilínea **`indoc!`**, **`formatdoc!`** y **`concatdoc!`** se reexportan del
|
||||||
*crate* [indoc](https://crates.io/crates/indoc) de [David Tolnay](https://crates.io/users/dtolnay).
|
*crate* [indoc](https://crates.io/crates/indoc) de [David Tolnay](https://crates.io/users/dtolnay).
|
||||||
|
|
||||||
Las macros para la concatenación de cadenas **`join!`** y **`join_pair!`** se apoyan internamente en
|
|
||||||
el *crate* [concat-string](https://crates.io/crates/concat_string), desarrollado por
|
|
||||||
[FaultyRAM](https://crates.io/users/FaultyRAM), para evitar el formato de cadenas cuando la
|
|
||||||
eficiencia pueda ser relevante.
|
|
||||||
|
|
||||||
La macro para generar identificadores dinámicos **`paste!`** se reexporta del *crate*
|
La macro para generar identificadores dinámicos **`paste!`** se reexporta del *crate*
|
||||||
[pastey](https://crates.io/crates/pastey), una implementación avanzada y soportada del popular
|
[pastey](https://crates.io/crates/pastey), una implementación avanzada y soportada del popular
|
||||||
`paste!` de [David Tolnay](https://crates.io/users/dtolnay).
|
`paste!` de [David Tolnay](https://crates.io/users/dtolnay).
|
||||||
|
|
@ -43,9 +38,6 @@ La macro para generar identificadores dinámicos **`paste!`** se reexporta del *
|
||||||
html_favicon_url = "https://git.cillero.es/manuelcillero/pagetop/raw/branch/main/assets/favicon.ico"
|
html_favicon_url = "https://git.cillero.es/manuelcillero/pagetop/raw/branch/main/assets/favicon.ico"
|
||||||
)]
|
)]
|
||||||
|
|
||||||
#[doc(hidden)]
|
|
||||||
pub use concat_string::concat_string;
|
|
||||||
|
|
||||||
pub use indoc::{concatdoc, formatdoc, indoc};
|
pub use indoc::{concatdoc, formatdoc, indoc};
|
||||||
|
|
||||||
/// Permite *pegar* tokens y generar identificadores a partir de otros.
|
/// Permite *pegar* tokens y generar identificadores a partir de otros.
|
||||||
|
|
@ -60,11 +52,17 @@ pub use pastey::paste;
|
||||||
// La documentación anterior se copia en `pagetop::util::paste!` porque el *crate* original no la
|
// La documentación anterior se copia en `pagetop::util::paste!` porque el *crate* original no la
|
||||||
// define y `pagetop` no la hereda automáticamente.
|
// define y `pagetop` no la hereda automáticamente.
|
||||||
|
|
||||||
/// Concatena eficientemente varios fragmentos en un [`String`].
|
/// Concatena varios fragmentos en un [`String`] reservando una sola vez la memoria del resultado.
|
||||||
///
|
///
|
||||||
/// Esta macro exporta [`concat_string!`](https://docs.rs/concat-string). Acepta cualquier número de
|
/// Acepta uno o más fragmentos que implementen [`AsRef<str>`] (literales, `&str`, [`String`],
|
||||||
/// fragmentos que implementen [`AsRef<str>`] y construye un [`String`] con el tamaño óptimo, de
|
/// `Cow<str>`...) y construye el resultado con la capacidad exacta, sin pasar por el formateo. Es
|
||||||
/// forma eficiente y evitando el uso de cadenas de formato que penalicen el rendimiento.
|
/// la forma preferida de componer texto a partir de fragmentos, y suele ser más rápida que
|
||||||
|
/// `format!`.
|
||||||
|
///
|
||||||
|
/// Cada expresión se evalúa **una sola vez**, por lo que admite llamadas como `n.to_string()` o
|
||||||
|
/// cierres con efectos secundarios. Los valores que no son cadenas (números, tipos `Display`) se
|
||||||
|
/// convierten antes con `to_string()`. Si hace falta formato real (`{:02}`, `{:?}`, `{:.2}`...),
|
||||||
|
/// usar `format!`.
|
||||||
///
|
///
|
||||||
/// # Ejemplo
|
/// # Ejemplo
|
||||||
///
|
///
|
||||||
|
|
@ -81,11 +79,26 @@ pub use pastey::paste;
|
||||||
/// // Un único fragmento devuelve el mismo valor.
|
/// // Un único fragmento devuelve el mismo valor.
|
||||||
/// let single_result = join!("Hello");
|
/// let single_result = join!("Hello");
|
||||||
/// assert_eq!(single_result, "Hello".to_string());
|
/// assert_eq!(single_result, "Hello".to_string());
|
||||||
|
///
|
||||||
|
/// // Los fragmentos pueden ser de tipos distintos; los números se convierten antes.
|
||||||
|
/// let name = String::from("item");
|
||||||
|
/// let result_mixed = join!(&name, "-", 7.to_string());
|
||||||
|
/// assert_eq!(result_mixed, "item-7".to_string());
|
||||||
|
///
|
||||||
|
/// // Cada expresión se evalúa una sola vez.
|
||||||
|
/// let mut calls = 0;
|
||||||
|
/// let mut next = || {
|
||||||
|
/// calls += 1;
|
||||||
|
/// calls.to_string()
|
||||||
|
/// };
|
||||||
|
/// let result_once = join!("n", next());
|
||||||
|
/// assert_eq!(result_once, "n1".to_string());
|
||||||
|
/// assert_eq!(calls, 1);
|
||||||
/// ```
|
/// ```
|
||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! join {
|
macro_rules! join {
|
||||||
($($arg:expr),+) => {
|
($($arg:expr),+ $(,)?) => {
|
||||||
$crate::concat_string!($($arg),+)
|
[$(::core::convert::AsRef::<str>::as_ref(&$arg)),+].concat()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,15 +139,15 @@ macro_rules! join_pair {
|
||||||
let second_val = $second;
|
let second_val = $second;
|
||||||
let separator_val = $separator;
|
let separator_val = $separator;
|
||||||
|
|
||||||
let first = AsRef::<str>::as_ref(&first_val);
|
let first = ::core::convert::AsRef::<str>::as_ref(&first_val);
|
||||||
let second = AsRef::<str>::as_ref(&second_val);
|
let second = ::core::convert::AsRef::<str>::as_ref(&second_val);
|
||||||
let separator = if first.is_empty() || second.is_empty() {
|
let separator = if first.is_empty() || second.is_empty() {
|
||||||
""
|
""
|
||||||
} else {
|
} else {
|
||||||
AsRef::<str>::as_ref(&separator_val)
|
::core::convert::AsRef::<str>::as_ref(&separator_val)
|
||||||
};
|
};
|
||||||
|
|
||||||
$crate::concat_string!(first, separator, second)
|
[first, separator, second].concat()
|
||||||
}};
|
}};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,8 +47,9 @@ pub mod image;
|
||||||
#[doc(inline)]
|
#[doc(inline)]
|
||||||
pub use image::Image;
|
pub use image::Image;
|
||||||
|
|
||||||
mod intro;
|
pub mod intro;
|
||||||
pub use intro::{Intro, IntroOpening};
|
#[doc(inline)]
|
||||||
|
pub use intro::Intro;
|
||||||
|
|
||||||
mod messages;
|
mod messages;
|
||||||
pub use messages::Messages;
|
pub use messages::Messages;
|
||||||
|
|
|
||||||
|
|
@ -25,28 +25,61 @@ pub enum Kind {
|
||||||
Article,
|
Article,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **< Width >**************************************************************************************
|
||||||
|
|
||||||
|
/// Define cómo se comporta el ancho de un contenedor ([`Container`]).
|
||||||
|
///
|
||||||
|
/// Cada variante se traduce en una clase CSS (`container`, `container-{nombre}` o
|
||||||
|
/// `container-fluid`), con el nombre que el tema activo da al punto de corte, y el tema decide qué
|
||||||
|
/// anchos máximos le corresponden. [`FluidMax`](Self::FluidMax) añade además un `max-width` en
|
||||||
|
/// línea.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub enum Width {
|
||||||
|
/// Aplica los anchos máximos predefinidos por el tema para cada punto de corte. Por debajo del
|
||||||
|
/// menor punto de corte ocupa el 100% del ancho disponible.
|
||||||
|
Responsive,
|
||||||
|
/// Aplica los anchos máximos predefinidos a partir del punto de corte indicado. Por debajo de
|
||||||
|
/// ese punto de corte ocupa el 100% del ancho disponible.
|
||||||
|
From(Breakpoint),
|
||||||
|
/// Ocupa el 100% del ancho disponible siempre.
|
||||||
|
Fluid,
|
||||||
|
/// Ocupa el 100% del ancho disponible hasta un ancho máximo explícito.
|
||||||
|
FluidMax(UnitValue),
|
||||||
|
}
|
||||||
|
|
||||||
// **< Container >**********************************************************************************
|
// **< Container >**********************************************************************************
|
||||||
|
|
||||||
/// Componente para crear un **contenedor de componentes**.
|
/// Componente para crear un **contenedor de componentes**.
|
||||||
///
|
///
|
||||||
/// Envuelve un conjunto de componentes en un contenedor establecido que se crea aplicando uno de
|
/// Envuelve un conjunto de componentes en un contenedor establecido que se crea aplicando uno de
|
||||||
/// los tipos definidos en [`Kind`].
|
/// los tipos definidos en [`Kind`]. Opcionalmente, su ancho se controla con [`Width`] mediante
|
||||||
|
/// [`with_width()`](Self::with_width); sin él, el contenedor no añade ninguna clase de ancho.
|
||||||
///
|
///
|
||||||
/// Si no contiene elementos, el componente **no se renderiza**.
|
/// Si no contiene elementos, el componente **no se renderiza**.
|
||||||
///
|
///
|
||||||
/// # Ejemplo
|
/// # Ejemplos
|
||||||
///
|
///
|
||||||
/// ```rust,no_run
|
/// ```rust,no_run
|
||||||
/// use pagetop::prelude::*;
|
/// use pagetop::prelude::*;
|
||||||
///
|
///
|
||||||
/// let main = Container::main().with_id("main-page");
|
/// let main = Container::main().with_id("main-page");
|
||||||
/// ```
|
/// ```
|
||||||
|
///
|
||||||
|
/// Contenedor centrado que ocupa todo el ancho hasta un máximo explícito:
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// # use pagetop::prelude::*;
|
||||||
|
/// let page = Container::new().with_width(container::Width::FluidMax(UnitValue::RelRem(75.0)));
|
||||||
|
/// ```
|
||||||
#[derive(AutoDefault, Clone, Debug, Getters)]
|
#[derive(AutoDefault, Clone, Debug, Getters)]
|
||||||
pub struct Container {
|
pub struct Container {
|
||||||
/// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente.
|
/// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente.
|
||||||
props: Props,
|
props: Props,
|
||||||
/// Devuelve el tipo semántico del contenedor.
|
/// Devuelve el tipo semántico del contenedor.
|
||||||
kind: Kind,
|
kind: Kind,
|
||||||
|
/// Devuelve el comportamiento del ancho, si se ha fijado.
|
||||||
|
#[getters(copy)]
|
||||||
|
width: Option<Width>,
|
||||||
/// Devuelve la lista de componentes (`children`) del contenedor.
|
/// Devuelve la lista de componentes (`children`) del contenedor.
|
||||||
children: Children,
|
children: Children,
|
||||||
}
|
}
|
||||||
|
|
@ -61,6 +94,25 @@ impl Component for Container {
|
||||||
self.props.get_id()
|
self.props.get_id()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn setup(&mut self, cx: &mut Context) {
|
||||||
|
if let Some(width) = self.width() {
|
||||||
|
let class: CowStr = match width {
|
||||||
|
Width::Responsive => "container".into(),
|
||||||
|
Width::From(bp) => match bp.resolved(cx) {
|
||||||
|
Some(entry) => util::join!("container-", entry.name).into(),
|
||||||
|
None => "container".into(),
|
||||||
|
},
|
||||||
|
Width::Fluid | Width::FluidMax(_) => "container-fluid".into(),
|
||||||
|
};
|
||||||
|
self.alter_prop(PropsOp::prepend_classes(class));
|
||||||
|
if let Width::FluidMax(max) = width
|
||||||
|
&& max.is_measurable()
|
||||||
|
{
|
||||||
|
self.alter_prop(PropsOp::add_style("max-width", max.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[rustfmt::skip]
|
#[rustfmt::skip]
|
||||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||||
let output = self.children().render(cx).await;
|
let output = self.children().render(cx).await;
|
||||||
|
|
@ -135,6 +187,15 @@ impl Container {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Define el comportamiento del ancho del contenedor. Con `None` no se añade ninguna clase de
|
||||||
|
/// ancho y cada tema decide cómo tratarlo.
|
||||||
|
///
|
||||||
|
/// Ver [`Width`] para las variantes disponibles.
|
||||||
|
pub fn with_width(mut self, width: impl Into<Option<Width>>) -> Self {
|
||||||
|
self.width = width.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Añade un nuevo componente al contenedor o modifica la lista de componentes (`children`) con
|
/// Añade un nuevo componente al contenedor o modifica la lista de componentes (`children`) con
|
||||||
/// una operación [`ChildOp`].
|
/// una operación [`ChildOp`].
|
||||||
pub fn with_child(mut self, op: impl Into<ChildOp>) -> Self {
|
pub fn with_child(mut self, op: impl Into<ChildOp>) -> Self {
|
||||||
|
|
|
||||||
|
|
@ -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`].
|
mod props;
|
||||||
///
|
pub use props::{Kind, Width};
|
||||||
/// 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,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Componente para divulgar PageTop (como hace [`Welcome`](crate::base::extension::Welcome)), o
|
mod component;
|
||||||
/// mostrar presentaciones.
|
pub use component::Intro;
|
||||||
///
|
|
||||||
/// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
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::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::{ChildOp, Component, ComponentError, ComponentRender};
|
||||||
use crate::core::component::{Context, Contextual};
|
use crate::core::component::{Context, Contextual};
|
||||||
use crate::core::extension::Extension;
|
use crate::core::extension::Extension;
|
||||||
|
|
@ -413,11 +413,9 @@ pub trait Theme: Extension + Send + Sync {
|
||||||
page.alter_title(title).alter_child_in(
|
page.alter_title(title).alter_child_in(
|
||||||
&CoreRegions::Content,
|
&CoreRegions::Content,
|
||||||
ChildOp::Prepend(
|
ChildOp::Prepend(
|
||||||
Intro::new()
|
Intro::custom()
|
||||||
.with_title(Lc::l("error_code").with_arg("code", code.to_string()))
|
.with_title(Lc::l("error_code").with_arg("code", code.to_string()))
|
||||||
.with_slogan(Lc::n(code.to_string()))
|
.with_slogan(Lc::n(code.to_string()))
|
||||||
.with_button(None)
|
|
||||||
.with_opening(IntroOpening::Custom)
|
|
||||||
.with_child(Html::with(move |cx| {
|
.with_child(Html::with(move |cx| {
|
||||||
html! {
|
html! {
|
||||||
h1 { (alert.using(cx)) }
|
h1 { (alert.using(cx)) }
|
||||||
|
|
|
||||||
|
|
@ -31,3 +31,83 @@ async fn main_kind_renders_a_main_element() {
|
||||||
assert!(html.starts_with("<main"));
|
assert!(html.starts_with("<main"));
|
||||||
assert!(html.ends_with("</main>"));
|
assert!(html.ends_with("</main>"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **< Container::with_width >**********************************************************************
|
||||||
|
|
||||||
|
async fn container_html(width: impl Into<Option<container::Width>>) -> String {
|
||||||
|
let mut container = Container::new().with_width(width).with_child(Lc::n("x"));
|
||||||
|
container
|
||||||
|
.render(&mut Context::default())
|
||||||
|
.await
|
||||||
|
.into_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn width_is_none_by_default_and_adds_no_class() {
|
||||||
|
assert_eq!(Container::new().width(), None);
|
||||||
|
// A neutral box: without a width, the component adds no class at all.
|
||||||
|
assert!(!container_html(None).await.contains("container"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn width_can_be_set_and_reset() {
|
||||||
|
let width = container::Width::From(Breakpoint::Lg);
|
||||||
|
assert_eq!(Container::new().with_width(width).width(), Some(width));
|
||||||
|
assert_eq!(
|
||||||
|
Container::new().with_width(width).with_width(None).width(),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn width_responsive_adds_the_container_class() {
|
||||||
|
assert!(
|
||||||
|
container_html(container::Width::Responsive)
|
||||||
|
.await
|
||||||
|
.contains(r#"class="container""#)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn width_from_uses_the_breakpoint_name() {
|
||||||
|
assert!(
|
||||||
|
container_html(container::Width::From(Breakpoint::Lg))
|
||||||
|
.await
|
||||||
|
.contains(r#"class="container-lg""#)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn width_from_a_breakpoint_without_min_width_has_no_name() {
|
||||||
|
// `Xs` has no minimum width, so the class carries no breakpoint name.
|
||||||
|
assert!(
|
||||||
|
container_html(container::Width::From(Breakpoint::Xs))
|
||||||
|
.await
|
||||||
|
.contains(r#"class="container""#)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn width_fluid_adds_the_container_fluid_class() {
|
||||||
|
assert!(
|
||||||
|
container_html(container::Width::Fluid)
|
||||||
|
.await
|
||||||
|
.contains(r#"class="container-fluid""#)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn width_fluid_max_adds_the_class_and_a_max_width_style() {
|
||||||
|
let html = container_html(container::Width::FluidMax(UnitValue::RelRem(75.0))).await;
|
||||||
|
|
||||||
|
assert!(html.contains("container-fluid"));
|
||||||
|
assert!(html.contains("max-width: 75rem"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn width_fluid_max_ignores_a_non_measurable_value() {
|
||||||
|
let html = container_html(container::Width::FluidMax(UnitValue::Auto)).await;
|
||||||
|
|
||||||
|
assert!(html.contains("container-fluid"));
|
||||||
|
assert!(!html.contains("max-width"));
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue