diff --git a/CREDITS.md b/CREDITS.md index f61bf867..227257d1 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -1,9 +1,9 @@ -# 馃攦 Dependencias +# Dependencias PageTop est谩 basado en [Rust](https://www.rust-lang.org/) y crece a hombros de gigantes aprovechando algunas de las librer铆as m谩s robustas y populares del [ecosistema Rust](https://lib.rs/) como son: - * [Actix Web](https://actix.rs/) para los servicios web. + * [Axum](https://github.com/tokio-rs/axum) para los servicios web. * [Config](https://docs.rs/config) para cargar y procesar las opciones de configuraci贸n. * [Tracing](https://github.com/tokio-rs/tracing) para la gesti贸n de trazas y registro de eventos de la aplicaci贸n. @@ -13,7 +13,7 @@ algunas de las librer铆as m谩s robustas y populares del [ecosistema Rust](https: PageTop y sus extensiones. -# 馃棜 FIGfonts +# FIGfonts PageTop usa el *crate* [figlet-rs](https://crates.io/crates/figlet-rs) desarrollado por *yuanbohan* para mostrar un banner de presentaci贸n en el terminal con el nombre de la aplicaci贸n en caracteres @@ -26,7 +26,13 @@ para mostrar un banner de presentaci贸n en el terminal con el nombre de la aplic * [starwars.flf](http://www.figlet.org/fontdb_example.cgi?font=starwars.flf) de *Ryan Youck* -# 馃懢 Icono +# Men煤s accesibles + +El tema Basic de PageTop usa [Accessible Menu](https://github.com/NickDJM/accessible-menu), +desarrollado por *Nick Milton*, para implementar la navegaci贸n de sus men煤s con soporte avanzado. + + +# Icono "La Mascota" sonriente es una simp谩tica creaci贸n de [Webalys](https://www.iconfinder.com/webalys). Forma parte de su colecci贸n [Nasty Icons](https://www.iconfinder.com/iconsets/nasty), disponible en diff --git a/Cargo.lock b/Cargo.lock index 1ad66c7c..e00901ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -115,6 +115,18 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -242,6 +254,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -251,6 +272,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bstr" version = "1.12.1" @@ -399,6 +429,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "core-foundation" version = "0.10.1" @@ -424,6 +460,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.4.0" @@ -501,6 +546,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "darling" version = "0.20.11" @@ -541,7 +595,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "pem-rfc7468", "zeroize", ] @@ -583,12 +637,23 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1053,7 +1118,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -1116,6 +1181,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.9.0" @@ -1489,7 +1563,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", ] [[package]] @@ -1716,6 +1790,7 @@ dependencies = [ name = "pagetop" version = "0.5.0" dependencies = [ + "async-trait", "axum", "chrono", "colored", @@ -1736,6 +1811,7 @@ dependencies = [ "serde_json", "tempfile", "terminal_size", + "thiserror", "tokio", "tower", "tower-http", @@ -1745,6 +1821,17 @@ dependencies = [ "unic-langid", ] +[[package]] +name = "pagetop-admin" +version = "0.1.0" +dependencies = [ + "pagetop", + "pagetop-seaorm", + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "pagetop-aliner" version = "0.1.0" @@ -1777,6 +1864,7 @@ version = "0.1.0" dependencies = [ "pagetop", "pagetop-build", + "serde_json", ] [[package]] @@ -1789,6 +1877,19 @@ dependencies = [ "syn", ] +[[package]] +name = "pagetop-menu" +version = "0.1.0" +dependencies = [ + "chrono", + "pagetop", + "pagetop-seaorm", + "sea-orm", + "serde", + "thiserror", + "tokio", +] + [[package]] name = "pagetop-minimal" version = "0.1.0" @@ -1802,7 +1903,6 @@ dependencies = [ name = "pagetop-seaorm" version = "0.0.4" dependencies = [ - "async-trait", "pagetop", "sea-orm", "sea-schema", @@ -1820,6 +1920,24 @@ dependencies = [ "path-slash 0.2.1", ] +[[package]] +name = "pagetop-user" +version = "0.1.0" +dependencies = [ + "argon2", + "base64ct", + "pagetop", + "pagetop-admin", + "pagetop-htmx", + "pagetop-seaorm", + "rand_core", + "serde", + "serde_json", + "serde_qs", + "sha2 0.11.0", + "thiserror", +] + [[package]] name = "parking" version = "2.2.1" @@ -1862,6 +1980,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + [[package]] name = "pastey" version = "0.2.2" @@ -2161,8 +2290,8 @@ version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "const-oid", - "digest", + "const-oid 0.9.6", + "digest 0.10.7", "num-bigint-dig", "num-integer", "num-traits", @@ -2260,6 +2389,7 @@ checksum = "2dc312fedd460a47ea563911761d254a84e7b51d8cc73ec92c929e78f33fa957" dependencies = [ "async-stream", "async-trait", + "chrono", "derive_more", "futures-util", "log", @@ -2295,6 +2425,7 @@ version = "0.32.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a5d1c518eaf5eda38e5773f902b26ab6d5e9e9e2bb2349ca6c64cf96f80448c" dependencies = [ + "chrono", "inherent", "ordered-float", "sea-query-derive", @@ -2306,6 +2437,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b0019f47430f7995af63deda77e238c17323359af241233ec768aba1faea7608" dependencies = [ + "chrono", "sea-query", "sqlx", ] @@ -2436,6 +2568,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_qs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67d525c8ff68aa99e5818302259bdd02d86d0303710616f39c0f44846ff6d332" +dependencies = [ + "itoa", + "percent-encoding", + "ryu", + "serde", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -2464,8 +2608,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -2475,8 +2619,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -2510,7 +2665,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core", ] @@ -2591,6 +2746,7 @@ checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ "base64", "bytes", + "chrono", "crc", "crossbeam-queue", "either", @@ -2609,7 +2765,7 @@ dependencies = [ "percent-encoding", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smallvec", "thiserror", "tokio", @@ -2646,7 +2802,7 @@ dependencies = [ "quote", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "sqlx-core", "sqlx-mysql", "sqlx-postgres", @@ -2667,8 +2823,9 @@ dependencies = [ "bitflags", "byteorder", "bytes", + "chrono", "crc", - "digest", + "digest 0.10.7", "dotenvy", "either", "futures-channel", @@ -2689,7 +2846,7 @@ dependencies = [ "rsa", "serde", "sha1", - "sha2", + "sha2 0.10.9", "smallvec", "sqlx-core", "stringprep", @@ -2708,6 +2865,7 @@ dependencies = [ "base64", "bitflags", "byteorder", + "chrono", "crc", "dotenvy", "etcetera", @@ -2726,7 +2884,7 @@ dependencies = [ "rand", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smallvec", "sqlx-core", "stringprep", @@ -2742,6 +2900,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" dependencies = [ "atoi", + "chrono", "flume", "futures-channel", "futures-core", @@ -3044,9 +3203,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.11" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233" dependencies = [ "bitflags", "bytes", @@ -3065,6 +3224,7 @@ dependencies = [ "tokio-util", "tower-layer", "tower-service", + "tracing", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 03a5944a..730922ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,10 @@ members = [ "extensions/pagetop-aliner", "extensions/pagetop-bootsier", "extensions/pagetop-htmx", + "extensions/pagetop-admin", + "extensions/pagetop-menu", "extensions/pagetop-seaorm", + "extensions/pagetop-user", ] [workspace.package] @@ -21,8 +24,10 @@ license = "MIT OR Apache-2.0" authors = ["Manuel Cillero "] [workspace.dependencies] +argon2 = "0.5" async-trait = "0.1" axum = { version = "0.8" } +base64ct = { version = "1.8", features = ["alloc"] } change-detection = "1.2" chrono = "0.4" colored = "3.1" @@ -43,14 +48,18 @@ path-slash = "0.2" proc-macro2 = "1.0" proc-macro2-diagnostics = { version = "0.10", default-features = false } quote = "1.0" +rand_core = { version = "0.6", features = ["getrandom"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +serde_qs = "1.1" +sha2 = "0.11" syn = { version = "2.0", features = ["full", "extra-traits"] } tempfile = "3.27" terminal_size = "0.4" +thiserror = "2.0" tokio = { version = "1", features = ["full"] } tower = { version = "0.5", features = ["util"] } -tower-http = { version = "0.6", features = ["fs"] } +tower-http = { version = "0.7", features = ["catch-panic", "fs"] } tracing = "0.1" tracing-appender = "0.2" tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] } @@ -62,16 +71,19 @@ pagetop-macros = { version = "0.3", path = "helpers/pagetop-macros" } pagetop-minimal = { version = "0.1", path = "helpers/pagetop-minimal" } pagetop-statics = { version = "0.1", path = "helpers/pagetop-statics" } # Extensions -pagetop-aliner = { version = "0.1", path = "extensions/pagetop-aliner" } +pagetop-admin = { version = "0.1", path = "extensions/pagetop-admin" } +pagetop-aliner = { version = "0.1", path = "extensions/pagetop-aliner" } pagetop-bootsier = { version = "0.1", path = "extensions/pagetop-bootsier" } -pagetop-htmx = { version = "0.1", path = "extensions/pagetop-htmx" } -pagetop-seaorm = { version = "0.0", path = "extensions/pagetop-seaorm" } +pagetop-htmx = { version = "0.1", path = "extensions/pagetop-htmx" } +pagetop-menu = { version = "0.1", path = "extensions/pagetop-menu" } +pagetop-seaorm = { version = "0.0", path = "extensions/pagetop-seaorm" } +pagetop-user = { version = "0.1", path = "extensions/pagetop-user" } # PageTop pagetop = { version = "0.5", path = "." } [workspace.dependencies.sea-orm] version = "1.1" -features = ["debug-print", "macros", "runtime-tokio-native-tls"] +features = ["debug-print", "macros", "runtime-tokio-native-tls", "with-chrono"] default-features = false [workspace.dependencies.sea-schema] @@ -99,6 +111,7 @@ default = [] testing = [] [dependencies] +async-trait.workspace = true axum.workspace = true chrono.workspace = true colored.workspace = true @@ -114,6 +127,7 @@ pagetop-minimal.workspace = true pagetop-statics.workspace = true serde.workspace = true terminal_size.workspace = true +thiserror.workspace = true tokio.workspace = true tower.workspace = true tower-http.workspace = true diff --git a/assets/css/basic.css b/assets/css/basic.css index 50e870c2..5a4afbc0 100644 --- a/assets/css/basic.css +++ b/assets/css/basic.css @@ -44,21 +44,10 @@ body { -webkit-tap-highlight-color: transparent; } -/* - * Brand component - */ - -.brand { - display: inline-flex; - align-items: center; - gap: 0.5rem; - color: var(--val-color--text); - font-size: 1.75rem; - font-weight: 600; - text-decoration: none; -} -.brand .image { - display: flex; +/* Header, Aside, Content y Footer se apilan en el mismo orden ocupando todo el ancho disponible. */ +.wrapper { + display: flex; + flex-direction: column; } /* @@ -87,6 +76,70 @@ body { .badge-warning { background-color: var(--val-color--warning); } .badge-severe { background-color: var(--val-color--danger); } +/* + * Brand component + */ + +.brand { + display: inline-flex; + align-items: center; + gap: 0.5rem; + color: var(--val-color--text); + font-size: 1.75rem; + font-weight: 600; + text-decoration: none; +} +.brand .image { + display: flex; +} + +/* + * Breadcrumb component + */ + +.breadcrumb { + display: flex; + flex-wrap: wrap; + list-style: none; + margin: 0 0 1rem; + padding: 0.75rem 1rem; + font-size: 0.875rem; +} +.breadcrumb-item + .breadcrumb-item { + padding-left: 0.5rem; +} +.breadcrumb-item + .breadcrumb-item::before { + float: left; + padding-right: 0.5rem; + color: var(--val-color--text--muted); + content: "/"; +} +.breadcrumb-item.active { + color: var(--val-color--text--muted); +} +.breadcrumb-item a { + color: var(--val-color--text); + text-decoration: none; +} +.breadcrumb-item a:hover { + text-decoration: underline; +} + +/* Geometr铆a base de .dropdown-button/.dropdown-toggle (ver "Dropdown component" m谩s abajo). Se + * declara aqu铆, antes de "Buttons", para que las clases .button-sm/.button-lg/.button-* ganen el + * empate de especificidad frente a ella cuando se combinan en el mismo elemento (CSS resuelve un + * empate por orden de aparici贸n en el fichero, no por el orden de las clases en el HTML). */ +.dropdown-button, +.dropdown-toggle { + padding: 0.5rem 0.75rem; + font: inherit; + color: var(--val-color--text); + background-color: transparent; + border: 1px solid var(--val-color--border); + border-radius: 0.375rem; + cursor: pointer; +} + /* * Buttons */ @@ -194,6 +247,171 @@ body { gap: 0.5rem; } +/* + * Dialog component + */ + +.dialog { + padding: 0; + width: 100%; + max-width: 32rem; + color: var(--val-color--text); + background-color: var(--val-color--bg); + border: none; + border-radius: 0.5rem; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); +} +.dialog::backdrop { + background-color: rgba(0, 0, 0, 0.5); +} + +.dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem 1.25rem; + border-bottom: 1px solid color-mix(in srgb, var(--val-color--border) 15%, var(--val-color--bg)); +} + +.dialog-title { + margin: 0; + font-size: 1.125rem; + font-weight: 600; +} + +.dialog-close { + width: 1.5rem; + height: 1.5rem; + padding: 0; + line-height: 1; + color: var(--val-color--text--muted); + background-color: transparent; + border: none; + cursor: pointer; +} +.dialog-close::before { + content: "\2715"; +} +.dialog-close:hover { + color: var(--val-color--text); +} + +.dialog-body { + padding: 1.25rem; +} + +.dialog-footer { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + padding: 1rem 1.25rem; + border-top: 1px solid color-mix(in srgb, var(--val-color--border) 15%, var(--val-color--bg)); +} + +/* + * Dropdown component + */ + +.dropdown { + position: relative; + display: inline-block; +} + +/* La geometr铆a base de .dropdown-button/.dropdown-toggle vive junto a "Buttons" (antes de esta + * secci贸n), para que las clases .button-sm/.button-lg/.button-* ganen el empate de especificidad + * frente a ella (CSS resuelve un empate por orden de aparici贸n en el fichero, no por el orden de + * las clases en el HTML). */ +.dropdown-toggle::after { + content: "\25BC"; + display: inline-block; + margin-left: 0.35em; + font-size: 0.8em; + transition: transform .15s ease-in-out; +} +.dropdown:has(> .dropdown-menu.show) > .dropdown-toggle::after { + transform: rotate(180deg); +} + +.dropdown-menu { + position: absolute; + z-index: 1000; + top: 100%; + left: 0; + min-width: 12rem; + margin: 0.25rem 0 0; + padding: 0.5rem 0; + list-style: none; + background-color: var(--val-color--bg); + border: 1px solid color-mix(in srgb, var(--val-color--border) 15%, var(--val-color--bg)); + border-radius: 0.375rem; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + /* Closed by default */ + display: none; +} +.dropdown-menu.show { + display: block; +} + +.dropdown-item { + display: block; + width: 100%; + padding: 0.375rem 1rem; + text-align: left; + color: var(--val-color--text); + text-decoration: none; + background-color: transparent; + border: none; + font: inherit; + cursor: pointer; +} +.dropdown-item:hover { + background-color: color-mix(in srgb, var(--val-color--text) 8%, transparent); +} +.dropdown-item.active { + color: var(--val-color--primary); + font-weight: 600; +} +.dropdown-item.disabled { + color: var(--val-color--text--muted); + pointer-events: none; +} + +.dropdown-item-text { + display: block; + padding: 0.375rem 1rem; + color: var(--val-color--text--muted); +} + +.dropdown-header { + display: block; + padding: 0.5rem 1rem 0.25rem; + margin: 0; + font-size: 0.8125rem; + font-weight: 600; + color: var(--val-color--text--muted); +} + +.dropdown-divider { + height: 0; + margin: 0.5rem 0; + border: none; + border-top: 1px solid var(--val-color--border); +} + +/* Utility: visually hidden but still accessible to assistive technology */ +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + /* * Form components */ @@ -293,175 +511,20 @@ input:disabled + label { color: var(--val-color--text--muted); } -/* - * Dialog component - */ - -.dialog { - padding: 0; - width: 100%; - max-width: 32rem; - color: var(--val-color--text); - background-color: var(--val-color--bg); - border: none; - border-radius: 0.5rem; - box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); -} -.dialog::backdrop { - background-color: rgba(0, 0, 0, 0.5); -} - -.dialog-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - padding: 1rem 1.25rem; - border-bottom: 1px solid color-mix(in srgb, var(--val-color--border) 15%, var(--val-color--bg)); -} - -.dialog-title { - margin: 0; - font-size: 1.125rem; - font-weight: 600; -} - -.dialog-close { - width: 1.5rem; - height: 1.5rem; - padding: 0; - line-height: 1; - color: var(--val-color--text--muted); - background-color: transparent; - border: none; - cursor: pointer; -} -.dialog-close::before { - content: "\2715"; -} -.dialog-close:hover { - color: var(--val-color--text); -} - -.dialog-body { - padding: 1.25rem; -} - -.dialog-footer { - display: flex; - justify-content: flex-end; - gap: 0.5rem; - padding: 1rem 1.25rem; - border-top: 1px solid color-mix(in srgb, var(--val-color--border) 15%, var(--val-color--bg)); -} - -/* - * Dropdown component - */ - -.dropdown { - position: relative; - display: inline-block; -} - -.dropdown-button, -.dropdown-toggle { - padding: 0.5rem 0.75rem; - font: inherit; - color: var(--val-color--text); - background-color: transparent; +.form-field > select.form-select, +.form-field > input.form-control, +.form-field > textarea.form-control { border: 1px solid var(--val-color--border); border-radius: 0.375rem; - cursor: pointer; + background-color: #fff; + transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out; } -.dropdown-toggle::after { - content: "\25BC"; - display: inline-block; - margin-left: 0.35em; - font-size: 0.8em; - transition: transform .15s ease-in-out; -} -.dropdown:has(> .dropdown-menu.show) > .dropdown-toggle::after { - transform: rotate(180deg); -} - -.dropdown-menu { - position: absolute; - z-index: 1000; - top: 100%; - left: 0; - min-width: 12rem; - margin: 0.25rem 0 0; - padding: 0.5rem 0; - list-style: none; - background-color: var(--val-color--bg); - border: 1px solid color-mix(in srgb, var(--val-color--border) 15%, var(--val-color--bg)); - border-radius: 0.375rem; - box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); - /* Closed by default */ - display: none; -} -.dropdown-menu.show { - display: block; -} - -.dropdown-item { - display: block; - width: 100%; - padding: 0.375rem 1rem; - text-align: left; - color: var(--val-color--text); - text-decoration: none; - background-color: transparent; - border: none; - font: inherit; - cursor: pointer; -} -.dropdown-item:hover { - background-color: color-mix(in srgb, var(--val-color--text) 8%, transparent); -} -.dropdown-item.active { - color: var(--val-color--primary); - font-weight: 600; -} -.dropdown-item.disabled { - color: var(--val-color--text--muted); - pointer-events: none; -} - -.dropdown-item-text { - display: block; - padding: 0.375rem 1rem; - color: var(--val-color--text--muted); -} - -.dropdown-header { - display: block; - padding: 0.5rem 1rem 0.25rem; - margin: 0; - font-size: 0.8125rem; - font-weight: 600; - color: var(--val-color--text--muted); -} - -.dropdown-divider { - height: 0; - margin: 0.5rem 0; - border: none; - border-top: 1px solid var(--val-color--border); -} - -/* Utility: visually hidden but still accessible to assistive technology */ -.visually-hidden { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; +.form-field > select.form-select:focus, +.form-field > input.form-control:focus, +.form-field > textarea.form-control:focus { + outline: none; + border-color: var(--val-color--primary); + box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--val-color--primary) 25%, transparent); } /* @@ -514,59 +577,123 @@ input:disabled + label { } /* - * Table component + * Nav component */ -.table-responsive { - overflow-x: auto; - -webkit-overflow-scrolling: touch; +.nav { + display: flex; + align-items: center; + gap: 0.25rem; + list-style: none; + margin: 0; + padding: 0; } -.table { - width: 100%; - border-collapse: collapse; - text-align: left; +.nav-item { + position: relative; } -.table th, -.table td { +.nav-link { + display: block; padding: 0.5rem 0.75rem; - border-bottom: 1px solid color-mix(in srgb, var(--val-color--border) 15%, var(--val-color--bg)); + color: var(--val-color--text); + text-decoration: none; + border-radius: 0.375rem; } - -.table thead th { - border-bottom-width: 2px; +.nav-link:hover { + background-color: color-mix(in srgb, var(--val-color--text) 8%, transparent); +} +.nav-link.active { + color: var(--val-color--primary); font-weight: 600; } - -.table-sort { - color: inherit; - text-decoration: none; -} -.table-sort:hover { - text-decoration: underline; -} -.table-sort::after { - content: "\25BC"; - margin-left: 0.25rem; - font-size: 0.8em; - opacity: 0.15; -} -.table-sort-asc::after { - content: "\25B2"; - font-size: 0.8em; - opacity: 1; -} -.table-sort-desc::after { - content: "\25BC"; - font-size: 0.8em; - opacity: 1; -} - -.table-empty { - text-align: center; - font-style: italic; +.nav-link.disabled { color: var(--val-color--text--muted); + pointer-events: none; +} + +.nav-start { + justify-content: flex-start; +} +.nav-center { + justify-content: center; +} +.nav-end { + justify-content: flex-end; +} +.nav-vertical { + flex-direction: column; + align-items: stretch; +} +.nav-fill > .nav-item { + flex: 1 1 auto; + text-align: center; +} +.nav-justified > .nav-item { + flex-basis: 0; + flex-grow: 1; + text-align: center; +} + +/* + * Navbar component + */ + +.navbar { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.5rem 1rem; +} + +.navbar-toggle { + display: none; + padding: 0.25rem 0.5rem; + font-size: 1.25rem; + line-height: 1; + color: inherit; + background-color: transparent; + border: 1px solid var(--val-color--border); + border-radius: 0.375rem; + cursor: pointer; +} +.navbar-toggle::before { + content: "\2630"; +} + +.navbar-content { + display: flex; + align-items: center; + gap: 1rem; + flex: 1 1 auto; +} + +.navbar-text { + color: var(--val-color--text--muted); +} + +@media (max-width: 767.98px) { + .navbar { + flex-wrap: wrap; + } + .navbar:has(.navbar-toggle) .navbar-toggle { + display: inline-block; + order: -1; + } + .navbar:has(.navbar-toggle) .navbar-content { + display: none; + flex-direction: column; + align-items: stretch; + width: 100%; + } + .navbar:has(.navbar-toggle) .navbar-content.show { + display: flex; + } + .navbar .nav { + flex-direction: column; + align-items: stretch; + width: 100%; + } } /* @@ -686,7 +813,7 @@ input:disabled + label { border: 0; border-start-end-radius: 0; border-end-end-radius: 0; - background-color: var(--val-color--light); + background-color: var(--val-color--bg--muted); } .pager-jump-input > input.form-control { text-align: center; @@ -709,36 +836,60 @@ input:disabled + label { } /* - * Breadcrumb component + * Table component */ -.breadcrumb { - display: flex; - flex-wrap: wrap; - list-style: none; - margin: 0 0 1rem; - padding: 0.75rem 1rem; - font-size: 0.875rem; +.table-responsive { + overflow-x: auto; + -webkit-overflow-scrolling: touch; } -.breadcrumb-item + .breadcrumb-item { - padding-left: 0.5rem; + +.table { + width: 100%; + border-collapse: collapse; + text-align: left; } -.breadcrumb-item + .breadcrumb-item::before { - float: left; - padding-right: 0.5rem; - color: var(--val-color--text--muted); - content: "/"; + +.table th, +.table td { + padding: 0.5rem 0.75rem; + border-bottom: 1px solid color-mix(in srgb, var(--val-color--border) 15%, var(--val-color--bg)); } -.breadcrumb-item.active { - color: var(--val-color--text--muted); + +.table thead th { + border-bottom-width: 2px; + font-weight: 600; } -.breadcrumb-item a { - color: var(--val-color--text); + +.table-sort { + color: inherit; text-decoration: none; } -.breadcrumb-item a:hover { +.table-sort:hover { text-decoration: underline; } +.table-sort::after { + content: "\25BC"; + margin-left: 0.25rem; + font-size: 0.8em; + opacity: 0.15; +} +.table-sort-asc::after { + content: "\25B2"; + font-size: 0.8em; + opacity: 1; +} +.table-sort-desc::after { + content: "\25BC"; + font-size: 0.8em; + opacity: 1; +} + +.table-empty { + text-align: center; + font-style: italic; + color: var(--val-color--text--muted); +} /* * Region Footer @@ -748,3 +899,76 @@ input:disabled + label { padding: .75rem 0 3rem; text-align: center; } + +/* + * pagetop-user: login page + */ + +.user-login-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 70vh; + padding: 2rem 1rem; + background: radial-gradient( + ellipse 120% 60% at 50% -10%, + color-mix(in srgb, var(--val-color--primary) 18%, var(--val-color--bg)) 0%, + var(--val-color--bg) 70% + ); +} + +.user-login-card { + width: 100%; + max-width: 24rem; + padding: 2rem; + background-color: #fff; + border-radius: 0.75rem; + box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.15); +} + +.user-login-card .form-label { + margin-top: 1.25rem; +} + +.user-login-card .button { + display: block; + width: 100%; + margin-top: 1.5rem; +} + +.user-admin-table-wrapper { + margin: 1.5rem 0; +} + +.user-admin-table { + font-size: 0.9375rem; +} + +.user-admin-permission-list { + list-style: none; + padding-left: 0; +} + +.user-admin-permission-granted { + font-weight: 600; +} + +.user-admin-permission-missing { + color: var(--val-color--text--muted); +} + +.user-form-error { + padding: 0.625rem 0.875rem; + margin-bottom: 1rem; + color: #fff; + background-color: var(--val-color--danger); + border-radius: 0.375rem; + font-size: 0.9375rem; +} + +.user-register-link, +.user-reset-link { + margin-top: 1rem; + text-align: center; + font-size: 0.9375rem; +} diff --git a/assets/css/intro.css b/assets/css/intro.css index bceacb68..5f07b378 100644 --- a/assets/css/intro.css +++ b/assets/css/intro.css @@ -24,7 +24,6 @@ body { .intro { position: relative; - min-width: 350px; color: var(--intro-color); background-color: var(--intro-bg-color); diff --git a/assets/js/basic.navbar.init.js b/assets/js/basic.navbar.init.js new file mode 100644 index 00000000..ee0a5e87 --- /dev/null +++ b/assets/js/basic.navbar.init.js @@ -0,0 +1,18 @@ +// Conecta cada bot贸n `.navbar-toggle` con el contenido que controla (`aria-controls`), alternando +// `aria-expanded` en el bot贸n y la clase `.show` en el contenido. A diferencia de `Dropdown` no usa +// `accessible-menu`: el contenido de una barra de navegaci贸n no es una 煤nica lista de elementos +// (puede llevar varios `Nav`, texto, lo que sea), as铆 que no encaja con el modelo de men煤 de esa +// librer铆a (ver `base/component/navbar/`). +document.querySelectorAll(".navbar-toggle").forEach(function (toggle) { + var id = toggle.getAttribute("aria-controls"); + var content = id && document.getElementById(id); + if (!content) { + return; + } + + toggle.addEventListener("click", function () { + var expanded = toggle.getAttribute("aria-expanded") === "true"; + toggle.setAttribute("aria-expanded", String(!expanded)); + content.classList.toggle("show", !expanded); + }); +}); diff --git a/build.rs b/build.rs index a83be1d4..3a7a6554 100644 --- a/build.rs +++ b/build.rs @@ -1,4 +1,4 @@ -use pagetop_build::{StaticFilesBundle, copy_dir}; +use pagetop_build::{StaticFilesBundle, compile_scss, copy_dir, minify_js}; fn main() -> std::io::Result<()> { // Regenera `static/` desde cero s贸lo si hay cambios en `assets/`. @@ -7,6 +7,21 @@ fn main() -> std::io::Result<()> { copy_dir("assets", "static")?; + // CSS: genera la variante minificada de `basic.css` del tema Basic. + compile_scss("assets/css/basic.css", "static/css/basic.min.css")?; + + // JS: minifica el manejo de `Dialog` del tema Basic. + minify_js( + "assets/js/basic.dialog.init.js", + "static/js/basic.dialog.min.js", + )?; + + // JS: minifica la integraci贸n de `Dropdown` con `accessible-menu` del tema Basic. + minify_js( + "assets/js/basic.dropdown.init.js", + "static/js/basic.dropdown.min.js", + )?; + StaticFilesBundle::from_dir("./static", None) .with_name("assets") .build() diff --git a/examples/basic-nav.rs b/examples/basic-nav.rs new file mode 100644 index 00000000..8fc37f94 --- /dev/null +++ b/examples/basic-nav.rs @@ -0,0 +1,46 @@ +use pagetop::prelude::*; + +struct NavDemo; + +#[async_trait] +impl Extension for NavDemo { + fn dependencies(&self) -> Vec { + vec![&pagetop::base::extension::Welcome] + } + + async fn initialize(&self) { + let main_nav = Nav::new() + .with_item(nav::Item::link(Lc::n("Home"), "/")) + .with_item(nav::Item::link_blank( + Lc::n("Docs"), + "https://docs.rs/pagetop", + )) + .with_item(nav::Item::dropdown( + Dropdown::new() + .with_title(Lc::n("Tools")) + .with_item(dropdown::Item::link(Lc::n("Generator"), "/tools/gen")) + .with_item(dropdown::Item::link(Lc::n("Reports"), "/tools/reports")) + .with_item(dropdown::Item::divider()) + .with_item(dropdown::Item::header(Lc::n("Danger zone"))) + .with_item(dropdown::Item::button(Lc::n("Reset settings"))), + )) + .with_item(nav::Item::link_disabled(Lc::n("Disabled"), "#")); + + let user_menu = Dropdown::new() + .with_title(Lc::n("Account")) + .with_item(dropdown::Item::label(Lc::n("Signed in as demo"))) + .with_item(dropdown::Item::divider()) + .with_item(dropdown::Item::link(Lc::n("Profile"), "/profile")) + .with_item(dropdown::Item::link(Lc::n("Settings"), "/settings")) + .with_item(dropdown::Item::divider()) + .with_item(dropdown::Item::button(Lc::n("Sign out"))); + + InRegion::Global(&CoreRegions::Header).add(main_nav); + InRegion::Global(&CoreRegions::Aside).add(user_menu); + } +} + +#[pagetop::main] +async fn main() -> std::io::Result<()> { + Application::prepare(&NavDemo).await.run().await +} diff --git a/examples/basic-navbar.rs b/examples/basic-navbar.rs new file mode 100644 index 00000000..ef4813aa --- /dev/null +++ b/examples/basic-navbar.rs @@ -0,0 +1,55 @@ +use pagetop::prelude::*; + +include_locales!(LOC from "examples/locale"); + +struct NavbarDemo; + +#[async_trait] +impl Extension for NavbarDemo { + fn dependencies(&self) -> Vec { + vec![ + &pagetop_bootsier::Bootsier, + &pagetop::base::extension::Welcome, + ] + } + + async fn initialize(&self) { + let brand = Brand::new() + .with_image(image::Source::logo(PageTopSvg::Color)) + .with_title(Lc::n("PageTop")) + .with_route(Route::from("/")); + + let main_nav = Nav::new() + .with_item(nav::Item::link(Lc::t("navbar_home", &LOC), "/")) + .with_item(nav::Item::link_blank( + Lc::t("navbar_docs", &LOC), + "https://docs.rs/pagetop", + )) + .with_item(nav::Item::dropdown( + Dropdown::new() + .with_title(Lc::t("navbar_tools", &LOC)) + .with_item(dropdown::Item::link( + Lc::t("navbar_generator", &LOC), + "/tools/gen", + )) + .with_item(dropdown::Item::link( + Lc::t("navbar_reports", &LOC), + "/tools/reports", + )), + )) + .with_item(nav::Item::link(Lc::t("navbar_active", &LOC), "/active").with_active(true)) + .with_item(nav::Item::link_disabled( + Lc::t("navbar_disabled", &LOC), + "#", + )); + + let navbar = Navbar::brand_left(brand).with_item(navbar::Item::nav(main_nav)); + + InRegion::Global(&CoreRegions::Header).add(navbar); + } +} + +#[pagetop::main] +async fn main() -> std::io::Result<()> { + Application::prepare(&NavbarDemo).await.run().await +} diff --git a/examples/locale/en-US/basic-navbar.ftl b/examples/locale/en-US/basic-navbar.ftl new file mode 100644 index 00000000..84fd12f3 --- /dev/null +++ b/examples/locale/en-US/basic-navbar.ftl @@ -0,0 +1,7 @@ +navbar_home = Home +navbar_docs = Docs +navbar_tools = Tools +navbar_generator = Generator +navbar_reports = Reports +navbar_active = Active page +navbar_disabled = Disabled diff --git a/examples/locale/es-ES/basic-navbar.ftl b/examples/locale/es-ES/basic-navbar.ftl new file mode 100644 index 00000000..bdb72660 --- /dev/null +++ b/examples/locale/es-ES/basic-navbar.ftl @@ -0,0 +1,7 @@ +navbar_home = Inicio +navbar_docs = Documentaci贸n +navbar_tools = Herramientas +navbar_generator = Generador +navbar_reports = Informes +navbar_active = P谩gina activa +navbar_disabled = Deshabilitado diff --git a/src/base/component.rs b/src/base/component.rs index 6523cfca..9fb10b77 100644 --- a/src/base/component.rs +++ b/src/base/component.rs @@ -47,6 +47,14 @@ pub use intro::{Intro, IntroOpening}; mod messages; pub use messages::Messages; +pub mod nav; +#[doc(inline)] +pub use nav::Nav; + +pub mod navbar; +#[doc(inline)] +pub use navbar::Navbar; + mod pager; pub use pager::{Pager, PagerAlign, PagerVisibility}; diff --git a/src/base/component/dropdown/component.rs b/src/base/component/dropdown/component.rs index 23019eda..0f5fe80a 100644 --- a/src/base/component/dropdown/component.rs +++ b/src/base/component/dropdown/component.rs @@ -31,6 +31,12 @@ pub struct Dropdown { title: Lc, /// Devuelve si el bot贸n se desdobla (*split*) en bot贸n de acci贸n m谩s *toggle*. button_split: bool, + /// Devuelve el tama帽o visual del bot贸n. + #[getters(copy)] + button_size: button::Size, + /// Devuelve el estilo visual del bot贸n. + #[getters(copy)] + button_style: button::Style, /// Devuelve la lista de elementos del men煤. items: Children, } @@ -65,15 +71,44 @@ impl Component for Dropdown { }); } + // Sin tama帽o ni estilo (caso m谩s com煤n), evita construir din谩micamente las clases. + let (button_classes, toggle_classes): (CowStr, CowStr) = + if matches!(self.button_size(), button::Size::None) + && matches!(self.button_style(), button::Style::None) + { + ( + CowStr::from("dropdown-button"), + CowStr::from("dropdown-toggle"), + ) + } else { + use button::{Size, Style}; + + let size_class = match self.button_size() { + Size::None => "", + Size::Small => " button-sm", + Size::Large => " button-lg", + }; + let style_class = match self.button_style() { + Style::None => String::new(), + Style::Solid(intent) => util::join!(" button-", intent.color(cx)), + Style::Outline(intent) => util::join!(" button-outline-", intent.color(cx)), + Style::Link => " button-link".to_string(), + }; + ( + util::join!("dropdown-button", size_class, &style_class).into(), + util::join!("dropdown-toggle", size_class, &style_class).into(), + ) + }; + let toggle_label = Lc::l("dropdown_toggle").using(cx); Ok(html! { div (self.props()) { @if *self.button_split() { - button type="button" class="dropdown-button" { (&title) } + button type="button" class=(&button_classes) { (&title) } button type="button" - class="dropdown-toggle" + class=(&toggle_classes) aria-haspopup="true" aria-expanded="false" { @@ -82,7 +117,7 @@ impl Component for Dropdown { } @else { button type="button" - class="dropdown-toggle" + class=(&toggle_classes) aria-haspopup="true" aria-expanded="false" { @@ -126,6 +161,20 @@ impl Dropdown { self } + /// Establece el tama帽o visual del bot贸n (usa [`button::Size::None`] para quitarlo). + #[builder_fn] + pub fn with_button_size(mut self, size: button::Size) -> Self { + self.button_size = size; + self + } + + /// Establece el estilo visual del bot贸n (usa [`button::Style::None`] para quitarlo). + #[builder_fn] + pub fn with_button_style(mut self, style: button::Style) -> Self { + self.button_style = style; + self + } + /// A帽ade un nuevo elemento al men煤 o modifica la lista de elementos del men煤 con una operaci贸n /// [`TypedOp`]. /// diff --git a/src/base/component/nav.rs b/src/base/component/nav.rs new file mode 100644 index 00000000..8e15a95d --- /dev/null +++ b/src/base/component/nav.rs @@ -0,0 +1,10 @@ +//! Definiciones para crear men煤s de navegaci贸n planos ([`Nav`]) y sus elementos ([`Item`]). + +mod props; +pub use props::Layout; + +mod component; +pub use component::Nav; + +mod item; +pub use item::{Item, ItemKind}; diff --git a/src/base/component/nav/component.rs b/src/base/component/nav/component.rs new file mode 100644 index 00000000..2ad18688 --- /dev/null +++ b/src/base/component/nav/component.rs @@ -0,0 +1,113 @@ +use crate::prelude::*; + +/// Componente para crear un **men煤 de navegaci贸n plano**. +/// +/// Presenta una lista de elementos [`nav::Item`](super::Item), donde alguno puede desplegar un +/// [`Dropdown`](super::super::Dropdown) embebido. +/// +/// Si no contiene elementos, el componente **no se renderiza**. +/// +/// # Ejemplo +/// +/// ```rust,no_run +/// use pagetop::prelude::*; +/// +/// let nav = nav::Nav::new() +/// .with_item(nav::Item::link(Lc::n("Home"), "/")) +/// .with_item(nav::Item::link_blank(Lc::n("External"), "https://docs.rs")) +/// .with_item(nav::Item::dropdown( +/// dropdown::Dropdown::new() +/// .with_title(Lc::n("Options")) +/// .with_item(dropdown::Item::link(Lc::n("Action"), "/action")) +/// .with_item(dropdown::Item::link(Lc::n("Another"), "/another")), +/// )) +/// .with_item(nav::Item::link_disabled(Lc::n("Disabled"), "#")); +/// ``` +#[derive(AutoDefault, Clone, Debug, Getters)] +pub struct Nav { + /// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente. + props: Props, + /// Devuelve la distribuci贸n y orientaci贸n seleccionada. + nav_layout: nav::Layout, + /// Devuelve la lista de elementos del men煤. + items: Children, +} + +#[async_trait] +impl Component for Nav { + fn new() -> Self { + Self::default() + } + + fn id(&self) -> Option { + self.props.get_id() + } + + fn setup(&mut self, _cx: &Context) { + self.alter_prop(PropsOp::prepend_classes(match self.nav_layout() { + nav::Layout::Default => "nav", + nav::Layout::Start => "nav nav-start", + nav::Layout::Center => "nav nav-center", + nav::Layout::End => "nav nav-end", + nav::Layout::Vertical => "nav nav-vertical", + nav::Layout::Fill => "nav nav-fill", + nav::Layout::Justified => "nav nav-justified", + })); + } + + async fn prepare(&self, cx: &mut Context) -> Result { + let items = self.items().render(cx).await; + if items.is_empty() { + return Ok(html! {}); + } + + Ok(html! { + ul (self.props()) { + (items) + } + }) + } +} + +impl Nav { + // **< Nav BUILDER >**************************************************************************** + + /// Establece el identificador 煤nico del componente; igual a `with_prop(PropsOp::set_id(id))`. + #[builder_fn] + pub fn with_id(mut self, id: impl Into) -> Self { + self.props.alter_id(id); + self + } + + /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente. + #[builder_fn] + pub fn with_prop(mut self, op: PropsOp) -> Self { + self.props.alter_prop(op); + self + } + + /// Selecciona la distribuci贸n y orientaci贸n del men煤. + #[builder_fn] + pub fn with_layout(mut self, layout: nav::Layout) -> Self { + self.nav_layout = layout; + self + } + + /// A帽ade un nuevo elemento al men煤 o modifica la lista de elementos del men煤 con una + /// operaci贸n [`TypedOp`]. + /// + /// # Ejemplo + /// + /// ```rust,ignore + /// nav.with_item(nav::Item::link("Inicio", "/")); + /// nav.with_item(TypedOp::AddMany(vec![ + /// nav::Item::link(...), + /// nav::Item::link_disabled(...), + /// ])); + /// ``` + #[builder_fn] + pub fn with_item(mut self, op: impl Into>) -> Self { + self.items.alter_child(op.into()); + self + } +} diff --git a/src/base/component/nav/item.rs b/src/base/component/nav/item.rs new file mode 100644 index 00000000..fddb1d8f --- /dev/null +++ b/src/base/component/nav/item.rs @@ -0,0 +1,291 @@ +use crate::prelude::*; + +// **< ItemKind >*********************************************************************************** + +/// Tipos de [`nav::Item`](super::Item) disponibles en un men煤 [`Nav`](super::Nav). +/// +/// Define internamente la naturaleza del elemento y su comportamiento al mostrarse o interactuar +/// con 茅l. +#[derive(AutoDefault, Clone, Debug)] +pub enum ItemKind { + /// Elemento vac铆o, no produce salida. + #[default] + Void, + /// Etiqueta sin comportamiento interactivo. + Label(Lc), + /// Elemento de navegaci贸n basado en una [`RoutePath`] din谩mica resuelta por una [`Route`]. + /// Opcionalmente, puede abrirse en una nueva ventana y estar inicialmente deshabilitado. + Link { + label: Lc, + route: Route, + blank: bool, + disabled: bool, + }, + /// Contenido HTML arbitrario. El componente [`Html`] se renderiza tal cual como elemento del + /// men煤, sin a帽adir ning煤n comportamiento de navegaci贸n adicional. + Html(Embed), + /// Elemento que despliega un men煤 [`Dropdown`](super::super::Dropdown). + Dropdown(Embed), +} + +impl ItemKind { + const ITEM: &str = "nav-item"; + const DROPDOWN: &str = "nav-item dropdown"; + + /// Devuelve las clases base asociadas al tipo de elemento. + #[inline] + pub const fn as_str(&self) -> &'static str { + match self { + Self::Void => "", + Self::Dropdown(_) => Self::DROPDOWN, + _ => Self::ITEM, + } + } +} + +// **< Item >*************************************************************************************** + +/// Representa un **elemento individual** de un men煤 [`Nav`](super::Nav). +/// +/// Cada instancia de [`nav::Item`](super::Item) se traduce en un componente visible que puede +/// comportarse como texto, enlace, contenido HTML o men煤 desplegable, seg煤n su [`ItemKind`]. +/// +/// Permite definir el identificador, las clases de estilo adicionales y el tipo de interacci贸n +/// asociada, manteniendo una interfaz com煤n para renderizar todos los elementos del men煤. +#[derive(AutoDefault, Clone, Debug, Getters)] +pub struct Item { + /// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente. + props: Props, + /// Devuelve el tipo de elemento representado. + item_kind: ItemKind, + /// Devuelve el valor de `active` forzado expl铆citamente, si lo hay. + /// + /// Sin forzar (`None`), un [`ItemKind::Link`] se marca activo cuando su ruta coincide + /// exactamente con la del *request* actual (ver [`prepare()`](Component::prepare)). Forzarlo + /// permite otros criterios, como marcar activa una secci贸n mientras la ruta actual sea + /// cualquiera de sus subrutas. + active_override: Option, +} + +#[async_trait] +impl Component for Item { + fn new() -> Self { + Self::default() + } + + fn id(&self) -> Option { + self.props.get_id() + } + + fn setup(&mut self, _cx: &Context) { + self.alter_prop(PropsOp::prepend_classes(self.item_kind().as_str())); + } + + async fn prepare(&self, cx: &mut Context) -> Result { + Ok(match self.item_kind() { + ItemKind::Void => html! {}, + + ItemKind::Label(label) => html! { + li (self.props()) { + span class="nav-link disabled" aria-disabled="true" { + (label.using(cx)) + } + } + }, + + ItemKind::Link { + label, + route, + blank, + disabled, + } => { + let enabled = !*disabled; + + // Deshabilitado, `href` no la usa: evita resolver la ruta para nada. + let route_link = enabled.then(|| route.resolve(cx)); + let current_path = cx.request().map(|request| request.path()); + let is_current = self + .active_override() + .copied() + .unwrap_or(enabled && route_link.as_ref().map(RoutePath::path) == current_path); + + let active_class = if is_current { " active" } else { "" }; + let disabled_class = if *disabled { " disabled" } else { "" }; + let classes = util::join!("nav-link", active_class, disabled_class); + + let target = (enabled && *blank).then_some("_blank"); + let rel = (enabled && *blank).then_some("noopener noreferrer"); + let aria_current = (enabled && is_current).then_some("page"); + let aria_disabled = (*disabled).then_some("true"); + + html! { + li (self.props()) { + a + class=(classes) + href=[route_link] + target=[target] + rel=[rel] + aria-current=[aria_current] + aria-disabled=[aria_disabled] + { + (label.using(cx)) + } + } + } + } + + ItemKind::Html(html) => html! { + li (self.props()) { + (html.render(cx).await) + } + }, + + ItemKind::Dropdown(menu) => { + if let Some(dd) = menu.get() { + let items = dd.items().render(cx).await; + if items.is_empty() { + return Ok(html! {}); + } + let title = dd.title().using(cx); + let title = if title.is_empty() { + Lc::l("dropdown_default_title").using(cx) + } else { + title + }; + html! { + li (self.props()) { + a + class="nav-link dropdown-toggle" + href="#" + role="button" + aria-haspopup="true" + aria-expanded="false" + { + (title) + } + ul class="dropdown-menu" { + (items) + } + } + } + } else { + html! {} + } + } + }) + } +} + +impl Item { + /// Crea un elemento de tipo texto, mostrado sin interacci贸n. + pub fn label(label: Lc) -> Self { + Self { + item_kind: ItemKind::Label(label), + ..Default::default() + } + } + + /// Crea un enlace para la navegaci贸n. + /// + /// La ruta se obtiene invocando [`Route::resolve()`], que devuelve din谩micamente una + /// [`RoutePath`] en funci贸n del [`Context`]. El enlace se marca como `active` si la ruta + /// actual del *request* coincide con la ruta de destino (devuelta por `RoutePath::path`). + pub fn link(label: Lc, route: impl Into) -> Self { + Self { + item_kind: ItemKind::Link { + label, + route: route.into(), + blank: false, + disabled: false, + }, + ..Default::default() + } + } + + /// Crea un enlace deshabilitado que no permite la interacci贸n. + pub fn link_disabled(label: Lc, route: impl Into) -> Self { + Self { + item_kind: ItemKind::Link { + label, + route: route.into(), + blank: false, + disabled: true, + }, + ..Default::default() + } + } + + /// Crea un enlace que se abre en una nueva ventana o pesta帽a. + pub fn link_blank(label: Lc, route: impl Into) -> Self { + Self { + item_kind: ItemKind::Link { + label, + route: route.into(), + blank: true, + disabled: false, + }, + ..Default::default() + } + } + + /// Crea un enlace inicialmente deshabilitado que se abrir铆a en una nueva ventana. + pub fn link_blank_disabled(label: Lc, route: impl Into) -> Self { + Self { + item_kind: ItemKind::Link { + label, + route: route.into(), + blank: true, + disabled: true, + }, + ..Default::default() + } + } + + /// Crea un elemento con contenido HTML arbitrario. + /// + /// El contenido se renderiza tal cual lo devuelve el componente [`Html`], dentro de un `
  • ` + /// con las clases de navegaci贸n asociadas a [`Item`]. + pub fn html(html: Html) -> Self { + Self { + item_kind: ItemKind::Html(Embed::with(html)), + ..Default::default() + } + } + + /// Crea un elemento de navegaci贸n que contiene un men煤 desplegable + /// [`Dropdown`](super::super::Dropdown). + /// + /// S贸lo se tienen en cuenta **el t铆tulo** (si no existe, se asigna uno por defecto) y **la + /// lista de elementos** del [`Dropdown`](super::super::Dropdown); el resto de propiedades no + /// afectar谩n a su representaci贸n en [`Nav`](super::Nav). + pub fn dropdown(menu: Dropdown) -> Self { + Self { + item_kind: ItemKind::Dropdown(Embed::with(menu)), + ..Default::default() + } + } + + // **< Item BUILDER >*************************************************************************** + + /// Establece el identificador 煤nico del componente; igual a `with_prop(PropsOp::set_id(id))`. + #[builder_fn] + pub fn with_id(mut self, id: impl Into) -> Self { + self.props.alter_id(id); + self + } + + /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente. + #[builder_fn] + pub fn with_prop(mut self, op: PropsOp) -> Self { + self.props.alter_prop(op); + self + } + + /// Fuerza si un [`ItemKind::Link`] se marca activo, o `None` para volver a la detecci贸n + /// autom谩tica por coincidencia exacta de ruta. + #[builder_fn] + pub fn with_active(mut self, active: impl Into>) -> Self { + self.active_override = active.into(); + self + } +} diff --git a/src/base/component/nav/props.rs b/src/base/component/nav/props.rs new file mode 100644 index 00000000..504d78c4 --- /dev/null +++ b/src/base/component/nav/props.rs @@ -0,0 +1,26 @@ +use crate::prelude::*; + +// **< Layout >************************************************************************************* + +/// Distribuci贸n y orientaci贸n de un men煤 [`Nav`](super::Nav). +/// +/// Las variantes son puramente sem谩nticas: cada tema decide en su propio `setup()` qu茅 clases CSS +/// les corresponden, seg煤n su propio vocabulario de estilos. +#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] +pub enum Layout { + /// Comportamiento por defecto, ancho definido por el contenido y sin alineaci贸n forzada. + #[default] + Default, + /// Alinea los elementos al inicio de la fila. + Start, + /// Centra horizontalmente los elementos. + Center, + /// Alinea los elementos al final de la fila. + End, + /// Apila los elementos en columna. + Vertical, + /// Los elementos se expanden para rellenar la fila. + Fill, + /// Todos los elementos ocupan el mismo ancho rellenando la fila. + Justified, +} diff --git a/src/base/component/navbar.rs b/src/base/component/navbar.rs new file mode 100644 index 00000000..8f40d65f --- /dev/null +++ b/src/base/component/navbar.rs @@ -0,0 +1,10 @@ +//! Definiciones para crear barras de navegaci贸n ([`Navbar`]) y sus elementos ([`Item`]). + +mod props; +pub use props::Layout; + +mod component; +pub use component::Navbar; + +mod item; +pub use item::Item; diff --git a/src/base/component/navbar/component.rs b/src/base/component/navbar/component.rs new file mode 100644 index 00000000..61b40bcb --- /dev/null +++ b/src/base/component/navbar/component.rs @@ -0,0 +1,238 @@ +use crate::prelude::*; + +/// Componente para crear una **barra de navegaci贸n**. +/// +/// Permite mostrar enlaces, men煤s desplegables ([`nav::Item::dropdown()`]) y una marca de +/// identidad, en distintas disposiciones controladas por [`navbar::Layout`]. +/// +/// Si no contiene elementos, el componente **no se renderiza**. +/// +/// # Ejemplos +/// +/// Barra **simple**, s贸lo con un men煤 horizontal: +/// +/// ```rust,no_run +/// use pagetop::prelude::*; +/// +/// let navbar = Navbar::simple() +/// .with_item(navbar::Item::nav( +/// Nav::new() +/// .with_item(nav::Item::link(Lc::n("Home"), "/")) +/// .with_item(nav::Item::link(Lc::n("About"), "/about")) +/// .with_item(nav::Item::link(Lc::n("Contact"), "/contact")), +/// )); +/// ``` +/// +/// Barra **colapsable**, con bot贸n de despliegue: +/// +/// ```rust,no_run +/// # use pagetop::prelude::*; +/// let navbar = Navbar::simple_toggle() +/// .with_item(navbar::Item::nav( +/// Nav::new() +/// .with_item(nav::Item::link(Lc::n("Home"), "/")) +/// .with_item(nav::Item::link_blank(Lc::n("Doc"), "https://docs.rs")) +/// .with_item(nav::Item::link(Lc::n("Support"), "/support")), +/// )); +/// ``` +/// +/// Barra con **marca de identidad** y men煤, con men煤s desplegables: +/// +/// ```rust,no_run +/// # use pagetop::prelude::*; +/// let brand = Brand::new() +/// .with_title(Lc::n("PageTop")) +/// .with_route(Route::from("/")); +/// +/// let navbar = Navbar::brand_left(brand) +/// .with_item(navbar::Item::nav( +/// Nav::new() +/// .with_item(nav::Item::link(Lc::n("Home"), "/")) +/// .with_item(nav::Item::dropdown( +/// Dropdown::new() +/// .with_title(Lc::n("Tools")) +/// .with_item(dropdown::Item::link(Lc::n("Generator"), "/tools/gen")) +/// .with_item(dropdown::Item::link(Lc::n("Reports"), "/tools/reports")), +/// )) +/// .with_item(nav::Item::link_disabled(Lc::n("Disabled"), "#")), +/// )); +/// ``` +/// +/// Barra con **bot贸n de despliegue** y **marca de identidad**, en ese orden: +/// +/// ```rust,no_run +/// # use pagetop::prelude::*; +/// let brand = Brand::new() +/// .with_title(Lc::n("Intranet")) +/// .with_route(Route::from("/")); +/// +/// let navbar = Navbar::brand_right(brand).with_item(navbar::Item::nav( +/// Nav::new() +/// .with_item(nav::Item::link(Lc::n("Dashboard"), "/dashboard")) +/// .with_item(nav::Item::link(Lc::n("Users"), "/users")), +/// )); +/// ``` +/// +/// [`nav::Item::dropdown()`]: super::super::nav::Item::dropdown +/// [`navbar::Layout`]: super::Layout +#[derive(AutoDefault, Clone, Debug, Getters)] +pub struct Navbar { + /// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente. + props: Props, + /// Devuelve la disposici贸n configurada para la barra de navegaci贸n. + layout: navbar::Layout, + /// Devuelve la lista de contenidos. + items: Children, +} + +#[async_trait] +impl Component for Navbar { + fn new() -> Self { + Self::default() + } + + fn id(&self) -> Option { + self.props.get_id() + } + + fn setup(&mut self, cx: &Context) { + // Asegura que la barra de navegaci贸n tiene un identificador 煤nico: lo necesita el bot贸n de + // despliegue para referenciar el contenido colapsable con `aria-controls`. + self.alter_prop(PropsOp::ensure_id(cx.build_id::(1))); + self.alter_prop(PropsOp::prepend_classes("navbar")); + } + + async fn prepare(&self, cx: &mut Context) -> Result { + // Bot贸n de despliegue para el contenido colapsable de la barra. + fn button(cx: &mut Context, id_content: &str) -> Markup { + html! { + button + type="button" + class="navbar-toggle" + aria-expanded="false" + aria-controls=(id_content) + aria-label=[Lc::l("navbar_toggle").lookup(cx)] + { + span class="navbar-toggle-icon" {} + } + } + } + + // Si no hay contenidos, no tiene sentido mostrar una barra vac铆a. + let items = self.items().render(cx).await; + if items.is_empty() { + return Ok(html! {}); + } + + // `setup()` garantiza que habr谩 un `id` antes de renderizar. + let id = self.id().unwrap(); + let id_content = util::join!(id, "-content"); + + Ok(html! { + nav (self.props()) { + @match self.layout() { + // Barra m谩s sencilla: s贸lo contenido, siempre visible. + navbar::Layout::Simple => { + div class="navbar-content" { (items) } + }, + + // Barra sencilla que se puede contraer/expandir. + navbar::Layout::SimpleToggle => { + (button(cx, &id_content)) + div id=(&id_content) class="navbar-content" { (items) } + }, + + // Barra con marca, siempre visible, sin bot贸n. + navbar::Layout::SimpleBrandLeft(brand) => { + (brand.render(cx).await) + div class="navbar-content" { (items) } + }, + + // Barra con marca y bot贸n, en ese orden. + navbar::Layout::BrandLeft(brand) => { + (brand.render(cx).await) + (button(cx, &id_content)) + div id=(&id_content) class="navbar-content" { (items) } + }, + + // Barra con bot贸n y marca, en ese orden. + navbar::Layout::BrandRight(brand) => { + (button(cx, &id_content)) + div id=(&id_content) class="navbar-content" { (items) } + (brand.render(cx).await) + }, + } + } + }) + } +} + +impl Navbar { + /// Crea una barra de navegaci贸n **simple**, sin marca y sin bot贸n. + pub fn simple() -> Self { + Self::default().with_layout(navbar::Layout::Simple) + } + + /// Crea una barra de navegaci贸n **simple pero colapsable**, con bot贸n de despliegue. + pub fn simple_toggle() -> Self { + Self::default().with_layout(navbar::Layout::SimpleToggle) + } + + /// Crea una barra de navegaci贸n **con marca de identidad**, siempre visible, sin bot贸n. + pub fn simple_brand_left(brand: Brand) -> Self { + Self::default().with_layout(navbar::Layout::SimpleBrandLeft(Embed::with(brand))) + } + + /// Crea una barra de navegaci贸n con **marca de identidad** y **bot贸n de despliegue**, en ese + /// orden. + pub fn brand_left(brand: Brand) -> Self { + Self::default().with_layout(navbar::Layout::BrandLeft(Embed::with(brand))) + } + + /// Crea una barra de navegaci贸n con **bot贸n de despliegue** y **marca de identidad**, en ese + /// orden. + pub fn brand_right(brand: Brand) -> Self { + Self::default().with_layout(navbar::Layout::BrandRight(Embed::with(brand))) + } + + // **< Navbar BUILDER >************************************************************************* + + /// Establece el identificador 煤nico del componente; igual a `with_prop(PropsOp::set_id(id))`. + #[builder_fn] + pub fn with_id(mut self, id: impl Into) -> Self { + self.props.alter_id(id); + self + } + + /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente. + #[builder_fn] + pub fn with_prop(mut self, op: PropsOp) -> Self { + self.props.alter_prop(op); + self + } + + /// Define el tipo de disposici贸n que tendr谩 la barra de navegaci贸n. + #[builder_fn] + pub fn with_layout(mut self, layout: navbar::Layout) -> Self { + self.layout = layout; + self + } + + /// A帽ade un nuevo contenido a la barra de navegaci贸n o modifica la lista de contenidos de la + /// barra con una operaci贸n [`TypedOp`]. + /// + /// # Ejemplo + /// + /// ```rust,ignore + /// navbar.with_item(navbar::Item::nav(...)); + /// navbar.with_item(TypedOp::AddMany(vec![ + /// navbar::Item::nav(...), + /// navbar::Item::text(...), + /// ])); + /// ``` + #[builder_fn] + pub fn with_item(mut self, op: impl Into>) -> Self { + self.items.alter_child(op.into()); + self + } +} diff --git a/src/base/component/navbar/item.rs b/src/base/component/navbar/item.rs new file mode 100644 index 00000000..77acf01c --- /dev/null +++ b/src/base/component/navbar/item.rs @@ -0,0 +1,84 @@ +use crate::prelude::*; + +/// Elementos que puede contener una barra de navegaci贸n [`Navbar`](super::Navbar). +/// +/// Cada variante determina qu茅 se renderiza y c贸mo. Estos elementos se colocan **dentro del +/// contenido** de la barra (la parte colapsable), por lo que son independientes de la marca o del +/// bot贸n que ya pueda definir el propio [`navbar::Layout`](super::Layout). +#[derive(AutoDefault, Clone, Debug)] +pub enum Item { + /// Sin contenido, no produce salida. + #[default] + Void, + /// Marca de identidad mostrada dentro del contenido de la barra de navegaci贸n. + /// + /// 脷til cuando el [`navbar::Layout`](super::Layout) no incluye marca, y se quiere incluir + /// dentro del 谩rea colapsable. Si el *layout* ya muestra una marca, esta variante no la + /// sustituye, s贸lo a帽ade otra dentro del bloque de contenidos. + Brand(Embed), + /// Representa un men煤 de navegaci贸n [`Nav`]. + Nav(Embed