Compare commits

...
Sign in to create a new pull request.

172 commits

Author SHA1 Message Date
f819181872 ✨ (user): Asigna roles desde la edición de usuario 2026-10-05 00:36:47 +02:00
806ed9e0dc 💄 (user): Muestra sólo permisos concedidos al rol 2026-10-05 00:10:53 +02:00
f337119219 💥 (admin): Exige un permiso propio en cada página 2026-10-04 23:50:58 +02:00
2299e63585 🔧 (tools): Añade pagetop-admin/user al changelog 2026-10-04 23:38:40 +02:00
70744a51a5 ✨ (auth): Añade idioma y tema preferidos al usuario
- CurrentUser pasa de enum a struct con `id` opcional (anónimo = None) y
  preferencias de idioma, zona horaria y tema validadas en `with_*()`.
- `RequestLocale` tiene en cuenta el idioma preferido del usuario, y
  Context usa su tema si lo tiene (`default_theme()` en otro caso).
- Nuevos componentes `form::SelectLanguage`, `form::SelectTheme` y
  `form::SelectTimezone`, con `Timezone::supported_by_region()` para
  listar y validar las zonas IANA ofrecidas.
2026-10-04 23:09:07 +02:00
35f2b66161 💄 (user): Usa componentes base en Administración
Sustituye marcado propio y clases sin estilo por componentes de base,
para que la interfaz se vea igual con cualquier tema.
2026-10-03 12:25:15 +02:00
c848b97450 🎨 (basic): Organiza estilos de Basic e Intro
Divide `basic.css` en parciales SCSS bajo `assets/scss/basic/`, integra
`normalize.css` en `basic.min.css` y compila `intro.scss` por separado a
`intro.min.css`.
2026-10-03 11:15:10 +02:00
b5da11777a ✨ (user): Deshabilita roles si no hay asignables 2026-10-02 23:48:18 +02:00
56a2471090 💄 (button): Aplica estilo deshabilitado a enlaces
Un `<a>` deshabilitado no tiene un equivalente a `:disabled` sobre el
que aplicar estilos. Button añade ahora la clase `disabled`.
2026-10-02 23:33:22 +02:00
c06efbd0d1 🎨 Ajustes menores de separadores de sección 2026-10-02 22:59:26 +02:00
04502ef81e ✨ (user): Añade zona horaria propia del usuario
Sustituye el campo de texto libre por un selector de zonas horarias IANA
canónicas agrupadas por región, con la opción de usar la del sitio (que
muestra la zona efectiva).
2026-10-02 22:31:58 +02:00
4cd75af8d2 🐛 (seaorm): Crea timestamp() con zona horaria
Las entidades guardan los instantes como DateTimeUtc, que PostgreSQL
sólo lee desde TIMESTAMPTZ. timestamp() y timestamp_null() pasan a crear
columnas con zona horaria; en MySQL y SQLite no cambia nada en la
práctica.
2026-10-02 06:27:36 +02:00
90e4f85bd4 ✨ (datetime): Añade zona horaria y formato de fechas
Añade formatos `DateFormat`, `TimeFormat`, `RelativeFormat` y
`DatePrecision`, con estilos traducidos vía Fluent (orden día/mes/año,
nombre del mes, plurales). La zona horaria efectiva se resuelve por
petición, la propia del usuario autenticado si la tiene, si no la
configurada para la aplicación (`app.timezone`), o UTC en su defecto- y
se expone en `Contextual` junto a format_date(), format_time(),
format_datetime(), format_iso_datetime(), format_relative(),
format_since() y format_until().
2026-10-01 21:53:57 +02:00
3df73f0551 ✨ (locale): Añade Lc::with_number() para plurales
`with_arg()` sólo interpola texto, y el selector de plural de Fluent
(`{ $n -> [one] ... *[other] ... }`) sólo aplica las reglas de plural
cuando el argumento es un FluentValue::Number.
2026-09-29 07:08:48 +02:00
5d199ef10d 🐛 (examples): Evita 405 al enviar los formularios 2026-09-27 15:04:17 +02:00
dbdaeb6151 ✨ (user): Ordena alfabéticamente grupos de permisos
Añade `Lc::collation_key()` en PageTop para obtener una clave de
ordenación no sensible a mayúsculas y acentos, con `ñ` como letra propia
entre `n` y `o` en español.
2026-09-27 13:12:57 +02:00
93cd3ba6c5 📝 (macros): Corrige enlace a builder_fn en la doc
Esa ruta sólo se resuelve en crates que dependen directamente de
pagetop-macros, quedando roto en las extensiones. Ahora apunta a
pagetop::builder_fn, que la reexporta y está disponible en todos los
crates que usan la macro.
2026-09-26 17:00:05 +02:00
9e2fc2ccfa ✨ (user): Añade el menú de cuenta AccountMenu 2026-09-26 16:57:57 +02:00
165cb49880 ✨ (base): Añade with_menu_end() para menú al final 2026-09-26 14:21:39 +02:00
f265f217bd 🔒️ (admin): Deniega /admin sin páginas accesibles
`render_sections()` devuelve `Option<Container>`, con `None` si no queda
ninguna sección visible con páginas accesibles. `dashboard()` y
`section_page()` responden con `AccessDenied` en ese caso, sin crear un
permiso nuevo.
2026-09-26 07:43:22 +02:00
3f3634fdab ♻️ Atributos class e id explícitos en html!
Se usa `class="..."` e `id="..."` en lugar de `div.a #x`. El HTML
generado es idéntico.
2026-09-26 07:05:59 +02:00
58d1fef4f1 ⚡️ (core): Registro de acciones inmutable 2026-09-26 00:56:30 +02:00
5b2ddeb6cc ⚡️ (pagetop): Mejora uso de Props, Grid y HttpRequest 2026-09-24 22:29:27 +02:00
58b4b4e036 ♻️ (user): El rol authenticated es implícito 2026-09-23 21:04:00 +02:00
1254e650f1 🔥 Elimina la extensión pagetop-menu del workspace 2026-09-23 20:46:25 +02:00
11c2bdffb1 🔥 (bootsier): Elimina AdminLTE
Retira AdminLTE 4 de Bootsier: CSS/JS, `bs::sidebar`, `BootsierRegions`
y el shell propio de `CoreTemplates::Admin`. Sólo quedaba un sidebar de
5 secciones ya alcanzables desde el dashboard, las páginas de sección y
el breadcrumb de `AdminFrame`.
2026-09-22 21:34:16 +02:00
6df71885cc ♻️ (bootsier): Cambia Offcanvas por Panel en Navbar 2026-09-22 18:58:43 +02:00
c47724bb68 ✨ (base): Implementa Navbar con funcionalidad base
Añade `Navbar::with_position()` y `navbar::Position` al núcleo, con las
clases de Basic para barras fijas y pegajosas, y `navbar::Item::form()`
para mostrar un formulario dentro de la barra.

Las barras sin botón de despliegue (`Simple` y `SimpleBrandLeft`) ya no
colapsan: el punto de corte no les afecta y su menú se mantiene en
línea, con salto de línea si no cabe. En Bootsier usan `navbar-expand` y
`SimpleBrandLeft` agrupa los contenidos junto a la marca.
2026-09-21 16:27:44 +02:00
f8f023b513 ✨ (pagetop): Añade PageTopSvg::Tile y ajusta Image 2026-09-21 13:51:18 +02:00
16eec316c4 ♻️ (base): Reorganiza el componente Intro 2026-09-20 23:53:18 +02:00
404739a3fd ♻️ Importa PageTop usando el prelude 2026-09-20 18:33:37 +02:00
8651b91ab2 ♻️ (minimal): Reimplementa join! y join_pair!
Usa `[&str]::concat()` de la biblioteca estándar y elimina la
dependencia de `concat-string`. Cada argumento se evalúa una sola vez y
no se consume.
2026-09-20 18:18:51 +02:00
c6cd4fcd7d ✨ (base): Añade with_width() a Container 2026-09-19 03:14:21 +02:00
9143f1eb26 ✨ (pagetop): Añade with_expand() a Navbar
Navbar guarda el punto de corte a partir del cual deja de colapsar
(`Breakpoint::Md` por defecto) y añade la clase `navbar-expand-{nombre}`
o `navbar-expand` si no tiene ancho mínimo. El nombre lo resuelve el
tema activo con `Theme::breakpoint_entry()`.
2026-09-19 01:52:55 +02:00
5bf28ef4f0 ♻️ (bootsier): Sustituye BreakPoint por Breakpoint
Elimina el enum BreakPoint propio de Bootsier. Los componentes pasan a
usar el Breakpoint del núcleo, cuyo nombre y ancho resuelve el tema
activo.
2026-09-18 18:40:59 +02:00
307ca6f62c ✨ (pagetop): Añade Grid y extrae Flex de Container 2026-09-18 16:49:09 +02:00
dfff12881f 🚧 (pagetop): Comparte align entre Flex y Grid
Extrae `Align`/`AlignContent`/`ItemAlign`/`Gap` de Flexbox a un nuevo
módulo neutral `html::align` (`Items`/`Content`/`ItemSelf`/`Gap`) para
que tambien lo use CSS Grid.
2026-09-13 23:08:39 +02:00
c2ec7568f0 ✅ (pagetop): Añade tests de Margin y Padding 2026-09-13 01:46:21 +02:00
3c3ffc91e6 ♻️ (pagetop): with_prop() acepta impl Into<PropsOp>
`FlexItem`, `Margin` y `Padding` se pasan directamente gracias a `From`
 hacia `PropsOp`, sin `PropsOp::flex_item()`/`margin()`/`padding()` ni
`.into()` explícito. Revisadas todas las llamadas en el código para
simplificarlas.
2026-09-13 01:38:33 +02:00
50e4b42fe4 ✨ (pagetop): Añade Margin y Padding nativos
- Nuevo módulo `html::spacing` con `Margin`/`Padding`: igual que
  `FlexItem`, se resuelven generando clases CSS dinámicamente y se
  aplican con `PropsOp::margin()`/`PropsOp::padding()` sobre cualquier
  componente.
- Elimina `Margin`/`Padding` de `pagetop-bootsier`.
- Añade el ejemplo `intro-spacing` con los patrones de uso típicos.
2026-09-13 00:48:11 +02:00
64113aff09 ♻️ (pagetop): Sustituye StyleSheet::inline
- `StyleSheet::inline` se resuelve ahora con `ResponsiveStyles`.
- `StyleSheet` se simplifica a sólo hojas de estilo externas.
- `ResponsiveStyles` añade `add_styles()` para declarar varias
  propiedades en una sola llamada.
- `AssetsOp` gana un constructor por variante y conversiones
  "From<Favicon/Preload/StyleSheet/JavaScript>" para pasarlos
  directamente a `with_assets()`.
2026-09-12 20:44:17 +02:00
0b8f3f3000 ✨ (pagetop): Añade puntos de corte a Flex/FlexItem
- `Breakpoint` gana variante `Xxxl` y métodos `name()`/`min_width()`/
  `resolved()`, para consultarse contra el tema activo.
- `Theme::breakpoint_entry()` sustituye a `breakpoint_min_width()`:
  devuelve un `BreakpointEntry` con nombre y ancho mínimo.
- Nuevo `Responsive<T>`, valor en cascada mobile-first por punto de
  corte.
- Ejemplo `examples/intro-responsive.rs` con los patrones de uso.
2026-09-12 11:13:19 +02:00
8577ca8a59 ♻️ (pagetop): Props::unpack() exige &mut Context 2026-09-08 07:28:50 +02:00
ee1b48ff3f ♻️ (pagetop): Divide props.rs en varios módulos 2026-09-06 20:36:12 +02:00
28f1eee391 ♻️ (pagetop): Separa handle_component() en 2 métodos
`Theme::handle_component()` se divide en `setup_component()` (síncrono,
muta por tipo con la macro homónima) y `render_component()` (async,
decide el renderizado con su propia macro). Aplica mismo criterio que ya
separa `Component::setup()` de `prepare()`.
2026-09-06 13:07:01 +02:00
1e2d171805 ♻️ (pagetop): setup() recibe &mut Context
Antes sólo mutaba el propio componente; ahora también puede aplicar
ajustes síncronos sobre el Context, visibles para las extensiones que
intercepten BeforeRender. La frontera con prepare() pasa a ser de
sincronía, no de qué puede mutar cada uno.
2026-09-06 09:04:44 +02:00
f2a7507317 ✨ (theme): Añade Breakpoint y ResponsiveStyles
- Nuevo `Breakpoint` (`Xs`/`Sm`/`Md`/`Lg`/`Xl`/`Xxl`) y
  `Theme::breakpoint_min_width()`, que traduce cada variante al ancho
  mínimo CSS del tema activo (mismo patrón que `Theme::intent_color()`).
- `ResponsiveStyles` acumula declaraciones `property: value` agrupadas
  por punto de corte (opcional: `None` para reglas siempre activas, sin
  depender del tema) y por clase o clases. Aplica first-write-wins para
  clases utilitarias repetidas por muchos componentes. `render()` las
  agrupa en bloques `@media (min-width: ...)` mobile-first, sin saltos
  de línea.
2026-09-06 01:46:06 +02:00
b163b7726e ♻️ (util): Simplifica normalize_ascii*
`normalize_ascii_or_empty()` pasa a llamarse `normalize_ascii()` (la
variante indulgente, la más usada); la antigua `normalize_ascii()` pasa
a `normalize_ascii_non_blank()`.
2026-09-05 21:19:15 +02:00
5939d33964 🐛 (examples): Corrige ejemplo en intro-flex
- El segundo Container anidado con `push_end()` no se justificaba contra
  el ancho completo. Ahora es un único Container con Flex, con
  `push_end()` sobre el elemento "Perfil".
- `caption()` acepta Lc también para el fragmento de código, y la
  descripción del ejemplo pasa a ser una traducción Fluent.
2026-09-05 06:22:58 +02:00
ab1d4f0efb ♻️ (pagetop): Incorpora Context al render de Props 2026-09-05 00:19:39 +02:00
a2b3ae2eb8 📝 Retoques tipográficos menores 2026-09-04 01:25:17 +02:00
17e16652e4 ✨ (pagetop): Añade Flex/FlexItem para usar Flexbox
Container y Navbar lo adoptan vía `with_flex()`/`PropsOp::flex_item()`.
Y se elimina `ButtonSet` porque su funcionalidad queda cubierta por este
mecanismo más general. Incluye el ejemplo `examples/intro-flex.rs` con
los patrones de uso.
2026-09-04 01:04:53 +02:00
4e4fdf7b10 📝 Resalta términos en docs de Block y Breadcrumb 2026-08-29 19:52:45 +02:00
0d38f2e9e7 ✨ (bootsier): Expone ButtonSet en el tema Bootsier 2026-08-29 19:52:23 +02:00
1be3d88888 ♻️ Migra #[builder_fn] a #[builder_impl]
Sustituye las ~400 anotaciones `#[builder_fn]` método a método por un
único `#[builder_impl]` por `impl`/`trait`, en 74 ficheros de pagetop y
sus extensiones (admin, bootsier, menu, user).
2026-08-29 19:25:58 +02:00
c34dc02357 ✨ (macros): Añade #[builder_impl]
Aplica `#[builder_fn]` a todos los métodos `with_...()` de un bloque
`impl` o de una definición de trait de una vez, sin anotarlos uno a uno.
2026-08-29 19:19:58 +02:00
bea4b3f30d 🐛 (menu): Evita panic si no hay sección [menu] 2026-08-28 21:07:11 +02:00
def0513246 ✨ Añade pagetop-user, con autenticación y RBAC
Identidad de usuario, sesiones en base de datos, roles y permisos (RBAC)
con "authenticated" implícito, contraseñas con Argon2id, tokens de
un solo uso para verificación de email y restablecimiento de contraseña,
y la UI de administración completa (usuarios, roles, permisos) sobre
pagetop-admin.

Incluye datos de demostración opcionales tras la feature `demo-data`.
2026-08-28 20:59:17 +02:00
043873a954 ✨ (menu): Añade extensión pagetop-menu
Gestión centralizada y persistente de menús: entidades y migraciones
SeaORM, sistema de acciones para declarar menús/ítems por código, caché
in-process del árbol y los componentes `MenuBlock`/`MenuBreadcrumb`.
2026-08-28 20:33:57 +02:00
88acc08b5f ✨ (admin): Añade la extensión pagetop-admin
Panel de administración extensible: registro de secciones/páginas/
tareas/acciones vía acciones `Declare*`, formularios de configuración
automáticos (ConfigForm + SettingsSchema) y persistencia en la tabla
`settings`.
2026-08-28 20:11:44 +02:00
c0a5a8c3ab ✨ (bootsier): Añade componentes esenciales
- Añade Dialog como modal de Bootstrap, con el JS de soporte para
  `htmx:confirm` y el saneado de su posición fija.
- Adapta Nav, Navbar y Dropdown al reuso de los tipos movidos al core, y
  traduce BootsierColors/Intent en Button y Badge, con soporte para
  Light/Dark vía with_color().
- Reexporta en el tema los componentes de PageTop que faltaban: Block,
  Breadcrumb, Messages, Pager, Table y form::Number.
2026-08-28 19:46:56 +02:00
eb63a7ef37 📝 (seaorm): Quita cursiva de "traits" en doc 2026-08-28 18:26:40 +02:00
35ae5bb6a8 ✨ (base): Añade componentes Nav y Navbar
Dropdown incorpora tamaño y estilo de botón configurables
(`button::Size`/`Style`), reutilizados también por Navbar. El tema Basic
sirve `basic.min.css` y añade el JS de inicialización de Navbar.
2026-08-28 18:24:43 +02:00
da959183f6 ✨ (button): Añade Button::anchor() para navegación
- Renderiza un `<a href>` real con el aspecto de un botón, distinto del
  estilo puramente visual `button::Style::Link`.
- Renombra `ButtonKind`/`ButtonStyle` a `Kind`/`Style`.
- Añade `button::Size` (`button-sm`/`button-lg`).
2026-08-28 11:38:57 +02:00
434d31c5eb 📝 (doc): Sustituye "..." por "etc." 2026-08-28 11:16:58 +02:00
56a6691c87 🚧 (theme): Renombra variantes de Intent
Renombra `Intent::Secondary`/`Danger` a `Neutral`/`Severe` y actualiza
Badge y su CSS por defecto (basic.css) al nuevo vocabulario.

Introduce el punto de extensión `Theme::intent_color()`, que traduce una
`Intent` al nombre de color de la paleta propia de cada tema, con
`Intent::color(cx)` como atajo de uso desde los componentes.
2026-08-28 01:10:45 +02:00
af53cd4b52 ♻️ (dropdown): Integra Dropdown con accessible-menu
Renombra `dropdown.init.js` a `basic.dropdown.init.js` y añade cierre
cruzado entre menús abiertos y cierre al clicar fuera. Corrige el icono
de apertura/cierre y el estado cerrado por defecto de dropdown-menu.
Migra `with_item` de `ChildOp` a `TypedOp<dropdown::Item>`.
2026-08-26 23:58:33 +02:00
76c5cb9b76 ✨ (base): Añade componente Dialog
Sustituye `PropsOp::Translate` por `Rename` para que los temas traduzcan
atributos `data-dialog-*` al vocabulario propio (p. ej. `data-bs-*` en
Bootsier).
2026-08-26 21:56:10 +02:00
3c67d9d04c ✨ (props): Añade la operación PropsOp::translate
Permite a un tema traducir el vocabulario de atributos de un componente
al suyo propio (p. ej. `data-dialog-*` -> `data-bs-*`) sin conocer su
valor, respetando el que ya tuviera el destino si existía.
2026-08-26 06:55:37 +02:00
99a4837e11 ✨ (button): Añade componentes Button y ButtonSet 2026-08-24 20:18:29 +02:00
c8654a5742 ✨ (core): Añade TypedOp<C> para restringir Children
Mismo repertorio de operaciones que `ChildOp`, pero cada variante exige
un componente de tipo `C`, evitando que un componente ajeno a ese tipo
acabe en una lista pensada para un único tipo de elemento.
2026-08-24 20:00:12 +02:00
d8f82ea1d2 ♻️ (response): Renombra Waypoint::as_str a as_deref 2026-08-23 13:39:38 +02:00
e24b9ab9de ♻️ (pagetop): Logotipo embebido y Badge en Intent
- `PageTopSvg::markup_with()` fusiona `Props` y controla la etiqueta
  accesible directamente en el `<svg>`.
- `Image` ya no duplica accesibilidad para el logotipo y corrige el
  `alt` vacío en el resto de fuentes (Responsive/Thumbnail/Plain).
- Sustituye `BadgeKind` y el `ColorName`/`IntoColor` de `core/theme`
  por un `Intent` compartido.
2026-08-22 09:44:28 +02:00
4ba66f5995 🚧 (base): Añade BadgeKind para el tipo de Badge 2026-08-21 11:18:16 +02:00
997017bb75 🎨 (core): Retoques de estilo menores 2026-08-21 08:23:39 +02:00
b50d186fb1 ✨ (theme): Añade IntoColor para colores opcionales
Permite que métodos como `with_color(color: impl IntoColor)` acepten un
color directo o uno opcional (`Some(...)`/`None`) sin la ambigüedad que
produce un `Into<Option<C>>` genérico.
2026-08-21 08:18:28 +02:00
bb6855e41f ✨ (theme): Añade ColorName y CoreColors
Nuevo trait `ColorName` para que cada tema aporte su propia paleta de
colores; `CoreColors` es la paleta por defecto.

Aprovecha para refactorizar `core/theme.rs` en submódulos por concepto.
2026-08-20 21:54:12 +02:00
1453764462 🚨 (macros): Corrige warning de clippy en expand_entry 2026-08-17 18:26:38 +02:00
fe2b2c176f ♻️ (bootsier): Mueve Brand al core de PageTop 2026-08-17 05:53:55 +02:00
bf4c635e59 ♻️ (pagetop): Sustituye Attr<T> por Option<T>
`Attr<T>` no aportaba nada sobre `Option<T>` directo: `Getters` ya trata
los campos `Option<T>` como caso especial (`Option<&T>`, o `Option<T>`
con `#[getters(copy)]`) y `#[builder_fn]` funciona igual sobre ellos.
2026-08-17 05:28:12 +02:00
213aa18b12 ♻️ (locale): Sustituye Attr<Lc> por Lc::none() 2026-08-17 03:16:49 +02:00
c3ff8a6ff8 ♻️ (bootsier): Mueve Image al core de PageTop
`Image`, `Size` y `Source` pasan a `pagetop::base::component` como
componente genérico reutilizable por cualquier tema. Bootsier
sustituye las clases semánticas por las de Bootstrap (`img-fluid`,
`img-thumbnail`) interceptando `setup()`.
2026-08-17 00:55:35 +02:00
745d492d5b ✨ (html): Evita duplicar atributos de Props
`html!` recopila los atributos literales de cada elemento y evita que
`Props` los duplique al renderizar.
2026-08-17 00:02:20 +02:00
05187c9343 ✨ (macros): Evita duplicar splices de attr en html! 2026-08-16 17:03:08 +02:00
ed30b2ae17 ✨ (props): Añade ReplaceAllClasses
Sustituye clases sólo si están todas las indicadas en el primer valor; a
diferencia de `ReplaceClasses`, no actúa parcialmente si falta alguna.
Renombra `Props::has_class()`/`has_any_class()` a `has_all_classes()`/
`has_classes()`, que la nueva variante usa internamente para comprobar
la condición.
2026-08-16 13:58:47 +02:00
946d335664 ✨ (base): Añade componente Dropdown 2026-08-14 20:01:15 +02:00
e5431bc7d7 ✨ (base): Añade componente Messages
Muestra los mensajes acumulados en el Context por severidad.
2026-08-13 17:35:23 +02:00
51e8b11090 ✨ (theme): Añade clases de tabla al tema Basic 2026-08-13 17:12:06 +02:00
3930499664 🐛 (table): Añade conversión From<C> a Cell 2026-08-13 15:29:54 +02:00
5842ea6efc ✨ (base): Añade componente Table
Incluye columnas ordenables (`table::SortLink`), paso de props/atributos
a filas y celdas para HTMX, y un mensaje de "sin datos" traducible con
valor por defecto.
2026-08-13 15:17:23 +02:00
17a9f0c9e0 ♻️ (form): Añade #[builder_fn] a Item y Group
Uniforma el criterio con el resto de elementos de componente (`Row`/
`Cell`/`Column`/`SortLink` en table, `Crumb` en breadcrumb) generando su
`alter_...` equivalente para poder mutarse con `&mut self`.
2026-08-13 11:01:32 +02:00
aa8c4100b0 ✨ (base): Añade conversión de Lc a Child/ChildOp 2026-08-12 23:57:31 +02:00
be10dd28a1 ♻️ (locale): Renombra L10n a Lc
Simplifica el nombre del tipo de localización. Actualiza todas las
referencias en el crate principal, extensiones, ejemplos y tests.
2026-08-12 22:56:09 +02:00
3198b74399 🐛 (locale): Corrige escapado de L10n::using()
`L10n::using()` insertaba el texto de `L10n::n()` sin escapar,
tratándolo igual que una traducción de confianza (`l()`/`t()`); ahora se
escapa como cualquier otro valor interpolado con `html!`.

Además, `using()` no debe usarse en valores de atributo. Su marcado de
confianza para `l()`/`t()` podría romper el delimitador si la traducción
contuviera una comilla. Se sustituye por `lookup()`
2026-08-12 14:28:47 +02:00
6bea4a5793 ✨ (base): Añade componente Breadcrumb 2026-08-11 00:46:45 +02:00
20b6c77c9e 🐛 (base): Exporta PagerAlign y corrige aria-label 2026-08-10 13:19:59 +02:00
8cb5e035dc ✨ (theme): Evita referencias cíclicas de parent()
Como `Theme::parent()` se resuelve en tiempo de ejecución, una
referencia cíclica (en la que un tema acaba siendo padre de sí mismo,
directa o transitivamente) no puede descartarse en compilación. Se
detecta al registrar el tema y aborta el arranque si procede.
2026-08-09 18:45:17 +02:00
825283caa3 ♻️ (base): El resumen de Pager usa PagerVisibility
Sustituye el bool de `summary` por `PagerVisibility`, con `Auto` como
valor por defecto: se activa igual que los botones de navegación y el
formulario de salto, sólo cuando el listado se trunca.
2026-08-07 23:47:14 +02:00
ee57d6cacf ✨ (base): Añade resumen de páginas en Pager 2026-08-07 19:03:59 +02:00
12c14afbf6 🐛 (base): Correcciones de visualización de Pager
Ajusta el cálculo de la ventana de páginas cerca de los extremos,
convierte la elipsis en una celda `.page-link` real (antes texto suelto
sin borde/fondo), separa el texto visible del aria-label accesible en
los botones anterior/siguiente, añade `with_align()` y ajusta el ancho
del campo de salto al número de dígitos.
2026-08-07 15:28:58 +02:00
3b01894024 ✨ (base): Añade componente Pager
Incluye soporte HTMX vía `sort_link()`/`hx_pager` para navegación sin
recarga.
2026-08-06 22:37:28 +02:00
e8bba0803d ✨ (form): Añade nuevo componente Number 2026-08-05 17:33:27 +02:00
502c00c891 🎨 Colapsa if anidados sugeridos por clippy 2026-08-05 13:07:35 +02:00
1cab0ae9a0 ✨ (theme): Añade región Aside
Introduce `CoreRegions::Aside` como nueva región de plantilla, entre
Header y Content, y Template ahora envuelve el cuerpo en `div.wrapper`,
omitiendo el renderizado si no hay contenido.

`CoreRegion`, `ReservedRegion` y `CoreTemplate` pasan a plural
(`CoreRegions`, `ReservedRegions`, `CoreTemplates`) para no confundirlos
con otras definiciones, y se amplía la documentación del módulo theme.
2026-08-02 10:40:36 +02:00
c49bf8f56a ✨ (form): Blinda Field contra el autorrelleno
Añade nuevas variantes `Kind::StrictText` y `StrictPassword`
(`Field::strict_text()` y `strict_password()`) para forzar en el campo
`autocomplete=off`, desactivar spellcheck y autocorrect, aplicar
`readonly+onfocus`, y enmascarar las contraseñas vía CSS en vez de
`type="password"`. Replicado en pagetop-bootsier.
2026-08-02 08:43:32 +02:00
545a87fbb0 ♻️ (bootsier): Ajusta componentes base
- Añade `#[builder_fn]` a `with_width()`/`with_floating_label()`.
- `Rounded::new()` pasa a aplicar el radio por defecto
  (`Rounded::default()` queda sin redondeo).
- `Form::action` usa `Route` en lugar de `AttrValue`, y `check::Item`
  expone `value` en vez de `name` para generar casillas con `name`
  compartido valores distintos.
2026-08-01 13:41:19 +02:00
4cc72aac0e ✨ (base): Añade componente Badge
Incluye soporte específico en Bootsier (`BadgeBootsier`) para fijar el
color de fondo con contraste de texto garantizado.
2026-08-01 12:15:00 +02:00
3e83f8e35e 🐛 (locale): Preserva grafía original de "?lang="
Antes, RequestLocale sólo guardaba el LanguageIdentifier ya resuelto,
así que `Context::route()` propagaba siempre la forma canónica en los
enlaces generados ("?lang=en-US"), aunque el usuario hubiera pedido
"?lang=en". Ahora se conserva el valor original de la query junto al
idioma resuelto, y se reutiliza al construir nuevos enlaces mientras
siga siendo válido.

El idioma efectivo para renderizar la petición no cambia: sigue siendo
siempre el LanguageIdentifier canónico soportado por la aplicación.
2026-08-01 08:05:48 +02:00
fdd2dca280 ♻️ (bootsier): Reorganiza clases y tokens utilitarios 2026-08-01 07:15:13 +02:00
1a3a295f18 ✅ (htmx): Uso de Context::default() por ::new(None) 2026-07-28 19:09:44 +02:00
c91b1cd35c ✨ (seaorm): Añade flatten_txn_err()
Convierte `TransactionError<E>` a `E` para propagar el resultado de una
transacción con `?` sin distinguir el origen del error.
2026-07-28 19:02:12 +02:00
8ddc26dae1 📝 (theme): Aclara alcance de la herencia de temas 2026-07-28 09:19:50 +02:00
dc2ed27da4 ♻️ (util): Extrae non_blank() y normalize_token() 2026-07-28 01:00:14 +02:00
747e64ce34 ✨ (auth): Añade permisos tipados
Sustituye las claves de permiso `&str` sueltas por el trait `Permission`
(clave, etiqueta y grupo), y añade `require_permission()` para cortar un
handler con `ErrorPage::AccessDenied` antes de construir nada.

Para que `require_permission()` pueda fallar sin un `Context` previo,
`ErrorPage` pasa a envolver `Option<HttpRequest>`, `Context::new()`
recibe ahora `HttpRequest` sin `Option`, y se añade `Context::admin()`
para la plantilla de administración.
2026-07-27 22:46:41 +02:00
ea0dc021ce ✨ (seaorm): Añade Paginated<T> y paginate()
Centraliza la paginación genérica junto con `Paginated::map_items()`
para enriquecer los elementos de una página con datos de otra tabla sin
perder la metadata de paginación.
2026-07-26 21:30:28 +02:00
9db1d4bab0 ✨ (htmx): Añade soporte OOB a HtmxResponse
Nuevo método `oob()` para incluir fragmentos adicionales marcados con
`hx-swap-oob` en el cuerpo de la respuesta, sin necesidad de construir
el envoltorio a mano ni depender del `hx-target` de la petición.

De paso, `set_header()` usa ya las constantes de `hx::response` en vez
de literales repetidos, y se revisan comentarios.
2026-07-26 17:55:25 +02:00
74dbcde5c5 ♻️ (core): Cambia HashMap x Vec en RegionComponents 2026-07-26 13:41:32 +02:00
7e6a9f8557 ♻️ (core): Clave &'static str en RegionComponents 2026-07-26 12:50:33 +02:00
845d91d8c1 ♻️ (core): Evita doble clonado de InRegion en Child
Los prototipos registrados con `InRegion` se clonaban dos veces por
petición: una al ensamblar la región (`ComponentGlobal::as_child`) y
otra al renderizar (`Child::render`). Ahora sólo se clonan al
renderizar.
2026-07-26 12:39:01 +02:00
ce6b4d2581 ♻️ (core): Simplifica Child a Arc<dyn Component>
Elimina el `RwLock<Box<dyn Component>>` interno ya que ningún punto del
ciclo de renderizado tomaba el write-lock.

De paso, `From<Embed<C>> for Child` pasa a una coerción de puntero sin
clonar, gracias al copy-on-write que ya ofrece `Embed::get_mut()`. Los
doctests en `rust,ignore` pasan a `rust,no_run`, verificados por el
compilador.
2026-07-26 11:30:16 +02:00
65c8a788c2 ✨ (web): Añade cabeceras HTTP a TestRequest 2026-07-26 10:03:54 +02:00
39f494539e ♻️ (htmx): Expone request y response como públicos 2026-07-26 10:02:36 +02:00
b9d9cdf601 🚧 Ajustes menores en rutas y localización
- Añade `Route::try_resolve()` y `RoutePath::is_empty()` para omitir
  atributos HTML opcionales cuando la ruta resultante está vacía.
- Renombra `Locale::configured_langid()` a `try_langid()` (convención
  `try_*` para Option).
- Reordena include_locales! y otros cambios menores.
2026-07-26 09:55:27 +02:00
8573aca29e ✨ (theme): Añade componentes Region y Template
Introduce Region y Template (`base::component::layout`) para componer el
`<body>` de una página, con captura explícita vía `handle_component()`
en lugar de maquetado implícito:

- `RegionName`/`TemplateName` (`core/theme.rs`) como interfaces de
  identidad; `CoreRegion` (`Header`/`Content`/`Footer`) y `CoreTemplate`
  (`Standard`/`Admin`) como catálogo por defecto.
- `ReservedRegion` (`PageTop`/`PageBottom`, en `response/page.rs`) queda
  fuera de `CoreRegion`. `Page::render()` las renderiza siempre,
  envolviendo `Theme::render_page_body()`, con independencia de la
  plantilla o el tema activos.
- Elimina `TemplateSource` en `Context`: la plantilla pasa a ser un
  `TemplateRef` fijo, sin resolución por tema (ese mecanismo nunca llegó
  a implementarse).
- La personalización por tema es por captura (`handle_component()` +
  `downcast_ref()`), no por sustituir qué constante se resuelve.
- Corrige referencias obsoletas a `DefaultRegions` por `CoreRegion`.
- Sustituye `tests/theme_template.rs` (API anterior) por
  `tests/component_template.rs`, que cubre el patrón de captura con el
  API nuevo.
2026-07-26 09:51:27 +02:00
e7f2563967 ✨ (htmx): Añade soporte HTMX a tablas ordenables
- Nuevo `SortDir` en `pagetop::html` para representar direcciones de
  orden (asc/desc) y calcular la siguiente al pulsar una cabecera.
- `hx_table::sort_link()` construye el enlace de ordenación con los
  cuatro atributos `hx-*` fijos, reutilizable en cualquier tabla.
- `HtmxResponse` usa `RoutePath` en `location`/`push_url`/`replace_url`/
  `redirect` para preservar "lang"; `location_json()` se separa de
  `location()` para el caso de configuración JSON personalizada.
- Añade el módulo `prelude` y una batería de tests para hx, hx_table,
  request, response y extension.
2026-07-23 07:21:27 +02:00
55159f6d8f 🌐 (tests): Traduce comentarios de tests a inglés 2026-07-19 23:34:38 +02:00
af548b03c9 ✨ (core): Añade Route, RoutePath y Waypoint
`Route` resuelve URLs sensibles al `Context`/idioma en propiedades de
componentes; `RoutePath` (renombrado desde html/route.rs) es el valor
concreto de ruta + query. `Waypoint` transporta la URL de retorno entre
pantallas de listado/alta/edición vía query string.

Ajusta `Context::route()`, `Redirect` y los componentes que aceptan
rutas para integrarse con el nuevo tipo `Route`.
2026-07-19 22:16:21 +02:00
6f82da220b 🐛 (web): HttpRequest clona ahora las extensiones
Documenta el nuevo comportamiento y aprovecha para pulir varios
comentarios y restringir visibilidad de símbolos internos.
2026-07-17 00:54:35 +02:00
370bc8ee47 ♻️ (seaorm): Renombra el re-export api a sea_orm
Amplía también los tipos reexportados (Condition, DbBackend, NotSet,
Order, Set, TransactionError, Unchanged) y habilita el mismo
re-export en migration.rs.
2026-07-14 18:37:43 +02:00
e6b4661f21 ✨ (web): Añade Form, RawForm, Request y el módulo middleware
Reexporta los extractores axum::extract::{Form, RawForm, Request} y
el módulo axum::middleware (from_fn, Next) desde pagetop::web.
2026-07-14 18:37:36 +02:00
5a8267032e 📝 Retoques menores en documentación
Quita las itálicas superfluas en "handlers" en pagetop-htmx y en el
doc de módulo de web.rs.
2026-07-14 18:37:10 +02:00
f6f78fddbe 🐛 (aliner): Corrige DefaultRegions en el pie 2026-07-14 18:31:16 +02:00
7a3419415e ✨ (datetime): Reexporta chrono::Duration 2026-07-14 18:26:52 +02:00
82e64238bd 🔥 (config): Elimina la opción app.description 2026-07-14 18:20:01 +02:00
eb3157c4a4 📝 Retoques menores en documentación 2026-07-14 07:27:51 +02:00
30151b6254 ♻️ (macros): Deja main/test sin depender de Tokio
Antes delegaban directamente en `#[tokio::main]`/`#[tokio::test]`; ahora
construyen su propio runtime vía `pagetop::util::build_runtime()`, con
el mismo comportamiento (`Builder::new_multi_thread().enable_all()`).
Las extensiones que usan estas macros ya no necesitan declarar Tokio
como dependencia directa en su `Cargo.toml`.
2026-07-14 00:44:21 +02:00
24bd13414c ✨ Añade gestión de errores catastróficos
- Los errores controlados (403, 404, fatales) se renderizan con el tema
  activo; un `panic!` no controlado se intercepta con un HTML mínimo y
  autónomo, sin depender del tema ni de los componentes.
- Corrige además que `Context::template()` no seguía los cambios de tema
  hechos con `with_theme()` después de crear la página.
- Renombra `DefaultRegion(s)`/`DefaultTemplate(s)`, añade
  `Theme::admin_template()` y `Page::admin()`, y documenta la creación
  de temas e `InRegion` en `core/theme.rs`.
2026-07-13 00:38:11 +02:00
0e72d51276 📝 Quita cursivas de algunos anglicismos en docs 2026-07-10 19:33:26 +02:00
771f61bf29 ♻️ (bootsier): Mueve Container al core de PageTop
`Container` y `Kind` pasan a `pagetop::base::component` como componente
genérico reutilizable por cualquier tema. Bootsier conserva `Width` y
añade el *trait* `ContainerBootsier`, que guarda el ancho como valor
extra en `Props`.
2026-07-09 21:44:52 +02:00
654618aa68 ✨ (html): Añade declaración de estilos a PropsOp 2026-07-09 21:40:50 +02:00
87ad78778d ♻️ (bootsier): Mueve input/select/textarea a theme
Consolida los handlers de estos campos en la capa del tema. Además,
`with_floating_label()` usa un valor extra que lee en `setup()` para
forzar `multiple`/`rows` en 'select` y `textarea`.
2026-07-09 01:26:52 +02:00
bff7cf1b38 ✨ (html): Añade PropsOp::replace_classes()
Permite sustituir una o varias clases por otras nuevas en una sola
operación, preservando su posición. Adapta el tema Bootsier para usarla
en `Button` y elimina el handler duplicado sin referencias.
2026-07-09 01:12:45 +02:00
9e8cd77140 📝 Retoques de documentación en html y componentes 2026-07-09 00:08:04 +02:00
25713a8a13 ♻️ (html): Añade valores extra a Props
- Reordena campos, variantes y métodos para mayor consistencia interna.
- Añade `extra_or`, `extra_or_default` y `extra_or_else` a `Props`.
- Mejora la documentación y amplía los tests de integración.
2026-07-09 00:05:28 +02:00
a26aa4d30a 💄 (intro): Reserva espacio vertical para *badges* 2026-07-07 15:32:30 +02:00
c7b680a7f7 ✨ (html): Añade Preload para precarga de recursos
Nuevo tipo `Preload` (junto a `StyleSheet` y `JavaScript`) que emite
`<link rel="preload">` con soporte para fuentes, estilos, scripts e
imágenes (WebP y AVIF incluidos). Bootsier lo usa para precargar las
fuentes Source Sans 3 y eliminar el FOUT en la carga inicial.
2026-07-07 00:03:36 +02:00
9b2d430d8b ♻️ (seaorm): Adapta al arranque async
- Elimina `run_now` y convierte `initialize()` en `async fn`.
- `DBCONN` pasa de `LazyLock` a `OnceLock`; `MigratorBase` adopta
  `#[pagetop::async_trait]` con métodos async nativos.
2026-07-06 19:40:54 +02:00
1139df6210 ♻️ (bootsier, htmx): Adapta ciclo de render async
Añade `#[async_trait]` a todos los `impl Extension`, `Theme` y
`Component`. Convierte `prepare()` en `async fn` y añade `.await` a las
llamadas `render()` y `render_offcanvas()`.
2026-07-06 07:55:30 +02:00
9354894b3a 🎨 Convierte el ciclo de renderizado en async
`prepare()` de `Component`, `render()` de `Region` y `Template`, y la
implementación de `ComponentRender` pasan a ser funciones async. Se
actualizan todos los componentes base, helpers, tests y ejemplos.
2026-07-06 00:48:41 +02:00
9acd8cc51a 🐛 Corrige orden de despacho en ActionsList
Las acciones con menor peso deben despacharse primero; `.rev()` lo
invertía. Eliminado de `for_each` y `try_for_each`.
2026-07-04 22:59:56 +02:00
aa8b109668 🐛 Corrige orden de extractores en handlers
`HttpRequest` vacía `parts.extensions` al extraerse; `Path<T>` y otros
extractores que dependen de ellas deben declararse antes en el handler.
Corregido en el ejemplo hello-name y documentado en HttpRequest.
2026-07-04 22:58:45 +02:00
8aa4372bdf ♻️ Convierte initialize() y Application en async
`async_trait` se re-exporta desde `pagetop` y se añade al preludio; ya
no es necesario declararlo como dependencia en las extensiones.
`initialize()` es ahora verdaderamente `async` (con `.await`), y
`Application::new()` y `prepare()` pasan también a ser funciones
`async`.
2026-07-04 22:56:19 +02:00
54d5b3e53d 📝 (seaorm): Documenta selección del motor de BD
Diferencia el patrón para aplicaciones (activan el motor via *feature*
en su `Cargo.toml`) y extensiones (sin *features*; la selección es
responsabilidad de la aplicación).
2026-07-02 22:08:28 +02:00
0e96bcce64 ✨ (auth): Añade CurrentUser y sistema de permisos
- Nuevo módulo `auth` que exporta `CurrentUser`, `CheckPermission` y
  `has_permission()`.
- `Context` y `Page` exponen `current_user()` vía `Contextual`.
- `HttpRequest` accede a extensiones de middleware con `extension<T>()`.
- `Extension` incluye nuevo método `configure_middleware`.
- `try_dispatch_actions` para despachar acciones con control del flujo.
2026-07-02 20:52:13 +02:00
3120fd9a6f 🚧 Expone as_str de AutofillField 2026-06-29 20:58:42 +02:00
ee6457acf6 🔧 Adapta ejemplos a la nueva estructura de bs:: 2026-06-29 20:41:06 +02:00
f37db56ec5 ♻️ (bootsier): Reorganiza en capas token/class/bs
Sustituye `attrs/` y `classes/` por tres capas diferenciadas:
- `token/`: primitivas CSS (BreakPoint, Color, ScaleSize, etc.).
- `class/`: tipos compuestos (Border, Background, Margin, etc.).
- `bs/*/props.rs`: props tipadas para cada componente de Bootstrap.

El patrón de construcción es `suffix()` -> `push_to()` -> `to_class()`.
`push_to()` es `pub` en `class/` y `bs/*/props.rs` para que los temas
derivados puedan acceder a los mismos mecanismos de construcción.
2026-06-29 20:32:20 +02:00
64db114eb0 🐛 (bootsier): Corrige ambigüedad en doctests 2026-06-23 07:33:52 +02:00
9435678e01 ♻️ (form): Mueve componentes de formulario a base 2026-06-22 02:12:09 +02:00
26f1cda831 ✨ Permite a plantillas cambiar atributos de <body> 2026-06-20 20:31:51 +02:00
d495c05b96 ♻️ (bootsier): Reorganiza activos estáticos
Compila SCSS, copia JS y fuentes desde `assets/` con prefijo bootsier.
2026-06-20 19:52:21 +02:00
dbf0894894 ✨ (bootsier): Añade ajuste dev.bootsier_static_dir
Permite servir `css`, `js` y `fonts` desde disco mientras la aplicación
está en ejecución, sin necesidad de recompilar.
2026-06-20 19:47:19 +02:00
2202a2350c ♻️ (bootsier): Usa nuevo Props en Navbar/Offcanvas 2026-06-20 16:26:20 +02:00
62219584b0 ♻️ (html): API para id's en Props y componentes 2026-06-20 15:02:23 +02:00
8d0103c257 ♻️ (bootsier): Adapta componentes a nueva API
Sustituye `Classes`/`ClassesOp` por `Props`/`PropsOp` en todos los
componentes: campo, *builder* `with_prop()`, `setup()` y `prepare()`.
2026-06-14 23:07:57 +02:00
1bd97d5705 ✨ (bootsier): Añade AdminLTE 4 y Bootstrap Icons
Integra AdminLTE 4.0.0 (SCSS y JS), Bootstrap Icons 1.13.1 y las fuentes
Source Sans 3 VF; reorganiza los SCSS del tema con la nueva estructura
de *pagetop-build*.
2026-06-14 21:25:52 +02:00
f27790c3a2 ✨ (build): Introduce nuevas funciones de build
Añade `compile_scss()`, `copy_dir()`, `copy_file()`,
`copy_file_replacing()` y `minify_js()` para preparar activos en
`build.rs`. Adopta el patrón `assets/ -> static/`: los archivos
estáticos se mueven a `assets/` y `static/` se añade a `.gitignore`. Los
`build.rs` de *pagetop* y *pagetop-htmx* se actualizan con el nuevo
patrón.  La documentación del módulo se reescribe para reflejar los
nuevos cambios.
2026-06-14 21:18:54 +02:00
6376e3e88c 📝 Ajusta formato de comentarios 2026-06-13 19:16:34 +02:00
38fd24453e ✨ (htmx): Añade integración con HTMX 2
Constantes `hx-*`, `HtmxRequestExt` y `HtmxResponse` cubren el ciclo
completo: escribir atributos, leer la petición y construir la respuesta.
La extensión Htmx inyecta el script automáticamente.

Añade `IntoResponse` y `Response` al prelude de PageTop.
2026-06-13 18:41:15 +02:00
511149caa7 📝 Añade no_run a doctests ilustrativos 2026-06-13 00:31:36 +02:00
41c4379bc3 📝 Limpia y corrige documentación de extensiones 2026-06-12 21:29:10 +02:00
f9e87058d8 ♻️ (html): Migra Classes/ClassesOp a Props/PropsOp
Introduce `Props`/`PropsOp` para gestionar pares `atributo="valor"` y
clases CSS para aplicar en componentes.

- Constructores `Props::new()`, `Props::classes()` y `Props::default()`.
- `Page.body_classes` reemplazado por `body_props` (permite atributos
  arbitrarios en `<body>`, no sólo clases).
- Tests nuevos para atributos y reescritos para clases.
2026-06-12 01:55:07 +02:00
599 changed files with 53674 additions and 10711 deletions

3
.gitignore vendored
View file

@ -1,6 +1,9 @@
# Ignora directorios de compilación
**/target
# Ignora directorios de archivos estáticos
**/static
# Archivos de log
**/log/*.log*

View file

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

291
Cargo.lock generated
View file

@ -20,6 +20,15 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "0.7.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac"
dependencies = [
"memchr",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@ -106,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"
@ -233,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"
@ -242,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"
@ -309,6 +348,16 @@ dependencies = [
"windows-link",
]
[[package]]
name = "chrono-tz"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
dependencies = [
"chrono",
"phf 0.12.1",
]
[[package]]
name = "clap"
version = "4.6.1"
@ -357,12 +406,6 @@ dependencies = [
"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]]
name = "concurrent-queue"
version = "2.5.0"
@ -390,6 +433,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"
@ -415,6 +464,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"
@ -492,6 +550,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"
@ -532,7 +599,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",
]
@ -574,12 +641,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"
@ -933,7 +1011,7 @@ version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3"
dependencies = [
"aho-corasick",
"aho-corasick 1.1.4",
"bstr",
"log",
"regex-automata",
@ -961,10 +1039,20 @@ dependencies = [
"indexmap",
"lasso",
"once_cell",
"phf",
"phf 0.11.3",
"rand",
]
[[package]]
name = "hashbrown"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e"
dependencies = [
"ahash",
"bumpalo",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
@ -1034,7 +1122,7 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
"digest 0.10.7",
]
[[package]]
@ -1097,6 +1185,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"
@ -1470,7 +1567,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest",
"digest 0.10.7",
]
[[package]]
@ -1495,6 +1592,17 @@ dependencies = [
"unicase",
]
[[package]]
name = "minify-js"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1fa5546ee8bd66024113e506cabe4230e76635a094c06ea2051b66021dda92e"
dependencies = [
"aho-corasick 0.7.20",
"lazy_static",
"parse-js",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@ -1686,8 +1794,10 @@ dependencies = [
name = "pagetop"
version = "0.5.0"
dependencies = [
"async-trait",
"axum",
"chrono",
"chrono-tz",
"colored",
"config",
"figlet-rs",
@ -1706,6 +1816,7 @@ dependencies = [
"serde_json",
"tempfile",
"terminal_size",
"thiserror",
"tokio",
"tower",
"tower-http",
@ -1715,13 +1826,23 @@ 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"
dependencies = [
"pagetop",
"pagetop-build",
"tokio",
]
[[package]]
@ -1731,7 +1852,6 @@ dependencies = [
"pagetop",
"pagetop-build",
"serde",
"tokio",
]
[[package]]
@ -1739,9 +1859,19 @@ name = "pagetop-build"
version = "0.3.2"
dependencies = [
"grass",
"minify-js",
"pagetop-statics",
]
[[package]]
name = "pagetop-htmx"
version = "0.1.0"
dependencies = [
"pagetop",
"pagetop-build",
"serde_json",
]
[[package]]
name = "pagetop-macros"
version = "0.3.0"
@ -1756,7 +1886,6 @@ dependencies = [
name = "pagetop-minimal"
version = "0.1.0"
dependencies = [
"concat-string",
"indoc",
"pastey",
]
@ -1765,7 +1894,6 @@ dependencies = [
name = "pagetop-seaorm"
version = "0.0.4"
dependencies = [
"async-trait",
"pagetop",
"sea-orm",
"sea-schema",
@ -1783,6 +1911,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"
@ -1812,6 +1958,30 @@ dependencies = [
"windows-link",
]
[[package]]
name = "parse-js"
version = "0.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2742b5e32dcb5930447ed9f9e401a7dfd883867fc079c4fac44ae8ba3593710e"
dependencies = [
"aho-corasick 0.7.20",
"bumpalo",
"hashbrown 0.13.2",
"lazy_static",
"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"
@ -1867,7 +2037,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_macros",
"phf_shared",
"phf_shared 0.11.3",
]
[[package]]
name = "phf"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
dependencies = [
"phf_shared 0.12.1",
]
[[package]]
@ -1876,7 +2055,7 @@ version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared",
"phf_shared 0.11.3",
"rand",
]
@ -1887,7 +2066,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator",
"phf_shared",
"phf_shared 0.11.3",
"proc-macro2",
"quote",
"syn",
@ -1902,6 +2081,15 @@ dependencies = [
"siphasher",
]
[[package]]
name = "phf_shared"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
dependencies = [
"siphasher",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@ -2094,7 +2282,7 @@ version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"aho-corasick 1.1.4",
"memchr",
"regex-syntax",
]
@ -2111,8 +2299,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",
@ -2210,6 +2398,7 @@ checksum = "2dc312fedd460a47ea563911761d254a84e7b51d8cc73ec92c929e78f33fa957"
dependencies = [
"async-stream",
"async-trait",
"chrono",
"derive_more",
"futures-util",
"log",
@ -2245,6 +2434,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",
@ -2256,6 +2446,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0019f47430f7995af63deda77e238c17323359af241233ec768aba1faea7608"
dependencies = [
"chrono",
"sea-query",
"sqlx",
]
@ -2386,6 +2577,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"
@ -2414,8 +2617,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]]
@ -2425,8 +2628,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]]
@ -2460,7 +2674,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",
]
@ -2541,6 +2755,7 @@ checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
dependencies = [
"base64",
"bytes",
"chrono",
"crc",
"crossbeam-queue",
"either",
@ -2559,7 +2774,7 @@ dependencies = [
"percent-encoding",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"smallvec",
"thiserror",
"tokio",
@ -2596,7 +2811,7 @@ dependencies = [
"quote",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"sqlx-core",
"sqlx-mysql",
"sqlx-postgres",
@ -2617,8 +2832,9 @@ dependencies = [
"bitflags",
"byteorder",
"bytes",
"chrono",
"crc",
"digest",
"digest 0.10.7",
"dotenvy",
"either",
"futures-channel",
@ -2639,7 +2855,7 @@ dependencies = [
"rsa",
"serde",
"sha1",
"sha2",
"sha2 0.10.9",
"smallvec",
"sqlx-core",
"stringprep",
@ -2658,6 +2874,7 @@ dependencies = [
"base64",
"bitflags",
"byteorder",
"chrono",
"crc",
"dotenvy",
"etcetera",
@ -2676,7 +2893,7 @@ dependencies = [
"rand",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"smallvec",
"sqlx-core",
"stringprep",
@ -2692,6 +2909,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
dependencies = [
"atoi",
"chrono",
"flume",
"futures-channel",
"futures-core",
@ -2994,9 +3212,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",
@ -3015,6 +3233,7 @@ dependencies = [
"tokio-util",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]

View file

@ -9,7 +9,10 @@ members = [
# Extensions
"extensions/pagetop-aliner",
"extensions/pagetop-bootsier",
"extensions/pagetop-htmx",
"extensions/pagetop-admin",
"extensions/pagetop-seaorm",
"extensions/pagetop-user",
]
[workspace.package]
@ -20,12 +23,14 @@ license = "MIT OR Apache-2.0"
authors = ["Manuel Cillero <manuel@cillero.es>"]
[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"
chrono-tz = "0.10"
colored = "3.1"
concat-string = "1.0"
config = { version = "0.15", default-features = false, features = ["toml"] }
figlet-rs = "1.0"
fluent-templates = "0.14"
@ -35,20 +40,25 @@ indexmap = "2.14"
indoc = "2.0"
itoa = "1.0"
mime_guess = "2.0"
minify-js = "0.6"
parking_lot = "0.12"
pastey = "0.2"
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"] }
@ -60,15 +70,18 @@ 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-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-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]
@ -96,8 +109,10 @@ default = []
testing = []
[dependencies]
async-trait.workspace = true
axum.workspace = true
chrono.workspace = true
chrono-tz.workspace = true
colored.workspace = true
config.workspace = true
figlet-rs.workspace = true
@ -111,6 +126,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

View file

@ -1,6 +1,6 @@
<div align="center">
<img src="https://git.cillero.es/manuelcillero/pagetop/raw/branch/main/static/banner.png" />
<img src="https://git.cillero.es/manuelcillero/pagetop/raw/branch/main/assets/banner.png" />
<h1>PageTop</h1>
@ -11,9 +11,8 @@
[![Descargas](https://img.shields.io/crates/d/pagetop.svg?label=Descargas&style=for-the-badge&logo=transmission)](https://crates.io/crates/pagetop)
[![Licencia](https://img.shields.io/badge/license-MIT%2FApache-blue.svg?label=Licencia&style=for-the-badge)](https://git.cillero.es/manuelcillero/pagetop#licencia)
</div>
<br>
</div>
PageTop reivindica la esencia de la web clásica usando [Rust](https://www.rust-lang.org/es) para la
creación de soluciones web SSR (*renderizadas en el servidor*) basadas en HTML, CSS y JavaScript.
@ -29,8 +28,7 @@ según las necesidades de cada proyecto, incluyendo:
* **Temas** (*themes*): son extensiones que permiten modificar la apariencia de páginas y
componentes.
## ⚡️ Guía rápida
## Guía rápida
La aplicación más sencilla de PageTop se ve así:
@ -39,43 +37,43 @@ use pagetop::prelude::*;
#[pagetop::main]
async fn main() -> std::io::Result<()> {
Application::new().run()?.await
Application::new().await.run().await
}
```
Este código arranca el servidor de PageTop. Con la configuración por defecto, muestra una página de
bienvenida accesible desde un navegador local en la dirección `http://localhost:8080`.
Para personalizar el servicio, se puede crear una extensión de PageTop de la siguiente manera:
Para personalizar el servicio, puedes crear una extensión de PageTop de la siguiente manera:
```rust,no_run
use pagetop::prelude::*;
struct HelloWorld;
#[async_trait]
impl Extension for HelloWorld {
fn configure_service(&self, scfg: &mut service::web::ServiceConfig) {
scfg.route("/", service::web::get().to(hello_world));
fn configure_router(&self, router: Router) -> Router {
router.route("/", web::get(hello_world))
}
}
async fn hello_world(request: HttpRequest) -> Result<Markup, ErrorPage> {
Page::new(request)
.add_child(Html::with(|_| html! { h1 { "Hello World!" } }))
.render()
.with_child(Html::with(|_| html! { h1 { "Hello World!" } }))
.render().await
}
#[pagetop::main]
async fn main() -> std::io::Result<()> {
Application::prepare(&HelloWorld).run()?.await
Application::prepare(&HelloWorld).await.run().await
}
```
Este programa implementa una extensión llamada `HelloWorld` que sirve una página web en la ruta raíz
(`/`) mostrando el texto "Hello world!" dentro de un elemento HTML `<h1>`.
(`/`) mostrando el texto "Hello World!" dentro de un elemento HTML `<h1>`.
## 📂 Proyecto
## Proyecto
El código se organiza en un *workspace* donde actualmente se incluyen los siguientes subproyectos:
@ -113,8 +111,7 @@ El código se organiza en un *workspace* donde actualmente se incluyen los sigui
* **[pagetop-seaorm](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/extensions/pagetop-seaorm)**,
integra [SeaORM](https://www.sea-ql.org/SeaORM) para acceder a bases de datos relacionales.
## 🧪 Pruebas
## Pruebas
Para simplificar el flujo de trabajo, el repositorio incluye varios **alias de Cargo** declarados en
`.cargo/config.toml`. Basta con ejecutarlos desde la raíz del proyecto:
@ -134,15 +131,13 @@ Para simplificar el flujo de trabajo, el repositorio incluye varios **alias de C
> * Los alias suprimen las trazas del registro de eventos. Para activarlas usa directamente
> `cargo test`.
## 🚧 Advertencia
## Advertencia
**PageTop** es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su
ecosistema. Su API está sujeta a cambios frecuentes. No se recomienda su uso en producción, al menos
hasta que se libere la versión **1.0.0**.
## 📜 Licencia
## Licencia
El código está disponible bajo una doble licencia:
@ -155,8 +150,7 @@ El código está disponible bajo una doble licencia:
Puedes elegir la licencia que prefieras. Este enfoque de doble licencia es el estándar de facto en
el ecosistema Rust.
## ✨ Contribuir
## Contribuir
PageTop mantiene **un único repositorio oficial**:

View file

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 180 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 183 KiB

After

Width:  |  Height:  |  Size: 183 KiB

Before After
Before After

View file

@ -0,0 +1,45 @@
// Abre y cierra los componentes `Dialog` de Basic (elemento `<dialog>` nativo) interpretando el
// vocabulario de atributos `data-dialog-*`, común a todos los temas: Bootsier traduce estos mismos
// nombres a los suyos propios (`data-bs-*`) en `setup()` para que el JS de Bootstrap los reconozca,
// así el marcado de un disparador o de un botón de cierre es idéntico en los dos temas. Sin
// dependencias: ni htmx, ni Bootstrap, ni ninguna otra librería.
// Abre el diálogo referenciado por `data-dialog-target` al pulsar un elemento con
// `data-dialog-toggle="modal"`.
document.addEventListener("click", function (event) {
var toggle = event.target.closest('[data-dialog-toggle="modal"]');
if (!toggle) {
return;
}
var target = toggle.getAttribute("data-dialog-target");
var dialog = target && document.querySelector(target);
if (!(dialog instanceof HTMLDialogElement)) {
return;
}
event.preventDefault();
dialog.showModal();
});
// Cierra el `<dialog>` ascendente más cercano al pulsar un elemento con
// `data-dialog-dismiss="modal"`.
document.addEventListener("click", function (event) {
var dismiss = event.target.closest('[data-dialog-dismiss="modal"]');
if (!dismiss) {
return;
}
var dialog = dismiss.closest("dialog");
if (dialog) {
event.preventDefault();
dialog.close();
}
});
// Cierra el diálogo al pulsar sobre el fondo (`::backdrop`): un clic ahí llega con el propio
// `<dialog>` como `event.target`, porque el fondo no forma parte de su caja de contenido.
document.querySelectorAll("dialog.dialog").forEach(function (dialog) {
dialog.addEventListener("click", function (event) {
if (event.target === dialog) {
dialog.close();
}
});
});

View file

@ -0,0 +1,66 @@
// Conecta cada `Dropdown` (standalone o embebido en un `Nav`) con `accessible-menu` (ver
// basic.menu.min.js), que añade los roles ARIA, el `aria-expanded` y la navegación por teclado
// sobre el marcado ya generado en el servidor. Selecciona por clase, no por etiqueta, porque el
// disparador es un `<button>` en un `Dropdown` independiente y un `<a>` cuando cuelga de un
// `nav::Item::dropdown()`.
document.querySelectorAll(".dropdown > .dropdown-toggle").forEach(function (toggle) {
var container = toggle.parentElement;
var menu = container.querySelector(":scope > .dropdown-menu");
if (!menu) {
return;
}
new TopLinkDisclosureMenu({
menuElement: menu,
containerElement: container,
controllerElement: toggle,
// `Header`/`Divider`/`Label` no son interactivos: se excluyen de `menuItemSelector`
// (si no, la librería asume que todo `<li>` tiene un enlace y lanza un TypeError al
// intentar añadirle listeners de foco).
menuItemSelector: "li:has(> a.dropdown-item, > button.dropdown-item)",
menuLinkSelector: "a.dropdown-item, button.dropdown-item",
// La navegación con flechas/Home/End es opcional en la librería (`false` por defecto);
// se activa para igualar el comportamiento habitual de un menú desplegable.
optionalKeySupport: true,
});
});
// Cada `TopLinkDisclosureMenu` es independiente: al hacer clic fuera sólo cierra su propio menú
// (ver `_handleClick()` en accessible-menu), así que abrir un desplegable no cierra otro que ya
// estuviera abierto en otra parte de la página. Se corrige aquí escuchando `accessibleMenuExpand`
// -evento que la librería emite en cada apertura- y cerrando el resto de menús de nivel superior
// ya registrados, vía `window.AccessibleMenu.menus`, el registro global que la propia librería
// mantiene con todas las instancias (incluye las de `basic.menu.init.js`).
document.addEventListener("accessibleMenuExpand", function (event) {
var openedToggle = event.detail.toggle;
var menus = (window.AccessibleMenu && window.AccessibleMenu.menus) || {};
Object.values(menus).forEach(function (menu) {
var controller = menu.elements.controller;
if (controller && controller !== openedToggle) {
controller.close();
}
});
});
// El cierre "al hacer clic fuera" que trae la librería no funciona para un `controllerElement` de
// nivel superior como el nuestro: en `toggleToggle()` (accessible-menu), al abrir, hace
// `menu.focusState = "self"` y a continuación `toggle.elements.controlledMenu.focusState =
// "none"` -pensado para cuando el toggle abre un submenú hijo distinto del menú que lo contiene-,
// pero aquí `menu` y `toggle.elements.controlledMenu` son el mismo objeto: la segunda asignación
// deshace la primera. El cierre nativo de la librería depende de `focusState !== "none"`, así que
// nunca se dispara aunque el menú esté abierto (`isOpen` sí queda correcto). Se sustituye aquí por
// una comprobación propia basada sólo en `isOpen` y en la posición del clic.
document.addEventListener("click", function (event) {
var menus = (window.AccessibleMenu && window.AccessibleMenu.menus) || {};
Object.values(menus).forEach(function (menu) {
var controller = menu.elements.controller;
if (
controller &&
controller.isOpen &&
!menu.dom.menu.contains(event.target) &&
!controller.dom.toggle.contains(event.target)
) {
controller.close();
}
});
});

3
assets/js/basic.menu.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

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

41
assets/scss/basic.scss Normal file
View file

@ -0,0 +1,41 @@
// PageTop Basic theme styles.
//
// Compiled by `build.rs` into `static/css/basic.min.css`. Comments written with `//` never reach
// the compiled file, so they can be as long as needed.
//
// The partials are emitted in the order they are loaded here, which matters when two rules tie on
// specificity: the later one wins.
// Third-party reset (normalize.css v8.0.1, MIT), copied verbatim. It goes first so that every
// rule of the theme can override it.
@use "basic/normalize";
// Custom properties and base styles.
@use "basic/root";
@use "basic/base";
// Components, in alphabetical order. `buttons` must go before `dropdown` and `nav`, see the notes
// in `basic/_buttons.scss`.
@use "basic/badge";
@use "basic/brand";
@use "basic/breadcrumb";
@use "basic/buttons";
@use "basic/container";
@use "basic/dialog";
@use "basic/dropdown";
@use "basic/form";
@use "basic/image";
@use "basic/messages";
@use "basic/nav";
@use "basic/navbar";
@use "basic/pager";
@use "basic/table";
// Theme regions.
@use "basic/regions";
// Styles for the pages of the `pagetop-user` extension.
@use "basic/user";
// Utilities go last so they win over any component rule they are combined with.
@use "basic/utilities";

View file

@ -0,0 +1,21 @@
.badge {
display: inline-block;
padding: 0.3em 0.6em;
font-size: 0.8125rem;
font-weight: 600;
line-height: 1;
color: #fff;
background-color: var(--val-color--secondary);
border-radius: 0.375rem;
white-space: nowrap;
}
.badge-info,
.badge-warning {
color: #000;
}
.badge-primary { background-color: var(--val-color--primary); }
.badge-neutral { background-color: var(--val-color--secondary); }
.badge-success { background-color: var(--val-color--success); }
.badge-info { background-color: var(--val-color--info); }
.badge-warning { background-color: var(--val-color--warning); }
.badge-severe { background-color: var(--val-color--danger); }

View file

@ -0,0 +1,23 @@
*, *::before, *::after {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
font-family: var(--val-font-family);
font-size: var(--val-fs--base);
font-weight: var(--val-fw--base);
line-height: var(--val-lh--base);
color: var(--val-color--text);
background-color: var(--val-color--bg);
-webkit-tap-highlight-color: transparent;
}
// Header, Aside, Content and Footer are stacked in that order, using the full available width.
.wrapper {
display: flex;
flex-direction: column;
}

View file

@ -0,0 +1,12 @@
.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;
}

View file

@ -0,0 +1,27 @@
.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;
}

View file

@ -0,0 +1,25 @@
// Breakpoints of the Basic theme, used to generate the responsive classes of `Container` and
// `Navbar`. They match the default `Theme::breakpoint_entry()` table (`xs` has no minimum width, so
// it is left out), so a child theme that renames or resizes the breakpoints must also redefine the
// classes generated from them.
//
// This partial emits no CSS on its own.
$breakpoints: (
sm: 576px,
md: 768px,
lg: 992px,
xl: 1200px,
xxl: 1400px,
xxxl: 1920px,
);
// Maximum width of `.container` and `.container-{name}` from each breakpoint on.
$container-max-widths: (
sm: 540px,
md: 720px,
lg: 960px,
xl: 1140px,
xxl: 1320px,
xxxl: 1800px,
);

View file

@ -0,0 +1,120 @@
// Base geometry of .dropdown-button/.dropdown-toggle (the rest is in `_dropdown.scss`). It is
// declared here, before the button classes, so that .button-sm/.button-lg/.button-* win the
// specificity tie against it when combined on the same element (CSS resolves a tie by order of
// appearance in the file, not by the order of the classes in the 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;
}
.button {
display: inline-block;
padding: 0.5rem 1.25rem;
font-size: var(--val-fs--base);
font-weight: 600;
line-height: var(--val-lh--base);
color: #fff;
background-color: var(--val-color--primary);
border: 1px solid var(--val-color--primary);
border-radius: 0.375rem;
text-decoration: none;
cursor: pointer;
transition: background-color .15s ease-in-out, border-color .15s ease-in-out;
}
.button:hover {
background-color: color-mix(in srgb, var(--val-color--primary) 85%, black);
border-color: color-mix(in srgb, var(--val-color--primary) 85%, black);
}
.button:disabled,
.button.disabled {
opacity: 0.65;
cursor: default;
pointer-events: none;
}
.button::-moz-focus-inner {
border: 0;
}
.button-sm {
padding: 0.25rem 0.75rem;
font-size: 0.875rem;
}
.button-lg {
padding: 0.75rem 1.5rem;
font-size: 1.25rem;
}
.button-primary,
.button-neutral,
.button-success,
.button-severe {
color: #fff;
}
.button-info,
.button-warning {
color: #000;
}
.button-primary { background-color: var(--val-color--primary); border-color: var(--val-color--primary); }
.button-neutral { background-color: var(--val-color--secondary); border-color: var(--val-color--secondary); }
.button-success { background-color: var(--val-color--success); border-color: var(--val-color--success); }
.button-info { background-color: var(--val-color--info); border-color: var(--val-color--info); }
.button-warning { background-color: var(--val-color--warning); border-color: var(--val-color--warning); }
.button-severe { background-color: var(--val-color--danger); border-color: var(--val-color--danger); }
.button-primary:hover { background-color: color-mix(in srgb, var(--val-color--primary) 85%, black); border-color: color-mix(in srgb, var(--val-color--primary) 85%, black); }
.button-neutral:hover { background-color: color-mix(in srgb, var(--val-color--secondary) 85%, black); border-color: color-mix(in srgb, var(--val-color--secondary) 85%, black); }
.button-success:hover { background-color: color-mix(in srgb, var(--val-color--success) 85%, black); border-color: color-mix(in srgb, var(--val-color--success) 85%, black); }
.button-info:hover { background-color: color-mix(in srgb, var(--val-color--info) 85%, black); border-color: color-mix(in srgb, var(--val-color--info) 85%, black); }
.button-warning:hover { background-color: color-mix(in srgb, var(--val-color--warning) 85%, black); border-color: color-mix(in srgb, var(--val-color--warning) 85%, black); }
.button-severe:hover { background-color: color-mix(in srgb, var(--val-color--danger) 85%, black); border-color: color-mix(in srgb, var(--val-color--danger) 85%, black); }
.button-outline-primary,
.button-outline-neutral,
.button-outline-success,
.button-outline-info,
.button-outline-warning,
.button-outline-severe {
background-color: transparent;
}
.button-outline-primary { border-color: var(--val-color--primary); color: var(--val-color--primary); }
.button-outline-neutral { border-color: var(--val-color--secondary); color: var(--val-color--secondary); }
.button-outline-success { border-color: var(--val-color--success); color: var(--val-color--success); }
.button-outline-info { border-color: var(--val-color--info); color: var(--val-color--info); }
.button-outline-warning { border-color: var(--val-color--warning); color: var(--val-color--warning); }
.button-outline-severe { border-color: var(--val-color--danger); color: var(--val-color--danger); }
.button-outline-primary:hover { background-color: var(--val-color--primary); border-color: var(--val-color--primary); color: #fff; }
.button-outline-neutral:hover { background-color: var(--val-color--secondary); border-color: var(--val-color--secondary); color: #fff; }
.button-outline-success:hover { background-color: var(--val-color--success); border-color: var(--val-color--success); color: #fff; }
.button-outline-info:hover { background-color: var(--val-color--info); border-color: var(--val-color--info); color: #000; }
.button-outline-warning:hover { background-color: var(--val-color--warning); border-color: var(--val-color--warning); color: #000; }
.button-outline-severe:hover { background-color: var(--val-color--danger); border-color: var(--val-color--danger); color: #fff; }
.button-link {
font-weight: 400;
color: var(--val-color--primary);
background-color: transparent;
border-color: transparent;
text-decoration: underline;
}
.button-link:hover {
color: color-mix(in srgb, var(--val-color--primary) 85%, black);
background-color: transparent;
border-color: transparent;
}
.button-link:disabled,
.button-link.disabled {
color: var(--val-color--text--muted);
}
.button-set {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}

View file

@ -0,0 +1,32 @@
@use "sass:list";
@use "sass:map";
@use "breakpoints" as bp;
// 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).
$named: ();
@each $name in map.keys(bp.$breakpoints) {
$named: list.append($named, ".container-#{$name}", comma);
}
.container,
.container-fluid,
#{$named} {
width: 100%;
margin-inline: auto;
padding-inline: 1rem;
}
// From each breakpoint on, `.container` and every `.container-{name}` up to that breakpoint take
// its maximum width, while the larger ones stay fluid until their own breakpoint is reached.
$sized: (".container");
@each $name, $width in bp.$breakpoints {
$sized: list.append($sized, ".container-#{$name}", comma);
@media (width >= $width) {
#{$sized} {
max-width: map.get(bp.$container-max-widths, $name);
}
}
}

View file

@ -0,0 +1,57 @@
.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));
}

View file

@ -0,0 +1,93 @@
.dropdown {
position: relative;
display: inline-block;
}
// The base geometry of .dropdown-button/.dropdown-toggle lives at the top of `_buttons.scss`, which
// is loaded before this partial, so that .button-sm/.button-lg/.button-* win the specificity tie
// against it (CSS resolves a tie by order of appearance in the file, not by the order of the
// classes in the 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;
}
// Anchors the menu's right edge to the button's right edge, so it opens inwards.
.dropdown-menu-end {
left: auto;
right: 0;
}
// Options and headers stay on one line, so the menu grows to fit the longest one.
.dropdown-item {
display: block;
width: 100%;
padding: 0.375rem 1rem;
text-align: left;
white-space: nowrap;
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;
white-space: nowrap;
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);
}

View file

@ -0,0 +1,110 @@
.form-required {
color: var(--val-color--danger);
margin-left: 0.25rem;
}
.form-label {
display: block;
margin-top: 1em;
}
.form-text {
font-size: 0.9375rem;
color: var(--val-color--text--muted);
}
.form-field > select.form-select,
.form-field > input.form-control,
.form-field > input.form-range,
.form-field > textarea.form-control {
display: block;
padding: 0.25rem 0.5rem;
width: 100%;
}
fieldset {
margin: 2rem 0 1rem;
background: var(--val-color--bg);
}
fieldset > legend {
background-color: var(--val-color--bg);
border: 2px groove threedface;
padding: 0 0.5em;
}
.form-check {
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 1.5rem;
margin-bottom: 0.25rem;
}
.form-check-inline {
display: inline-flex;
margin-right: 1rem;
}
.form-check-reverse {
flex-direction: row-reverse;
justify-content: flex-start;
text-align: right;
}
.form-check-input {
flex-shrink: 0;
cursor: pointer;
}
.form-check-label {
cursor: pointer;
}
.form-switch .form-check-input {
appearance: none;
-webkit-appearance: none;
width: 2em;
height: 1em;
background-color: var(--val-color--switch-off);
border-radius: 1em;
position: relative;
cursor: pointer;
transition: background-color .15s ease-in-out;
}
.form-switch .form-check-input::after {
content: "";
position: absolute;
width: 1em;
height: 1em;
background-color: white;
border-radius: 50%;
top: 0;
left: 0;
transition: transform .15s ease-in-out;
box-shadow: 0 0 0 1px rgba(0, 0, 0, .25);
}
.form-switch .form-check-input:checked {
background-color: var(--val-color--primary);
}
.form-switch .form-check-input:checked::after {
transform: translateX(1em);
}
input:disabled,
input:disabled + label {
cursor: default !important;
}
input:disabled + label {
color: var(--val-color--text--muted);
}
.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;
background-color: #fff;
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
}
.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);
}

View file

@ -0,0 +1,15 @@
.image {
vertical-align: middle;
}
.image-fluid {
max-width: 100%;
height: auto;
}
.image-thumbnail {
max-width: 100%;
height: auto;
padding: 0.25rem;
background-color: var(--val-color--bg);
border: 1px solid var(--val-color--border);
border-radius: 0.375rem;
}

View file

@ -0,0 +1,24 @@
.message {
padding: 0.75rem 1rem;
margin-bottom: 1rem;
border: 1px solid transparent;
border-radius: 0.375rem;
}
.message-info {
color: color-mix(in srgb, var(--val-color--info) 40%, black);
background-color: color-mix(in srgb, var(--val-color--info) 15%, var(--val-color--bg));
border-color: color-mix(in srgb, var(--val-color--info) 40%, var(--val-color--bg));
}
.message-warning {
color: color-mix(in srgb, var(--val-color--warning) 40%, black);
background-color: color-mix(in srgb, var(--val-color--warning) 15%, var(--val-color--bg));
border-color: color-mix(in srgb, var(--val-color--warning) 40%, var(--val-color--bg));
}
.message-error {
color: color-mix(in srgb, var(--val-color--danger) 40%, black);
background-color: color-mix(in srgb, var(--val-color--danger) 15%, var(--val-color--bg));
border-color: color-mix(in srgb, var(--val-color--danger) 40%, var(--val-color--bg));
}

View file

@ -0,0 +1,61 @@
.nav {
display: flex;
align-items: center;
gap: 0.25rem;
list-style: none;
margin: 0;
padding: 0;
}
.nav-item {
position: relative;
}
.nav-link {
display: block;
padding: 0.5rem 0.75rem;
color: var(--val-color--text);
text-decoration: none;
border-radius: 0.375rem;
}
.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;
}
.nav-link.disabled {
color: var(--val-color--text--muted);
pointer-events: none;
}
// The toggle of a dropdown inside a `Nav` looks like the other links: it drops the border that a
// standalone `Dropdown` toggle has (see `_buttons.scss`).
.nav-link.dropdown-toggle {
width: 100%;
text-align: start;
border: 0;
}
.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;
}

View file

@ -0,0 +1,118 @@
@use "breakpoints" as bp;
.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";
}
// Pushes the toggle (`BrandLeft`) or the brand (`BrandRight`) to the far end of the bar.
.brand ~ .navbar-toggle,
.navbar-toggle ~ .brand {
margin-inline-start: auto;
}
.navbar-content {
display: flex;
align-items: center;
gap: 1rem;
flex: 1 1 auto;
}
// A bar without a toggle button never stacks its `.nav` items in a column, so they wrap onto
// another line when they don't fit instead of overflowing the bar.
.navbar-content .nav {
flex-wrap: wrap;
}
.navbar-text {
color: var(--val-color--text--muted);
}
// Navbar position, from the `navbar::Position` classes. `.navbar` has no background of its own, so
// a bar taken out of the flow (fixed) or pinned while scrolling (sticky) gets one, or the content
// passing underneath would show through.
.navbar.fixed-top,
.navbar.fixed-bottom,
.navbar.sticky-top,
.navbar.sticky-bottom {
background-color: var(--val-color--bg);
}
.navbar.fixed-top,
.navbar.fixed-bottom {
position: fixed;
right: 0;
left: 0;
z-index: 1030;
}
.navbar.fixed-top {
top: 0;
}
.navbar.fixed-bottom {
bottom: 0;
}
.navbar.sticky-top,
.navbar.sticky-bottom {
position: sticky;
z-index: 1020;
}
.navbar.sticky-top {
top: 0;
}
.navbar.sticky-bottom {
bottom: 0;
}
// A navbar with a toggle button collapses below the breakpoint set by its `navbar-expand-{name}`
// class, added by the `Navbar` component. `navbar-expand` alone (a breakpoint with no minimum
// width) never collapses and needs no rules, since the base rules above are the expanded layout.
//
// While collapsed, the `.nav` items are stacked in a column, so a dropdown menu inside it is laid
// out in the flow (`position: static`) to push down the following items instead of covering them.
// A navbar without a toggle button has nothing to collapse: its `.nav` items stay in a row.
@each $name, $width in bp.$breakpoints {
@media (width < $width) {
.navbar-expand-#{$name} {
flex-wrap: wrap;
}
.navbar-expand-#{$name}:has(.navbar-toggle) {
.navbar-toggle {
display: inline-block;
}
.navbar-content {
order: 1;
display: none;
flex-direction: column;
align-items: stretch;
width: 100%;
}
.navbar-content.show {
display: flex;
}
.nav {
flex-direction: column;
align-items: stretch;
width: 100%;
}
.nav .dropdown-menu {
position: static;
box-shadow: none;
}
}
}
}

View file

@ -0,0 +1,133 @@
.pager {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem;
margin: 1rem 0;
padding: 0 1rem;
}
.pager-align-start {
justify-content: flex-start;
}
.pager-align-center {
justify-content: center;
}
.pager-align-end {
justify-content: flex-end;
}
.pager-summary {
color: var(--val-color--text--muted);
font-size: 0.8125rem;
align-self: flex-start;
margin-inline-end: auto;
}
.pager-align-start .pager-summary {
order: 1;
margin-inline-end: 0;
margin-inline-start: auto;
}
.pager-align-center .pager-summary {
order: 1;
flex-basis: 100%;
text-align: center;
margin-inline-end: 0;
}
.pagination {
display: flex;
align-items: center;
gap: 0.25rem;
list-style: none;
margin: 0;
padding: 0;
}
.pagination a {
display: flex;
align-items: center;
justify-content: center;
min-width: 1.5rem;
padding: 0.375rem 0.625rem;
border-radius: 0.375rem;
color: var(--val-color--text);
text-decoration: none;
}
.pagination a:hover {
background-color: color-mix(in srgb, var(--val-color--text) 8%, transparent);
}
.pagination .active a {
color: #fff;
background-color: var(--val-color--primary);
}
.pagination .disabled a {
color: var(--val-color--text--muted);
pointer-events: none;
cursor: default;
}
.page-ellipsis {
padding: 0.375rem 0.25rem;
color: var(--val-color--text--muted);
}
// .page-link-icon shows the navigation text of the previous/next buttons. The full accessible text
// goes separately, in the link's aria-label. So replacing the visible text with other content does
// not affect accessibility, e.g. to use "‹"/"›" you can apply:
//
// .page-link-icon {
// display: inline-block;
// font-size: 0;
// }
// .page-previous .page-link-icon::before {
// content: "‹";
// font-size: 1rem;
// display: inline-block;
// transform: translateY(-0.15em) scale(1.5);
// }
// .page-next .page-link-icon::before {
// content: "›";
// font-size: 1rem;
// display: inline-block;
// transform: translateY(-0.15em) scale(1.5);
// }
.pager-jump {
display: flex;
align-items: center;
}
.pager-jump:focus-within {
border-radius: 0.375rem;
box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--val-color--primary) 25%, transparent);
}
.pager-jump-input > input.form-control:focus,
.pager-jump-button:focus {
outline: none;
box-shadow: none;
}
.pager-jump-input > input.form-control {
width: calc(var(--pager-jump-width, 1ch) + 2.5rem);
padding: 0.375rem 0.625rem;
line-height: var(--val-lh--base);
border: 0;
border-start-end-radius: 0;
border-end-end-radius: 0;
background-color: var(--val-color--bg--muted);
}
.pager-jump-input > input.form-control {
text-align: center;
appearance: textfield;
-moz-appearance: textfield;
}
.pager-jump-input > input.form-control::-webkit-outer-spin-button,
.pager-jump-input > input.form-control::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.pager-jump-button {
padding: 0.375rem 0.625rem;
border: 0;
border-start-start-radius: 0;
border-end-start-radius: 0;
}
.pager-jump-button:hover {
background-color: color-mix(in srgb, var(--val-color--primary) 85%, black);
}

View file

@ -0,0 +1,4 @@
.region-footer {
padding: .75rem 0 3rem;
text-align: center;
}

View file

@ -0,0 +1,27 @@
:root {
// Font families
--val-font-sans: system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif;
--val-font-serif: Georgia,"Times New Roman",serif;
--val-font-monospace: SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;
--val-font-family: var(--val-font-sans);
// Font size
--val-fs--base: 1rem;
// Font weight
--val-fw--base: 400;
// Line height
--val-lh--base: 1.5;
// Core Colors
--val-color--primary: #0d6efd;
--val-color--secondary: #6c757d;
--val-color--success: #198754;
--val-color--info: #0dcaf0;
--val-color--warning: #ffc107;
--val-color--danger: #dc3545;
// Other Colors
--val-color--bg: #fafafa;
--val-color--bg--muted: #dee2e6;
--val-color--text: #212529;
--val-color--text--muted: color-mix(in srgb, var(--val-color--text) 50%, var(--val-color--bg));
--val-color--switch-off: #adb5bd;
--val-color--border: #212529;
}

View file

@ -0,0 +1,51 @@
.table-responsive {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.table {
width: 100%;
border-collapse: collapse;
text-align: left;
}
.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));
}
.table thead th {
border-bottom-width: 2px;
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;
color: var(--val-color--text--muted);
}

View file

@ -0,0 +1,55 @@
.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-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;
}

View file

@ -0,0 +1,12 @@
// 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;
}

522
assets/scss/intro.scss Normal file
View file

@ -0,0 +1,522 @@
// Styles of the `Intro` component.
//
// Compiled by `build.rs` into `static/css/intro.min.css`, apart from `basic.scss`: the component
// loads them on its own, whatever the active theme is.
:root {
--intro-bg-img: url('/pagetop/img/intro-header.jpg');
--intro-bg-img-set: image-set(url('/pagetop/img/intro-header.avif') type('image/avif'), url('/pagetop/img/intro-header.webp') type('image/webp'), var(--intro-bg-img) type('image/jpeg'));
--intro-bg-img-sm: url('/pagetop/img/intro-header-sm.jpg');
--intro-bg-img-sm-set: image-set(url('/pagetop/img/intro-header-sm.avif') type('image/avif'), url('/pagetop/img/intro-header-sm.webp') type('image/webp'), var(--intro-bg-img-sm) type('image/jpeg'));
--intro-bg-color: #7a430e;
--intro-bg-block-1: #ffb84b;
--intro-bg-block-2: #ffc66f;
--intro-bg-block-3: #ffd493;
--intro-bg-block-4: #ffe3b7;
--intro-bg-block-5: #fff1db;
--intro-bg-block-6: #ffffff;
--intro-color: #1a202c;
--intro-color-gray: #e8e3da;
--intro-color-link: #8b2500;
--intro-focus-outline: 2px solid var(--intro-color-link);
--intro-focus-outline-offset: 2px;
--intro-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
body {
overflow-x: clip;
}
// Media queries: 768px and 992px match the default `Md` and `Lg` breakpoints of the core.
.intro {
position: relative;
color: var(--intro-color);
background-color: var(--intro-bg-color);
left: 50%;
right: 50%;
margin-left: -50vw;
margin-right: -50vw;
width: 100vw;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.intro section {
position: relative;
text-align: center;
}
// Intro Header
.intro-header {
display: flex;
flex-direction: column-reverse;
width: 100%;
max-width: 80rem;
margin: 0 auto;
padding-bottom: 4rem;
background-image: var(--intro-bg-img-sm);
background-image: var(--intro-bg-img-sm-set);
background-position: top center;
background-position-y: -1rem;
background-size: contain;
background-repeat: no-repeat;
}
.intro-header-body {
padding: 0;
background: none;
}
.intro-header-title {
margin: 0 0 0 1.5rem;
text-align: left;
display: flex;
flex-direction: column;
box-sizing: border-box;
color: #dceefb;
padding: clamp(0rem, -5.4892rem + 23.4206vw, 9.5rem) 1rem 1rem;
font-size: clamp(1.5rem, 0.7231rem + 3.3149vw, 3.375rem);
font-style: italic;
font-weight: 600;
line-height: 110%;
text-shadow: 0 0.125rem 0.1875rem rgba(0, 0, 0, 0.3);
}
.intro-header-title > span {
background: linear-gradient(180deg, #ddff95 30%, #ffb84b 100%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-size: clamp(2.25rem, 1.3177rem + 3.9779vw, 6.5rem);
font-style: normal;
font-weight: 700;
line-height: 135%;
text-shadow: none;
}
.intro-header-img {
display: flex;
justify-content: flex-start;
text-align: right;
width: 100%;
}
.intro-header-mascot {
margin: 2rem;
flex-shrink: 1;
width: 280px;
height: 280px;
}
@media (width >= 992px) {
.intro-header {
background-image: var(--intro-bg-img);
background-image: var(--intro-bg-img-set);
}
.intro-header-title {
padding: 0 2rem 2.6rem 2rem;
}
.intro-header-img {
justify-content: flex-end;
}
.intro-header-mascot {
margin-right: 12rem;
}
}
// Intro Content
.intro-content {
height: auto;
margin-top: 1.6rem;
width: 100%;
}
.intro-content-body {
box-sizing: border-box;
max-width: 80rem;
margin-left: auto;
margin-right: auto;
}
.intro-content-body:before,
.intro-content-body:after {
content: '';
position: absolute;
left: 0;
right: 0;
background: linear-gradient(130deg, rgba(13, 44, 91, 0) 0%, #ddff95 77.4%);
margin: 0 -1.2rem;
filter: blur(2.75rem);
opacity: 0.8;
inset: unset;
}
.intro-content-body:before {
top: -1rem;
}
.intro-content-body:after {
bottom: -1rem;
}
@media (width < 768px) {
.intro-content-body {
margin-top: -9.8rem;
}
}
@media (width >= 992px) {
.intro-content {
margin-top: 0;
}
.intro-content-body {
margin-top: -5.7rem;
}
.intro-content-body:before,
.intro-content-body:after {
inset: 11.75rem;
}
}
.intro-button {
width: 100%;
margin: 0 auto;
}
.intro-button-link {
background: #7f1d1d;
background-image: linear-gradient(to bottom, rgba(255,0,0,0.8), rgba(255,255,255,0));
background-position: top left, center;
background-size: contain;
background-repeat: no-repeat;
border-radius: 0.75rem;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
font-size: 1.5rem;
line-height: 1.3;
text-decoration: none !important;
transition: transform 0.3s ease-in-out !important;
position: relative;
overflow: hidden;
min-width: 28.875rem;
min-height: 7.6875rem;
outline: none;
}
.intro-button-link:focus-visible {
outline: var(--intro-focus-outline);
outline-offset: var(--intro-focus-outline-offset);
}
.intro-button-link::before {
content: '';
position: absolute;
top: -13.125rem;
left: -10rem;
height: 26.25rem;
width: 26.25rem;
background: linear-gradient(135deg, #ec7bae 50.41%, #9600b8 70.41%);
transform: rotate(45deg);
transition: transform 0.3s ease-in-out;
z-index: 5;
}
.intro-button-text {
display: flex;
flex-direction: column;
flex: 1;
transition: all 0.5s ease-in-out;
position: relative;
z-index: 10;
padding: 1rem 1.5rem;
text-align: left;
color: white;
font-size: 1.65rem;
font-style: normal;
font-weight: 600;
line-height: 130.023%;
letter-spacing: 0.0075rem;
}
.intro-button-text strong {
font-size: 2.625rem;
font-weight: 600;
line-height: 130.023%;
letter-spacing: 0.013125rem;
}
.intro-button-link span {
position: absolute;
display: block;
pointer-events: none;
}
.intro-button-link span:nth-child(1) {
height: 8px;
width: 100%;
top: 0;
left: 0;
background: linear-gradient(to right, rgba(0, 0, 0, 0), #f6e58d);
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
transform: translateX(-100%);
animation: span1 8s linear infinite;
animation-delay: 1s;
}
@keyframes span1 {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.intro-button-link span:nth-child(2) {
width: 8px;
height: 100%;
top: 0;
right: 0;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0), #f6e58d);
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
transform: translateY(100%);
animation: span2 4s linear infinite;
animation-delay: 5s;
}
@keyframes span2 {
0% {
transform: translateY(-100%);
}
100% {
transform: translateY(100%);
}
}
.intro-button-link span:nth-child(3) {
height: 8px;
width: 100%;
bottom: 0;
right: 0;
background: linear-gradient(to left, rgba(0, 0, 0, 0), #f6e58d);
border-top-left-radius: 4px;
border-bottom-left-radius: 4px;
transform: translateX(100%);
animation: span3 8s linear infinite;
animation-delay: 7s;
}
@keyframes span3 {
0% {
transform: translateX(100%);
}
100% {
transform: translateX(-100%);
}
}
.intro-button-link:hover span {
animation-play-state: paused;
}
@media (width < 768px) {
.intro-header {
padding-bottom: 9rem;;
}
.intro-button-link {
height: 6.25rem;
min-width: auto;
border-radius: 0;
}
.intro-button-text {
display: inline;
padding-top: .5rem;
}
}
@media (width >= 768px) {
.intro-button {
position: absolute;
top: 0;
left: 50%;
transform: translate(-50%, -50%);
max-width: 29.375rem;
margin-bottom: 0;
}
.intro-button-link:hover {
transition: all .5s;
transform: rotate(-3deg) scale(1.125);
}
}
.intro-text {
z-index: 1;
width: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
align-items: center;
text-align: left;
margin-bottom: 0;
background: #fff;
position: relative;
}
.intro-text-body {
padding: 2.5rem 1.063rem 0.75rem;
overflow: hidden;
width: 100%;
}
.intro-text-lead {
width: 100%;
font-size: 1.5rem;
margin: 0 0 1.5rem;
}
@media (width >= 768px) {
.intro-button + .intro-text-body {
padding-top: 7rem;
}
}
@media (width >= 992px) {
.intro-header {
padding-bottom: 9rem;;
}
.intro-text,
.intro-text-body {
border-radius: 0.75rem;
}
.intro-text {
box-shadow: var(--intro-shadow);
max-width: min(60rem, 100vw - 4rem);
margin: 0 auto 6rem;
}
.intro-text-narrow {
max-width: min(50rem, 100vw - 4rem);
}
.intro-text-wide {
max-width: min(67rem, 100vw - 4rem);
}
.intro-text-full {
max-width: min(74rem, 100vw - 4rem);
}
.intro-text-body {
padding-left: 4.5rem;
padding-right: 4.5rem;
}
}
.intro-text-body .block {
position: relative;
}
.intro-text-body .block-title {
margin: 1em 0 .8em;
}
.intro-text-body .block-title span {
display: inline-block;
padding: 10px 30px 14px;
margin: 30px 20px 0;
background: white;
border: 5px solid;
border-radius: 30px;
box-shadow: 0 0 0 5px white, inset 0 0 0 5px white;
border-color: orangered;
transform: rotate(-3deg) translateY(-25%);
}
.intro-text-body .block-title:before {
content: "";
height: 5px;
position: absolute;
top: 50px;
left: -15%;
width: 130%;
z-index: -5;
background: orangered;
box-shadow: 0 0 0 5px white, 0 -10px 0 5px white;
transform: rotate(2deg) translateY(-50%);
transform-origin: top left;
}
.intro-text-body .block-title:after {
content: "";
height: 120%;
position: absolute;
top: 42px;
left: -25%;
width: 150%;
z-index: -10;
background: var(--intro-bg-block-1);
transform: rotate(2deg);
}
.intro-text-body .block:nth-of-type(6n+1) .block-title:after {
background: var(--intro-bg-block-1);
}
.intro-text-body .block:nth-of-type(6n+2) .block-title:after {
background: var(--intro-bg-block-2);
}
.intro-text-body .block:nth-of-type(6n+3) .block-title:after {
background: var(--intro-bg-block-3);
}
.intro-text-body .block:nth-of-type(6n+4) .block-title:after {
background: var(--intro-bg-block-4);
}
.intro-text-body .block:nth-of-type(6n+5) .block-title:after {
background: var(--intro-bg-block-5);
}
.intro-text-body .block:nth-of-type(6n+6) .block-title:after {
background: var(--intro-bg-block-6);
}
#intro-badges {
min-height: 3.2rem;
text-align: center;
visibility: hidden;
}
#intro-badges img {
margin-bottom: 0.25rem;
}
#intro-release:not([src]) {
display: none;
}
// Intro Footer
.intro-footer {
width: 100%;
background-color: black;
color: var(--intro-color-gray);
padding-bottom: 2rem;
}
.intro-footer-body {
display: flex;
justify-content: center;
flex-direction: column;
margin: 0 auto;
padding: 0 10.625rem;
max-width: 80rem;
}
.intro-footer-body a:visited {
color: var(--intro-color-gray);
}
// The footer has its own dark surface, so its links don't inherit the theme's link colors.
.intro-footer-links a {
color: currentColor;
text-decoration: underline;
transition: font-size 0.2s, text-decoration-color 0.2s;
}
.intro-footer-links a:focus-visible {
outline: var(--intro-focus-outline);
outline-offset: var(--intro-focus-outline-offset);
}
.intro-footer-links a:hover,
.intro-footer-links a:hover:visited {
text-decoration-color: var(--intro-color-link);
}
.intro-footer-logo,
.intro-footer-links {
display: flex;
justify-content: center;
width: 100%;
}
.intro-footer-logo {
max-height: 12.625rem;
}
.intro-footer-logo svg {
width: 100%;
}
.intro-footer-links {
gap: 1.875rem;
flex-wrap: wrap;
margin-top: 2rem;
}
@media (width < 768px) {
.intro-footer-logo {
display: none;
}
}
@media (width < 992px) {
.intro-footer-body {
padding: 0 1rem;
}
}

View file

@ -1,6 +1,34 @@
use pagetop_build::StaticFilesBundle;
use pagetop_build::{StaticFilesBundle, compile_scss, copy_dir, copy_file, minify_js};
fn main() -> std::io::Result<()> {
// Regenera `static/` desde cero sólo si hay cambios en `assets/`.
println!("cargo:rerun-if-changed=assets");
let _ = std::fs::remove_dir_all("static");
// Copia sin transformar. `assets/scss/` queda fuera porque sólo contiene fuentes SCSS.
copy_file("assets/banner.png", "static/banner.png")?;
copy_file("assets/favicon.ico", "static/favicon.ico")?;
copy_dir("assets/img", "static/img")?;
copy_dir("assets/js", "static/js")?;
// CSS: compila los estilos del tema Basic.
compile_scss("assets/scss/basic.scss", "static/css/basic.min.css")?;
// CSS: compila los estilos del componente `Intro`, independientes del tema.
compile_scss("assets/scss/intro.scss", "static/css/intro.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()

View file

@ -2,5 +2,5 @@ use pagetop::prelude::*;
#[pagetop::main]
async fn main() -> std::io::Result<()> {
Application::new().run()?.await
Application::new().await.run().await
}

46
examples/basic-nav.rs Normal file
View file

@ -0,0 +1,46 @@
use pagetop::prelude::*;
struct NavDemo;
#[async_trait]
impl Extension for NavDemo {
fn dependencies(&self) -> Vec<ExtensionRef> {
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
}

55
examples/basic-navbar.rs Normal file
View file

@ -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<ExtensionRef> {
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
}

View file

@ -1,33 +1,31 @@
use pagetop::prelude::*;
use pagetop_bootsier::theme::*;
include_locales!(LOC from "examples/locale");
struct FormControls;
#[async_trait]
impl Extension for FormControls {
fn dependencies(&self) -> Vec<ExtensionRef> {
vec![&pagetop_aliner::Aliner, &pagetop_bootsier::Bootsier]
}
fn configure_router(&self, router: Router) -> Router {
router.route("/", web::get(form_controls))
router.route("/", web::get(form_controls).post(form_controls))
}
}
async fn form_controls(request: HttpRequest) -> Result<Markup, ErrorPage> {
Page::new(request)
.with_child(
Intro::default()
.with_opening(IntroOpening::Custom)
.with_title(L10n::t("title", &LOC))
.with_slogan(L10n::t("slogan", &LOC))
.with_button(None::<(L10n, FnPathByContext)>)
Intro::custom()
.with_title(Lc::t("title", &LOC))
.with_slogan(Lc::t("slogan", &LOC))
// Bloque 1: casillas, interruptores y botones de opción.
.with_child(
Block::new()
.with_title(L10n::t("block_selections", &LOC))
.with_title(Lc::t("block_selections", &LOC))
.with_child(
Form::new()
.with_id("form-selections")
@ -36,69 +34,69 @@ async fn form_controls(request: HttpRequest) -> Result<Markup, ErrorPage> {
// Casillas e interruptores (form::Checkbox).
.with_child(
form::Fieldset::new()
.with_legend(L10n::t("fieldset_checkbox", &LOC))
.with_description(L10n::t("desc_checkbox", &LOC))
.with_legend(Lc::t("fieldset_checkbox", &LOC))
.with_description(Lc::t("desc_checkbox", &LOC))
.with_child(
form::Checkbox::new()
.with_name("accept_terms")
.with_label(L10n::t("label_terms", &LOC))
.with_label(Lc::t("label_terms", &LOC))
.with_required(true),
)
.with_child(
form::Checkbox::new()
.with_name("accept_marketing")
.with_label(L10n::t("label_marketing", &LOC))
.with_label(Lc::t("label_marketing", &LOC))
.with_checked(true)
.with_inline(true),
)
.with_child(
form::Checkbox::new()
.with_name("newsletter")
.with_label(L10n::t("label_newsletter", &LOC))
.with_label(Lc::t("label_newsletter", &LOC))
.with_inline(true),
)
.with_child(
form::Checkbox::switch()
.with_name("notifications")
.with_label(L10n::t("label_notifications", &LOC))
.with_label(Lc::t("label_notifications", &LOC))
.with_checked(true)
.with_reverse(true),
)
.with_child(
form::Checkbox::switch()
.with_name("dark_mode")
.with_label(L10n::t("label_dark_mode", &LOC))
.with_label(Lc::t("label_dark_mode", &LOC))
.with_disabled(true),
),
)
// Grupo de casillas de verificación (form::check::Field).
.with_child(
form::Fieldset::new()
.with_legend(L10n::t("fieldset_checkgroup", &LOC))
.with_legend(Lc::t("fieldset_checkgroup", &LOC))
.with_child(
form::check::Field::new()
.with_name("interests")
.with_label(L10n::t("label_interests", &LOC))
.with_help_text(L10n::t("help_interests", &LOC))
.with_label(Lc::t("label_interests", &LOC))
.with_help_text(Lc::t("help_interests", &LOC))
.with_item(
form::check::Item::new(
"rust",
L10n::t("check_rust", &LOC),
Lc::t("check_rust", &LOC),
)
.with_checked(true),
)
.with_item(form::check::Item::new(
"web",
L10n::t("check_web", &LOC),
Lc::t("check_web", &LOC),
))
.with_item(form::check::Item::new(
"ai",
L10n::t("check_ai", &LOC),
Lc::t("check_ai", &LOC),
))
.with_item(
form::check::Item::new(
"games",
L10n::t("check_games", &LOC),
Lc::t("check_games", &LOC),
)
.with_disabled(true),
),
@ -107,60 +105,62 @@ async fn form_controls(request: HttpRequest) -> Result<Markup, ErrorPage> {
// Botones de opción (form::radio::Field).
.with_child(
form::Fieldset::new()
.with_legend(L10n::t("fieldset_radio", &LOC))
.with_legend(Lc::t("fieldset_radio", &LOC))
.with_child(
form::radio::Field::new()
.with_name("frequency")
.with_label(L10n::t("label_frequency", &LOC))
.with_label(Lc::t("label_frequency", &LOC))
.with_item(form::radio::Item::new(
"daily",
L10n::t("radio_daily", &LOC),
Lc::t("radio_daily", &LOC),
))
.with_item(
form::radio::Item::new(
"weekly",
L10n::t("radio_weekly", &LOC),
Lc::t("radio_weekly", &LOC),
)
.with_checked(true),
)
.with_item(form::radio::Item::new(
"monthly",
L10n::t("radio_monthly", &LOC),
Lc::t("radio_monthly", &LOC),
))
.with_item(
form::radio::Item::new(
"never",
L10n::t("radio_never", &LOC),
Lc::t("radio_never", &LOC),
)
.with_disabled(true),
),
),
)
// Campo oculto (form::Hidden).
.with_child(form::Hidden::field("origin", "form-selections"))
// Botonera de acciones.
.with_child(
form::Hidden::new()
.with_name("origin")
.with_value("form-selections"),
)
// Botones de acción.
Flex::new()
.with_wrap(flex::Behavior::Wrap)
.with_gap(align::Gap::Both(UnitValue::RelRem(0.5)))
.with_child(
Button::submit(L10n::t("btn_submit", &LOC))
.with_color(ButtonColor::Background(Color::Primary)),
Button::submit(Lc::t("btn_submit", &LOC))
.with_style(button::Style::Solid(Intent::Primary)),
)
.with_child(
Button::reset(L10n::t("btn_reset", &LOC))
.with_color(ButtonColor::Outline(Color::Secondary)),
Button::reset(Lc::t("btn_reset", &LOC)).with_style(
button::Style::Outline(Intent::Neutral),
),
)
.with_child(
Button::plain(L10n::t("btn_cancel", &LOC))
.with_color(ButtonColor::Link),
Button::plain(Lc::t("btn_cancel", &LOC))
.with_style(button::Style::Link),
),
),
),
)
// Bloque 2: campos de texto, multilínea y rango.
.with_child(
Block::new()
.with_title(L10n::t("block_text", &LOC))
.with_title(Lc::t("block_text", &LOC))
.with_child(
Form::new()
.with_id("form-text")
@ -169,22 +169,21 @@ async fn form_controls(request: HttpRequest) -> Result<Markup, ErrorPage> {
// Campos de texto (form::input::Field).
.with_child(
form::Fieldset::new()
.with_legend(L10n::t("fieldset_text", &LOC))
.with_legend(Lc::t("fieldset_text", &LOC))
.with_child(
form::input::Field::text()
.with_name("name")
.with_label(L10n::t("label_name", &LOC))
.with_placeholder(L10n::t("placeholder_name", &LOC))
.with_label(Lc::t("label_name", &LOC))
.with_placeholder(Lc::t("placeholder_name", &LOC))
.with_help_text(Lc::t("help_name", &LOC))
.with_required(true),
)
.with_child(
form::input::Field::email()
.with_name("email")
.with_label(L10n::t("label_email", &LOC))
.with_placeholder(L10n::t(
"placeholder_email",
&LOC,
))
.with_label(Lc::t("label_email", &LOC))
.with_placeholder(Lc::t("placeholder_email", &LOC))
.with_help_text(Lc::t("help_email", &LOC))
.with_autocomplete(
Some(form::Autocomplete::email()),
)
@ -193,7 +192,7 @@ async fn form_controls(request: HttpRequest) -> Result<Markup, ErrorPage> {
.with_child(
form::input::Field::password()
.with_name("password")
.with_label(L10n::t("label_password", &LOC))
.with_label(Lc::t("label_password", &LOC))
.with_autocomplete(Some(
form::Autocomplete::new_password(),
))
@ -202,23 +201,20 @@ async fn form_controls(request: HttpRequest) -> Result<Markup, ErrorPage> {
.with_child(
form::input::Field::telephone()
.with_name("phone")
.with_label(L10n::t("label_phone", &LOC))
.with_placeholder(L10n::t(
"placeholder_phone",
&LOC,
)),
.with_label(Lc::t("label_phone", &LOC))
.with_placeholder(Lc::t("placeholder_phone", &LOC)),
)
.with_child(
form::input::Field::url()
.with_name("website")
.with_label(L10n::t("label_url", &LOC))
.with_placeholder(L10n::t("placeholder_url", &LOC)),
.with_label(Lc::t("label_url", &LOC))
.with_placeholder(Lc::t("placeholder_url", &LOC)),
)
.with_child(
form::input::Field::search()
.with_name("search")
.with_label(L10n::t("label_search", &LOC))
.with_placeholder(L10n::t(
.with_label(Lc::t("label_search", &LOC))
.with_placeholder(Lc::t(
"placeholder_search",
&LOC,
)),
@ -227,112 +223,146 @@ async fn form_controls(request: HttpRequest) -> Result<Markup, ErrorPage> {
// Área de texto (form::Textarea).
.with_child(
form::Fieldset::new()
.with_legend(L10n::t("fieldset_textarea", &LOC))
.with_legend(Lc::t("fieldset_textarea", &LOC))
.with_child(
form::Textarea::new()
.with_name("comment")
.with_label(L10n::t("label_comment", &LOC))
.with_placeholder(L10n::t(
.with_label(Lc::t("label_comment", &LOC))
.with_placeholder(Lc::t(
"placeholder_comment",
&LOC,
))
.with_rows(Some(4))
.with_help_text(L10n::t("help_comment", &LOC)),
.with_help_text(Lc::t("help_comment", &LOC)),
),
)
// Control deslizante (form::Range).
.with_child(
form::Fieldset::new()
.with_legend(L10n::t("fieldset_range", &LOC))
.with_legend(Lc::t("fieldset_range", &LOC))
.with_child(
form::Range::new()
.with_name("rating")
.with_label(L10n::t("label_rating", &LOC))
.with_label(Lc::t("label_rating", &LOC))
.with_min(Some(1.0))
.with_max(Some(10.0))
.with_step(Some(1.0))
.with_value(Some(5.0))
.with_help_text(L10n::t("help_rating", &LOC)),
.with_help_text(Lc::t("help_rating", &LOC)),
),
)
// Campo oculto (form::Hidden).
.with_child(form::Hidden::field("origin", "form-text"))
// Botonera de acciones.
.with_child(
form::Hidden::new()
.with_name("origin")
.with_value("form-text"),
)
// Botones de acción.
Flex::new()
.with_wrap(flex::Behavior::Wrap)
.with_gap(align::Gap::Both(UnitValue::RelRem(0.5)))
.with_child(
Button::submit(L10n::t("btn_submit", &LOC))
.with_color(ButtonColor::Background(Color::Primary)),
Button::submit(Lc::t("btn_submit", &LOC))
.with_style(button::Style::Solid(Intent::Primary)),
)
.with_child(
Button::reset(L10n::t("btn_reset", &LOC))
.with_color(ButtonColor::Outline(Color::Secondary)),
Button::reset(Lc::t("btn_reset", &LOC)).with_style(
button::Style::Outline(Intent::Neutral),
),
)
.with_child(
Button::plain(L10n::t("btn_cancel", &LOC))
.with_color(ButtonColor::Link),
Button::plain(Lc::t("btn_cancel", &LOC))
.with_style(button::Style::Link),
),
),
),
)
// Bloque 3: listas de selección y etiquetas flotantes.
.with_child(
Block::new()
.with_title(L10n::t("block_lists", &LOC))
.with_title(
if global::SETTINGS.app.theme.eq_ignore_ascii_case("bootsier") {
Lc::t("block_lists_floating", &LOC)
} else {
Lc::t("block_lists", &LOC)
},
)
.with_child(form_lists()),
)
// Bloque 4: diálogo modal.
.with_child(
Form::new()
Block::new()
.with_title(Lc::t("block_dialog", &LOC))
.with_child(
Dialog::new()
.with_id("delete-confirm")
.with_title(Lc::t("dialog_delete_title", &LOC))
.with_child(Html::with(|cx| {
html! {
p { (Lc::t("dialog_delete_body", &LOC).using(cx)) }
}
}))
.with_footer(
Button::plain(Lc::t("btn_ok", &LOC))
.with_prop(PropsOp::set("data-dialog-dismiss", "modal"))
.with_style(button::Style::Solid(Intent::Primary)),
)
.with_footer(
Button::plain(Lc::t("btn_cancel", &LOC))
.with_prop(PropsOp::set("data-dialog-dismiss", "modal"))
.with_style(button::Style::Outline(Intent::Neutral)),
),
)
.with_child(
Button::plain(Lc::t("btn_delete", &LOC))
.with_prop(PropsOp::set("data-dialog-toggle", "modal"))
.with_prop(PropsOp::set("data-dialog-target", "#delete-confirm"))
.with_style(button::Style::Solid(Intent::Severe)),
),
),
)
.render()
.await
}
fn form_lists() -> Form {
let mut form = Form::new()
.with_id("form-lists")
.with_action("/")
.with_method(form::Method::Post)
// Listas de selección (form::select::Field).
.with_child(
form::Fieldset::new()
.with_legend(L10n::t("fieldset_select", &LOC))
.with_legend(Lc::t("fieldset_select", &LOC))
.with_child(
form::select::Field::new()
.with_name("language")
.with_label(L10n::t("label_language", &LOC))
.with_label(Lc::t("label_language", &LOC))
.with_item(
form::select::Item::new(
"",
L10n::t("select_choose", &LOC),
)
form::select::Item::new("", Lc::t("select_choose", &LOC))
.with_selected(true),
)
.with_group(
form::select::Group::new(L10n::t(
"select_group_europe",
&LOC,
))
form::select::Group::new(Lc::t("select_group_europe", &LOC))
.with_item(form::select::Item::new(
"es",
L10n::t("select_spanish", &LOC),
Lc::t("select_spanish", &LOC),
))
.with_item(form::select::Item::new(
"fr",
L10n::t("select_french", &LOC),
Lc::t("select_french", &LOC),
)),
)
.with_group(
form::select::Group::new(L10n::t(
"select_group_americas",
&LOC,
))
form::select::Group::new(Lc::t("select_group_americas", &LOC))
.with_item(form::select::Item::new(
"en",
L10n::t("select_english", &LOC),
Lc::t("select_english", &LOC),
))
.with_item(form::select::Item::new(
"pt",
L10n::t("select_portuguese", &LOC),
Lc::t("select_portuguese", &LOC),
)),
)
.with_item(
form::select::Item::new(
"xx",
L10n::t("select_disabled", &LOC),
)
form::select::Item::new("xx", Lc::t("select_disabled", &LOC))
.with_disabled(true),
)
.with_required(true),
@ -340,117 +370,86 @@ async fn form_controls(request: HttpRequest) -> Result<Markup, ErrorPage> {
.with_child(
form::select::Field::new()
.with_name("technologies")
.with_label(L10n::t("label_technologies", &LOC))
.with_label(Lc::t("label_technologies", &LOC))
.with_item(
form::select::Item::new(
"rust",
L10n::n("Rust"),
)
.with_selected(true),
form::select::Item::new("rust", Lc::n("Rust")).with_selected(true),
)
.with_item(
form::select::Item::new(
"python",
L10n::n("Python"),
form::select::Item::new("python", Lc::n("Python")).with_selected(true),
)
.with_selected(true),
)
.with_item(form::select::Item::new(
"javascript",
L10n::n("JavaScript"),
))
.with_item(form::select::Item::new(
"go",
L10n::n("Go"),
))
.with_item(form::select::Item::new(
"typescript",
L10n::n("TypeScript"),
))
.with_item(form::select::Item::new("javascript", Lc::n("JavaScript")))
.with_item(form::select::Item::new("go", Lc::n("Go")))
.with_item(form::select::Item::new("typescript", Lc::n("TypeScript")))
.with_multiple(true)
.with_rows(Some(4))
.with_help_text(L10n::t("help_technologies", &LOC)),
.with_help_text(Lc::t("help_technologies", &LOC)),
),
)
// Etiquetas flotantes.
.with_child(
);
// Etiquetas flotantes: solo disponibles con el tema Bootsier.
if global::SETTINGS.app.theme.eq_ignore_ascii_case("bootsier") {
form = form.with_child(
form::Fieldset::new()
.with_legend(L10n::t("fieldset_floating", &LOC))
.with_legend(Lc::t("fieldset_floating", &LOC))
.with_child(
form::input::Field::text()
.with_name("fl_name")
.with_label(L10n::t("label_name", &LOC))
.with_placeholder(L10n::t("placeholder_name", &LOC))
.with_label(Lc::t("label_name", &LOC))
.with_placeholder(Lc::t("placeholder_name", &LOC))
.with_floating_label(true)
.with_required(true),
)
.with_child(
form::Textarea::new()
.with_name("fl_comment")
.with_label(L10n::t("label_comment", &LOC))
.with_placeholder(L10n::t(
"placeholder_comment",
&LOC,
))
.with_label(Lc::t("label_comment", &LOC))
.with_placeholder(Lc::t("placeholder_comment", &LOC))
.with_floating_label(true),
)
.with_child(
form::select::Field::new()
.with_name("fl_country")
.with_label(L10n::t("label_country", &LOC))
.with_label(Lc::t("label_country", &LOC))
.with_item(
form::select::Item::new(
"",
L10n::t("select_choose", &LOC),
)
form::select::Item::new("", Lc::t("select_choose", &LOC))
.with_selected(true),
)
.with_item(form::select::Item::new(
"de",
L10n::t("select_germany", &LOC),
))
.with_item(form::select::Item::new(
"es",
L10n::t("select_spain", &LOC),
))
.with_item(form::select::Item::new(
"fr",
L10n::t("select_france", &LOC),
))
.with_item(form::select::Item::new("de", Lc::t("select_germany", &LOC)))
.with_item(form::select::Item::new("es", Lc::t("select_spain", &LOC)))
.with_item(form::select::Item::new("fr", Lc::t("select_france", &LOC)))
.with_item(form::select::Item::new(
"pt",
L10n::t("select_portugal", &LOC),
Lc::t("select_portugal", &LOC),
))
.with_floating_label(true)
.with_required(true),
),
)
);
}
form
// Campo oculto (form::Hidden).
.with_child(form::Hidden::field("origin", "form-lists"))
// Botonera de acciones.
.with_child(
form::Hidden::new()
.with_name("origin")
.with_value("form-lists"),
)
// Botones de acción.
Flex::new()
.with_wrap(flex::Behavior::Wrap)
.with_gap(align::Gap::Both(UnitValue::RelRem(0.5)))
.with_child(
Button::submit(L10n::t("btn_submit", &LOC))
.with_color(ButtonColor::Background(Color::Primary)),
Button::submit(Lc::t("btn_submit", &LOC))
.with_style(button::Style::Solid(Intent::Primary)),
)
.with_child(
Button::reset(L10n::t("btn_reset", &LOC))
.with_color(ButtonColor::Outline(Color::Secondary)),
Button::reset(Lc::t("btn_reset", &LOC))
.with_style(button::Style::Outline(Intent::Neutral)),
)
.with_child(
Button::plain(L10n::t("btn_cancel", &LOC))
.with_color(ButtonColor::Link),
),
),
Button::plain(Lc::t("btn_cancel", &LOC)).with_style(button::Style::Link),
),
)
.render()
}
#[pagetop::main]
async fn main() -> std::io::Result<()> {
Application::prepare(&FormControls).run()?.await
Application::prepare(&FormControls).await.run().await
}

View file

@ -2,6 +2,7 @@ use pagetop::prelude::*;
struct HelloName;
#[async_trait]
impl Extension for HelloName {
fn configure_router(&self, router: Router) -> Router {
router.route("/hello/{name}", web::get(hello_name))
@ -19,9 +20,10 @@ async fn hello_name(
}
}))
.render()
.await
}
#[pagetop::main]
async fn main() -> std::io::Result<()> {
Application::prepare(&HelloName).run()?.await
Application::prepare(&HelloName).await.run().await
}

View file

@ -2,6 +2,7 @@ use pagetop::prelude::*;
struct HelloWorld;
#[async_trait]
impl Extension for HelloWorld {
fn configure_router(&self, router: Router) -> Router {
router.route("/", web::get(hello_world))
@ -16,9 +17,10 @@ async fn hello_world(request: HttpRequest) -> Result<Markup, ErrorPage> {
}
}))
.render()
.await
}
#[pagetop::main]
async fn main() -> std::io::Result<()> {
Application::prepare(&HelloWorld).run()?.await
Application::prepare(&HelloWorld).await.run().await
}

View file

@ -4,6 +4,7 @@ include_locales!(LOC from "examples/locale");
struct IntroColors;
#[async_trait]
impl Extension for IntroColors {
fn configure_router(&self, router: Router) -> Router {
router.route("/", web::get(intro_colors))
@ -13,70 +14,69 @@ impl Extension for IntroColors {
async fn intro_colors(request: HttpRequest) -> Result<Markup, ErrorPage> {
Page::new(request)
.with_child(
Intro::default()
.with_opening(IntroOpening::Custom)
.with_title(L10n::n("PageTop"))
.with_slogan(L10n::t("colors_slogan", &LOC))
.with_button(None::<(L10n, FnPathByContext)>)
Intro::custom()
.with_title(Lc::n("PageTop"))
.with_slogan(Lc::t("colors_slogan", &LOC))
.with_child(
Block::new()
.with_title(L10n::t("colors_block", &LOC).with_arg("n", "1"))
.with_title(Lc::t("colors_block", &LOC).with_arg("n", "1"))
.with_child(Html::with(|cx| {
html! {
p { (L10n::t("colors_val_1", &LOC).using(cx)) }
p { (Lc::t("colors_val_1", &LOC).using(cx)) }
}
})),
)
.with_child(
Block::new()
.with_title(L10n::t("colors_block", &LOC).with_arg("n", "2"))
.with_title(Lc::t("colors_block", &LOC).with_arg("n", "2"))
.with_child(Html::with(|cx| {
html! {
p { (L10n::t("colors_val_2", &LOC).using(cx)) }
p { (Lc::t("colors_val_2", &LOC).using(cx)) }
}
})),
)
.with_child(
Block::new()
.with_title(L10n::t("colors_block", &LOC).with_arg("n", "3"))
.with_title(Lc::t("colors_block", &LOC).with_arg("n", "3"))
.with_child(Html::with(|cx| {
html! {
p { (L10n::t("colors_val_3", &LOC).using(cx)) }
p { (Lc::t("colors_val_3", &LOC).using(cx)) }
}
})),
)
.with_child(
Block::new()
.with_title(L10n::t("colors_block", &LOC).with_arg("n", "4"))
.with_title(Lc::t("colors_block", &LOC).with_arg("n", "4"))
.with_child(Html::with(|cx| {
html! {
p { (L10n::t("colors_val_4", &LOC).using(cx)) }
p { (Lc::t("colors_val_4", &LOC).using(cx)) }
}
})),
)
.with_child(
Block::new()
.with_title(L10n::t("colors_block", &LOC).with_arg("n", "5"))
.with_title(Lc::t("colors_block", &LOC).with_arg("n", "5"))
.with_child(Html::with(|cx| {
html! {
p { (L10n::t("colors_val_5", &LOC).using(cx)) }
p { (Lc::t("colors_val_5", &LOC).using(cx)) }
}
})),
)
.with_child(
Block::new()
.with_title(L10n::t("colors_block", &LOC).with_arg("n", "6"))
.with_title(Lc::t("colors_block", &LOC).with_arg("n", "6"))
.with_child(Html::with(|cx| {
html! {
p { (L10n::t("colors_val_6", &LOC).using(cx)) }
p { (Lc::t("colors_val_6", &LOC).using(cx)) }
}
})),
),
)
.render()
.await
}
#[pagetop::main]
async fn main() -> std::io::Result<()> {
Application::prepare(&IntroColors).run()?.await
Application::prepare(&IntroColors).await.run().await
}

270
examples/intro-datetime.rs Normal file
View file

@ -0,0 +1,270 @@
use pagetop::prelude::*;
include_locales!(LOC from "examples/locale");
struct IntroDatetime;
#[async_trait]
impl Extension for IntroDatetime {
fn configure_router(&self, router: Router) -> Router {
router.route("/", web::get(intro_datetime))
}
}
async fn intro_datetime(request: HttpRequest) -> Result<Markup, ErrorPage> {
Page::new(request)
.with_child(
Intro::custom()
.with_title(Lc::n("PageTop"))
.with_slogan(Lc::t("datetime_slogan", &LOC))
.with_child(world_block())
.with_child(formats_block())
.with_child(relative_block())
.with_child(since_until_block()),
)
.render()
.await
}
// Zonas IANA de la demostración, con la clave de su etiqueta traducible.
const ZONES: [(&str, &str); 4] = [
("datetime_zone_utc", "UTC"),
("datetime_zone_madrid", "Europe/Madrid"),
("datetime_zone_mexico", "America/Mexico_City"),
("datetime_zone_tokyo", "Asia/Tokyo"),
];
// El mismo instante (`Utc::now()`) mostrado en cada zona de `ZONES`: fecha y hora combinadas
// (`DateFormat::Medium` + `TimeFormat::Short`) y su equivalente ISO 8601, con el offset propio de
// cada zona.
fn world_block() -> Block {
Block::new()
.with_title(Lc::t("datetime_block_world", &LOC))
.with_child(Html::with(|cx| {
let now = Utc::now();
let rows: Vec<(Lc, String, String)> = ZONES
.into_iter()
.map(|(label_key, zone)| {
let tz: Tz = zone.parse().expect("valid IANA timezone");
let zone_cx = Context::default().with_langid(cx).with_timezone(tz);
(
Lc::t(label_key, &LOC),
zone_cx.format_datetime(now, DateFormat::Medium, TimeFormat::Short),
zone_cx.format_iso_datetime(now),
)
})
.collect();
html! {
ul {
@for (label, combined, iso) in &rows {
li {
strong { (label.using(cx)) ": " } (combined)
" (ISO " code { (iso) } ")"
}
}
}
}
}))
}
// Combinaciones de `DateFormat`/`TimeFormat` sobre la zona horaria efectiva del visitante. Para
// añadir un nuevo formato basta con ampliar el array correspondiente.
fn formats_block() -> Block {
Block::new()
.with_title(Lc::t("datetime_block_formats", &LOC))
.with_child(Html::with(|cx| {
let now = Utc::now();
let today = now.with_timezone(&cx.timezone()).date_naive();
let dates: Vec<(&str, String)> = [
("Short", DateFormat::Short),
("Medium", DateFormat::Medium),
("Long", DateFormat::Long),
("Iso", DateFormat::Iso),
("Custom(\"%d.%m.%Y\")", DateFormat::Custom("%d.%m.%Y")),
]
.into_iter()
.map(|(label, format)| (label, cx.format_date(today, format)))
.collect();
let times: Vec<(&str, String)> = [
("Short", TimeFormat::Short),
("Long", TimeFormat::Long),
("Custom(\"%I:%M %p\")", TimeFormat::Custom("%I:%M %p")),
]
.into_iter()
.map(|(label, format)| (label, cx.format_time(now, format)))
.collect();
let combos: Vec<(&str, String)> = [
("Short + Short", DateFormat::Short, TimeFormat::Short),
("Medium + Short", DateFormat::Medium, TimeFormat::Short),
("Long + Long", DateFormat::Long, TimeFormat::Long),
("Long + Short", DateFormat::Long, TimeFormat::Short),
("Iso + Long", DateFormat::Iso, TimeFormat::Long),
]
.into_iter()
.map(|(label, date, time)| (label, cx.format_datetime(now, date, time)))
.collect();
html! {
h3 { (Lc::t("datetime_formats_date", &LOC).using(cx)) }
ul {
@for (label, value) in &dates {
li { code { (label) } ": " (value) }
}
}
h3 { (Lc::t("datetime_formats_time", &LOC).using(cx)) }
ul {
@for (label, value) in &times {
li { code { (label) } ": " (value) }
}
}
h3 { (Lc::t("datetime_formats_combos", &LOC).using(cx)) }
ul {
@for (label, value) in &combos {
li { code { (label) } ": " (value) }
}
}
p {
(Lc::t("datetime_formats_iso", &LOC).using(cx))
" " code { (cx.format_iso_datetime(now)) }
}
}
}))
}
// Casos ilustrativos de `RelativeFormat` sobre la zona horaria efectiva del visitante: hoy, un
// desplazamiento simple de días, uno con el componente de meses en cero, uno con los tres
// componentes y uno en el futuro -- siempre respecto al día actual. Añadir un caso nuevo es sólo
// ampliar `samples`. La primera columna muestra la fecha seleccionada (no una etiqueta fija),
// formateada con `DateFormat::Medium` en el idioma efectivo, para que se vea a qué fecha concreta
// corresponde cada resultado relativo.
fn relative_block() -> Block {
Block::new()
.with_title(Lc::t("datetime_block_relative", &LOC))
.with_child(Html::with(|cx| {
let today = Utc::now().date_naive();
// Desplazamientos de meses/años construidos con `checked_sub_months()` (mismo mecanismo
// que usa `RelativeFormat` por dentro), para que el resultado coincida exactamente con
// el criterio de cada caso.
let two_years_and_3_days_ago = today
.checked_sub_months(Months::new(24))
.expect("valid date")
- Duration::days(3);
let three_years_2_months_10_days_ago = today
.checked_sub_months(Months::new(38))
.expect("valid date")
- Duration::days(10);
let samples = [
today,
today - Duration::days(3),
two_years_and_3_days_ago,
three_years_2_months_10_days_ago,
today + Duration::days(5),
];
let rows: Vec<(String, String, String, String)> = samples
.into_iter()
.map(|date| {
let dt = at_noon(date);
(
cx.format_date(date, DateFormat::Medium),
cx.format_relative(dt, RelativeFormat::Short),
cx.format_relative(dt, RelativeFormat::Medium),
cx.format_relative(dt, RelativeFormat::Long),
)
})
.collect();
html! {
table style="width: 100%; border: 1px solid black;" {
thead {
tr {
th { (Lc::t("datetime_relative_col_date", &LOC).using(cx)) }
th { "Short" }
th { "Medium" }
th { "Long" }
}
}
tbody {
@for (date, short, medium, long) in &rows {
tr {
td { (date) }
td { (short) }
td { (medium) }
td { (long) }
}
}
}
}
}
}))
}
// Niveles de `DatePrecision` para `format_since()`/`format_until()`. Añadir un nivel nuevo es sólo
// ampliar este array.
const PRECISIONS: [(&str, DatePrecision); 3] = [
("Short", DatePrecision::Short),
("Medium", DatePrecision::Medium),
("Long", DatePrecision::Long),
];
// `format_since()`/`format_until()` sobre una única fecha de ejemplo: a diferencia de `DateFormat`,
// lo que cambia entre niveles no es el estilo, sino la propia precisión revelada (sólo el mes, mes
// y año, o fecha completa).
fn since_until_block() -> Block {
Block::new()
.with_title(Lc::t("datetime_block_since_until", &LOC))
.with_child(Html::with(|cx| {
let date = NaiveDate::from_ymd_opt(2026, 6, 3).unwrap();
let rows: Vec<(&str, String, String)> = PRECISIONS
.into_iter()
.map(|(label, precision)| {
(
label,
cx.format_since(date, precision),
cx.format_until(date, precision),
)
})
.collect();
html! {
table style="width: 100%; border: 1px solid black;" {
thead {
tr {
th { (Lc::t("datetime_since_until_col_precision", &LOC).using(cx)) }
th { (Lc::t("datetime_since_until_col_since", &LOC).using(cx)) }
th { (Lc::t("datetime_since_until_col_until", &LOC).using(cx)) }
}
}
tbody {
@for (label, since, until) in &rows {
tr {
td { (*label) }
td { (since) }
td { (until) }
}
}
}
}
}
}))
}
// Mediodía, para evitar que el redondeo horario de la conversión de zona horaria empuje la fecha
// civil resultante al día anterior o siguiente en zonas con un offset amplio.
fn at_noon(date: NaiveDate) -> DateTime<Utc> {
date.and_hms_opt(12, 0, 0)
.expect("noon is always a valid time")
.and_utc()
}
#[pagetop::main]
async fn main() -> std::io::Result<()> {
Application::prepare(&IntroDatetime).await.run().await
}

548
examples/intro-flex.rs Normal file
View file

@ -0,0 +1,548 @@
use pagetop::prelude::*;
include_locales!(LOC from "examples/locale");
struct IntroFlex;
#[async_trait]
impl Extension for IntroFlex {
fn dependencies(&self) -> Vec<ExtensionRef> {
vec![&pagetop_bootsier::Bootsier]
}
fn configure_router(&self, router: Router) -> Router {
router.route("/", web::get(intro_flex))
}
}
async fn intro_flex(request: HttpRequest) -> Result<Markup, ErrorPage> {
Page::new(request)
.with_assets(demo_box_styles())
.with_assets(demo_row_styles())
.with_child(
Intro::custom()
.with_title(Lc::n("PageTop"))
.with_slogan(Lc::t("flex_slogan", &LOC))
.with_child(direction_block())
.with_child(justify_block())
.with_child(align_block())
.with_child(align_self_block())
.with_child(align_content_block())
.with_child(grow_shrink_block())
.with_child(other_block()),
)
.render()
.await
}
fn direction_block() -> Block {
let mut block = Block::new().with_title(Lc::t("flex_block_title_direction", &LOC));
let direction_variants: [(&str, Flex, &str); 4] = [
("flex_title_direction_row", Flex::new(), "Flex::new()"),
(
"flex_title_direction_row_reverse",
Flex::new().with_direction(flex::Direction::RowReverse),
"Flex::new().with_direction(Direction::RowReverse)",
),
(
"flex_title_direction_column",
Flex::new().with_direction(flex::Direction::Column),
"Flex::new().with_direction(Direction::Column)",
),
(
"flex_title_direction_column_reverse",
Flex::new().with_direction(flex::Direction::ColumnReverse),
"Flex::new().with_direction(Direction::ColumnReverse)",
),
];
for (title_key, flex, code) in direction_variants {
block = block
.with_child(caption(Lc::t(title_key, &LOC), Lc::n(code)))
.with_child(
demo_row(flex)
.with_child(demo_box(flex_item("1")))
.with_child(demo_box(flex_item("2")))
.with_child(demo_box(flex_item("3"))),
);
}
block
}
fn justify_block() -> Block {
let mut block = Block::new().with_title(Lc::t("flex_block_title_justify", &LOC));
let justify_variants: [(&str, flex::ContentJustify, &str); 6] = [
(
"flex_title_justify_start",
flex::ContentJustify::Start,
"Flex::new().with_justify(ContentJustify::Start)",
),
(
"flex_title_justify_center",
flex::ContentJustify::Center,
"Flex::new().with_justify(ContentJustify::Center)",
),
(
"flex_title_justify_end",
flex::ContentJustify::End,
"Flex::new().with_justify(ContentJustify::End)",
),
(
"flex_title_justify_between",
flex::ContentJustify::SpaceBetween,
"Flex::new().with_justify(ContentJustify::SpaceBetween)",
),
(
"flex_title_justify_around",
flex::ContentJustify::SpaceAround,
"Flex::new().with_justify(ContentJustify::SpaceAround)",
),
(
"flex_title_justify_evenly",
flex::ContentJustify::SpaceEvenly,
"Flex::new().with_justify(ContentJustify::SpaceEvenly)",
),
];
for (title_key, justify, code) in justify_variants {
block = block
.with_child(caption(Lc::t(title_key, &LOC), Lc::n(code)))
.with_child(
demo_row(
Flex::new()
.with_justify(justify)
.with_gap(align::Gap::Both(UnitValue::RelRem(0.5))),
)
.with_child(demo_box(flex_item("1")))
.with_child(demo_box(flex_item("2")))
.with_child(demo_box(flex_item("3"))),
);
}
block
}
fn align_block() -> Block {
let mut block = Block::new().with_title(Lc::t("flex_block_title_align", &LOC));
let align_variants: [(&str, align::Items, &str); 4] = [
(
"flex_title_align_start",
align::Items::Start,
"Flex::new().with_align(align::Items::Start)",
),
(
"flex_title_align_center",
align::Items::Center,
"Flex::new().with_align(align::Items::Center)",
),
(
"flex_title_align_end",
align::Items::End,
"Flex::new().with_align(align::Items::End)",
),
(
"flex_title_align_stretch",
align::Items::Stretch,
"Flex::new().with_align(align::Items::Stretch)",
),
];
for (title_key, align, code) in align_variants {
block = block
.with_child(caption(Lc::t(title_key, &LOC), Lc::n(code)))
.with_child(
demo_row(
Flex::new()
.with_align(align)
.with_gap(align::Gap::Both(UnitValue::RelRem(0.5))),
)
.with_child(sized_box(Lc::t("flex_box_tall", &LOC), "2.5rem 1rem"))
.with_child(demo_box(Lc::t("flex_box_medium", &LOC)))
.with_child(sized_box(Lc::t("flex_box_short", &LOC), "0.15rem 1rem")),
);
}
block
.with_child(caption(
Lc::t("flex_title_align_baseline", &LOC),
Lc::n("Flex::new().with_align(align::Items::Baseline)"),
))
.with_child(
demo_row(
Flex::new()
.with_align(align::Items::Baseline)
.with_gap(align::Gap::Both(UnitValue::RelRem(0.5))),
)
.with_child(
sized_box(Lc::t("flex_box_tall", &LOC), "2.5rem 1rem")
.with_prop(PropsOp::add_style("font-size", "1.75rem")),
)
.with_child(demo_box(Lc::t("flex_box_medium", &LOC)))
.with_child(sized_box(Lc::t("flex_box_short", &LOC), "0.15rem 1rem")),
)
}
fn align_self_block() -> Block {
let mut block = Block::new().with_title(Lc::t("flex_block_title_align_self", &LOC));
let align_self_variants: [(&str, align::ItemSelf, &str); 4] = [
(
"flex_title_align_self_start",
align::ItemSelf::Start,
"FlexItem::new().with_align_self(align::ItemSelf::Start)",
),
(
"flex_title_align_self_end",
align::ItemSelf::End,
"FlexItem::new().with_align_self(align::ItemSelf::End)",
),
(
"flex_title_align_self_center",
align::ItemSelf::Center,
"FlexItem::new().with_align_self(align::ItemSelf::Center)",
),
(
"flex_title_align_self_stretch",
align::ItemSelf::Stretch,
"FlexItem::new().with_align_self(align::ItemSelf::Stretch)",
),
];
for (title_key, align_self, code) in align_self_variants {
block = block
.with_child(caption(Lc::t(title_key, &LOC), Lc::n(code)))
.with_child(
demo_row(
Flex::new()
.with_align(align::Items::Start)
.with_gap(align::Gap::Both(UnitValue::RelRem(0.5))),
)
.with_child(sized_box(Lc::t("flex_box_tall", &LOC), "2.5rem 1rem"))
.with_child(
demo_box(flex_item("1")).with_prop(FlexItem::new().with_align_self(align_self)),
)
.with_child(sized_box(Lc::t("flex_box_tall", &LOC), "2.5rem 1rem")),
);
}
block
.with_child(caption(
Lc::t("flex_title_align_self_baseline", &LOC),
Lc::n("FlexItem::new().with_align_self(align::ItemSelf::Baseline)"),
))
.with_child(
demo_row(
Flex::new()
.with_align(align::Items::Start)
.with_gap(align::Gap::Both(UnitValue::RelRem(0.5))),
)
.with_child(
sized_box(Lc::t("flex_box_tall", &LOC), "2.5rem 1rem")
.with_prop(PropsOp::add_style("font-size", "1.75rem")),
)
.with_child(
demo_box(flex_item("1"))
.with_prop(FlexItem::new().with_align_self(align::ItemSelf::Baseline)),
)
.with_child(sized_box(Lc::t("flex_box_tall", &LOC), "2.5rem 1rem")),
)
}
fn align_content_block() -> Block {
let mut block = Block::new().with_title(Lc::t("flex_block_title_align_content", &LOC));
let align_content_variants: [(&str, align::Content, &str); 7] = [
(
"flex_title_align_content_start",
align::Content::Start,
"Flex::new().with_wrap(Behavior::Wrap).with_align_content(align::Content::Start)",
),
(
"flex_title_align_content_end",
align::Content::End,
"Flex::new().with_wrap(Behavior::Wrap).with_align_content(align::Content::End)",
),
(
"flex_title_align_content_center",
align::Content::Center,
"Flex::new().with_wrap(Behavior::Wrap).with_align_content(align::Content::Center)",
),
(
"flex_title_align_content_between",
align::Content::SpaceBetween,
"Flex::new().with_wrap(Behavior::Wrap).with_align_content(align::Content::SpaceBetween)",
),
(
"flex_title_align_content_around",
align::Content::SpaceAround,
"Flex::new().with_wrap(Behavior::Wrap).with_align_content(align::Content::SpaceAround)",
),
(
"flex_title_align_content_evenly",
align::Content::SpaceEvenly,
"Flex::new().with_wrap(Behavior::Wrap).with_align_content(align::Content::SpaceEvenly)",
),
(
"flex_title_align_content_stretch",
align::Content::Stretch,
"Flex::new().with_wrap(Behavior::Wrap).with_align_content(align::Content::Stretch)",
),
];
for (title_key, align_content, code) in align_content_variants {
let mut row = demo_row(
Flex::new()
.with_wrap(flex::Behavior::Wrap)
.with_align_content(align_content)
.with_gap(align::Gap::Both(UnitValue::RelRem(0.5))),
)
.with_prop(PropsOp::add_style("max-width", "21rem"))
.with_prop(PropsOp::add_style("min-height", "11rem"));
for label in ["1", "2", "3", "4"] {
row = row.with_child(
sized_box(flex_item(label), "0.5rem 1rem")
.with_prop(PropsOp::add_style("width", "9rem")),
);
}
block = block
.with_child(caption(Lc::t(title_key, &LOC), Lc::n(code)))
.with_child(row);
}
block
}
fn grow_shrink_block() -> Block {
Block::new()
.with_title(Lc::t("flex_block_title_grow_shrink", &LOC))
.with_child(caption(
Lc::t("flex_title_grow", &LOC),
Lc::n("FlexItem::new().with_grow(flex::ItemGrow::Is1)"),
))
.with_child(
demo_row(Flex::new().with_gap(align::Gap::Both(UnitValue::RelRem(0.5))))
.with_child(demo_box(Lc::t("flex_box_fixed", &LOC)))
.with_child(
demo_box(Lc::t("flex_box_grows", &LOC))
.with_prop(FlexItem::new().with_grow(flex::ItemGrow::Is1)),
)
.with_child(demo_box(Lc::t("flex_box_fixed", &LOC))),
)
.with_child(caption(
Lc::t("flex_title_shrink", &LOC),
Lc::n("FlexItem::new().with_shrink(flex::ItemShrink::Is0)"),
))
.with_child(
demo_row(Flex::new().with_gap(align::Gap::Both(UnitValue::RelRem(0.5))))
.with_prop(PropsOp::add_style("max-width", "31rem"))
.with_child(
demo_box(flex_item("1")).with_prop(PropsOp::add_style("width", "10.5rem")),
)
.with_child(
demo_box(flex_item("2"))
.with_prop(PropsOp::add_style("width", "10.5rem"))
.with_prop(FlexItem::new().with_shrink(flex::ItemShrink::Is0)),
)
.with_child(
demo_box(flex_item("3")).with_prop(PropsOp::add_style("width", "10.5rem")),
),
)
}
fn other_block() -> Block {
let mut block = Block::new().with_title(Lc::t("flex_block_title_other", &LOC));
block = block
.with_child(caption(
Lc::t("flex_title_push_end", &LOC),
Lc::n("FlexItem::push_end()"),
))
.with_child(
demo_row(Flex::new().with_gap(align::Gap::Both(UnitValue::RelRem(0.5))))
.with_child(demo_box(Lc::t("flex_box_start_1", &LOC)))
.with_child(demo_box(Lc::t("flex_box_start_2", &LOC)))
.with_child(demo_box(Lc::t("flex_box_end", &LOC)).with_prop(FlexItem::push_end())),
);
let mut wrap_row = demo_row(
Flex::new()
.with_wrap(flex::Behavior::Wrap)
.with_align_content(align::Content::SpaceBetween)
.with_gap(align::Gap::Both(UnitValue::RelRem(0.5))),
)
.with_prop(PropsOp::add_style("max-width", "20rem"))
.with_prop(PropsOp::add_style("min-height", "11rem"));
for label in ["1", "2", "3", "4", "5", "6", "7", "8"] {
wrap_row = wrap_row.with_child(
sized_box(flex_item(label), "0.5rem 1rem")
.with_prop(PropsOp::add_style("width", "4rem")),
);
}
block
.with_child(caption(
Lc::t("flex_title_wrap", &LOC),
Lc::n(concat!(
"Flex::new()",
".with_wrap(Behavior::Wrap)",
".with_align_content(align::Content::SpaceBetween)",
)),
))
.with_child(wrap_row)
.with_child(caption(
Lc::t("flex_title_order", &LOC),
Lc::n("FlexItem::new().with_order(ItemOrder::First) / .with_order(ItemOrder::Last)"),
))
.with_child(
demo_row(Flex::new().with_gap(align::Gap::Both(UnitValue::RelRem(0.5))))
.with_child(
demo_box(Lc::n("A"))
.with_prop(FlexItem::new().with_order(flex::ItemOrder::Last)),
)
.with_child(demo_box(Lc::n("B")))
.with_child(demo_box(Lc::n("C")))
.with_child(demo_box(Lc::n("D")))
.with_child(
demo_box(Lc::n("E"))
.with_prop(FlexItem::new().with_order(flex::ItemOrder::First)),
),
)
.with_child(caption(
Lc::t("flex_title_gap_none", &LOC),
Lc::n("Flex::new() (align::Gap::None por defecto)"),
))
.with_child(
demo_row(Flex::new())
.with_child(demo_box(flex_item("1")))
.with_child(demo_box(flex_item("2")))
.with_child(demo_box(flex_item("3"))),
)
.with_child(caption(
Lc::t("flex_title_gap_some", &LOC),
Lc::n("Flex::new().with_gap(align::Gap::Both(UnitValue::RelRem(1.5)))"),
))
.with_child(
demo_row(Flex::new().with_gap(align::Gap::Both(UnitValue::RelRem(1.5))))
.with_child(demo_box(flex_item("1")))
.with_child(demo_box(flex_item("2")))
.with_child(demo_box(flex_item("3"))),
)
.with_child(caption(
Lc::t("flex_title_grid_thirds", &LOC),
Lc::n("FlexItem::new().with_size(flex::ItemSize::Percent33)"),
))
.with_child(
demo_row(Flex::new())
.with_child(
demo_box(Lc::n("1/3"))
.with_prop(FlexItem::new().with_size(flex::ItemSize::Percent33)),
)
.with_child(
demo_box(Lc::n("1/3"))
.with_prop(FlexItem::new().with_size(flex::ItemSize::Percent33)),
)
.with_child(
demo_box(Lc::n("1/3"))
.with_prop(FlexItem::new().with_size(flex::ItemSize::Percent33)),
),
)
.with_child(caption(
Lc::t("flex_title_grid_offset", &LOC),
Lc::n(concat!(
"FlexItem::new()",
".with_size(ItemSize::Percent50)",
".with_offset(ItemOffset::Percent25)",
)),
))
.with_child(
demo_row(Flex::new()).with_child(
demo_box(Lc::t("flex_box_half_centered", &LOC)).with_prop(
FlexItem::new()
.with_size(flex::ItemSize::Percent50)
.with_offset(flex::ItemOffset::Percent25),
),
),
)
.with_child(caption(
Lc::t("flex_title_toolbar", &LOC),
Lc::t("flex_desc_toolbar", &LOC),
))
.with_child(
demo_row(
Flex::new()
.with_align(align::Items::Center)
.with_gap(align::Gap::Both(UnitValue::RelRem(0.5))),
)
.with_child(demo_box(Lc::t("flex_box_file", &LOC)))
.with_child(demo_box(Lc::t("flex_box_edit", &LOC)))
.with_child(demo_box(Lc::t("flex_box_view", &LOC)))
.with_child(demo_box(Lc::t("flex_box_profile", &LOC)).with_prop(FlexItem::push_end()))
.with_child(demo_box(Lc::t("flex_box_logout", &LOC))),
)
}
// **< HELPERS >************************************************************************************
// Aspecto fijo de las cajas de muestra.
fn demo_box_styles() -> AssetsOp {
AssetsOp::add_responsive_styles(
None,
"flex-demo-box",
[
("background-color", "#0d6efd"),
("color", "#fff"),
("min-width", "3rem"),
("width", "auto"),
("max-width", "none"),
("margin", "0"),
("border-radius", "0.375rem"),
("text-align", "center"),
],
)
}
// Aspecto fijo de las filas de muestra.
fn demo_row_styles() -> AssetsOp {
AssetsOp::add_responsive_styles(
None,
"flex-demo-row",
[
("background-color", "#f1f3f5"),
("width", "100%"),
("max-width", "none"),
("margin", "0 0 1.5rem"),
("padding", "0.75rem"),
],
)
}
// Caja con fondo azul y relleno vertical configurable, para mostrar diferencias de altura.
fn sized_box(label: Lc, padding: &'static str) -> Container {
Container::new()
.with_prop(PropsOp::add_classes("flex-demo-box"))
.with_prop(PropsOp::add_style("padding", padding))
.with_child(Html::with(move |cx| html! { (label.using(cx)) }))
}
// Caja con el relleno vertical estandar del resto de ejemplos.
fn demo_box(label: Lc) -> Container {
sized_box(label, "0.5rem 1rem")
}
// Etiqueta "Flex item N" para las cajas que solo se distinguen por su posicion.
fn flex_item(n: impl Into<CowStr>) -> Lc {
Lc::t("flex_item_label", &LOC).with_arg("n", n)
}
// Fila de demostracion con fondo gris para visualizar los limites del propio contenedor flex.
fn demo_row(flex: Flex) -> Flex {
flex.with_prop(PropsOp::add_classes("flex-demo-row"))
}
// Titulo y fragmento de codigo que introducen cada demostracion.
fn caption(title: Lc, code: Lc) -> Html {
Html::with(move |cx| {
html! {
h3 { (title.using(cx)) }
p { code { (code.using(cx)) } }
}
})
}
#[pagetop::main]
async fn main() -> std::io::Result<()> {
Application::prepare(&IntroFlex).await.run().await
}

543
examples/intro-grid.rs Normal file
View file

@ -0,0 +1,543 @@
use pagetop::prelude::*;
include_locales!(LOC from "examples/locale");
struct IntroGrid;
#[async_trait]
impl Extension for IntroGrid {
fn dependencies(&self) -> Vec<ExtensionRef> {
vec![&pagetop_bootsier::Bootsier]
}
fn configure_router(&self, router: Router) -> Router {
router.route("/", web::get(intro_grid))
}
}
async fn intro_grid(request: HttpRequest) -> Result<Markup, ErrorPage> {
Page::new(request)
.with_assets(demo_box_styles())
.with_assets(demo_row_styles())
.with_child(
Intro::custom()
.with_title(Lc::n("PageTop"))
.with_slogan(Lc::t("grid_slogan", &LOC))
.with_child(columns_block())
.with_child(rows_gap_block())
.with_child(placement_block())
.with_child(alignment_block())
.with_child(content_alignment_block())
.with_child(item_alignment_block())
.with_child(auto_flow_block())
.with_child(auto_tracks_block())
.with_child(responsive_block())
.with_child(layout_block()),
)
.render()
.await
}
fn columns_block() -> Block {
Block::new()
.with_title(Lc::t("grid_block_title_columns", &LOC))
.with_child(caption(
Lc::t("grid_title_columns_thirds", &LOC),
Lc::n("Grid::new().with_columns(Tracks::repeat(3, AxisTrack::Fraction(1.0)))"),
))
.with_child(
demo_grid(
Grid::new()
.with_columns(grid::Tracks::repeat(3, grid::AxisTrack::Fraction(1.0)))
.with_gap(align::Gap::Both(UnitValue::RelRem(0.5))),
)
.with_child(demo_box(grid_box("1")))
.with_child(demo_box(grid_box("2")))
.with_child(demo_box(grid_box("3"))),
)
.with_child(caption(
Lc::t("grid_title_columns_sidebar", &LOC),
Lc::n(concat!(
"Grid::new().with_columns(Tracks::new()",
".with_track(AxisTrack::Fixed(UnitValue::Px(140)))",
".with_track(AxisTrack::Fraction(1.0)))",
)),
))
.with_child(
demo_grid(
Grid::new()
.with_columns(
grid::Tracks::new()
.with_track(grid::AxisTrack::Fixed(UnitValue::Px(140)))
.with_track(grid::AxisTrack::Fraction(1.0)),
)
.with_gap(align::Gap::Both(UnitValue::RelRem(0.5))),
)
.with_child(demo_box(Lc::t("grid_box_sidebar", &LOC)))
.with_child(demo_box(Lc::t("grid_box_content", &LOC))),
)
.with_child(caption(
Lc::t("grid_title_columns_min_max", &LOC),
Lc::n(concat!(
"Grid::new().with_columns(Tracks::new()",
".with_track(AxisTrack::MinContent)",
".with_track(AxisTrack::Fraction(1.0))",
".with_track(AxisTrack::MaxContent))",
)),
))
.with_child(
demo_grid(
Grid::new()
.with_columns(
grid::Tracks::new()
.with_track(grid::AxisTrack::MinContent)
.with_track(grid::AxisTrack::Fraction(1.0))
.with_track(grid::AxisTrack::MaxContent),
)
.with_gap(align::Gap::Both(UnitValue::RelRem(0.5))),
)
.with_child(demo_box(Lc::t("grid_box_min_content", &LOC)))
.with_child(demo_box(Lc::t("grid_box_fraction", &LOC)))
.with_child(demo_box(Lc::t("grid_box_max_content", &LOC))),
)
}
fn rows_gap_block() -> Block {
Block::new()
.with_title(Lc::t("grid_block_title_rows_gap", &LOC))
.with_child(caption(
Lc::t("grid_title_rows", &LOC),
Lc::n(concat!(
"Grid::new()",
".with_columns(Tracks::repeat(2, AxisTrack::Fraction(1.0)))",
".with_rows(Tracks::repeat(2, AxisTrack::Fixed(UnitValue::RelRem(3.0))))",
)),
))
.with_child(
demo_grid(
Grid::new()
.with_columns(grid::Tracks::repeat(2, grid::AxisTrack::Fraction(1.0)))
.with_rows(grid::Tracks::repeat(
2,
grid::AxisTrack::Fixed(UnitValue::RelRem(3.0)),
))
.with_gap(align::Gap::Both(UnitValue::RelRem(0.5))),
)
.with_child(demo_box(grid_box("1")))
.with_child(demo_box(grid_box("2")))
.with_child(demo_box(grid_box("3")))
.with_child(demo_box(grid_box("4"))),
)
.with_child(caption(
Lc::t("grid_title_gap_distinct", &LOC),
Lc::n(concat!(
"Grid::new().with_gap(align::Gap::Distinct {",
" row: UnitValue::RelRem(1.5), column: UnitValue::RelRem(0.25) })",
)),
))
.with_child(
demo_grid(
Grid::new()
.with_columns(grid::Tracks::repeat(3, grid::AxisTrack::Fraction(1.0)))
.with_gap(align::Gap::Distinct {
row: UnitValue::RelRem(1.5),
column: UnitValue::RelRem(0.25),
}),
)
.with_child(demo_box(grid_box("1")))
.with_child(demo_box(grid_box("2")))
.with_child(demo_box(grid_box("3")))
.with_child(demo_box(grid_box("4")))
.with_child(demo_box(grid_box("5")))
.with_child(demo_box(grid_box("6"))),
)
}
fn placement_block() -> Block {
Block::new()
.with_title(Lc::t("grid_block_title_placement", &LOC))
.with_child(caption(
Lc::t("grid_title_placement", &LOC),
Lc::n(concat!(
"GridItem::new().with_column(ItemPlacement::Span(2))",
" / .with_column(ItemPlacement::Range(2, 4))",
)),
))
.with_child(
demo_grid(
Grid::new()
.with_columns(grid::Tracks::repeat(4, grid::AxisTrack::Fraction(1.0)))
.with_gap(align::Gap::Both(UnitValue::RelRem(0.5))),
)
.with_child(demo_box(Lc::t("grid_box_plain", &LOC)))
.with_child(
demo_box(Lc::t("grid_box_span_2", &LOC))
.with_prop(GridItem::new().with_column(grid::ItemPlacement::Span(2))),
)
.with_child(demo_box(Lc::t("grid_box_plain", &LOC)))
.with_child(
demo_box(Lc::t("grid_box_range", &LOC)).with_prop(
GridItem::new()
.with_column(grid::ItemPlacement::Range(2, 4))
.with_row(grid::ItemPlacement::Line(2)),
),
),
)
}
fn alignment_block() -> Block {
let mut block = Block::new().with_title(Lc::t("grid_block_title_alignment", &LOC));
let variants: [(&str, grid::DefaultJustify, align::Items, &str); 4] = [
(
"grid_title_alignment_start",
grid::DefaultJustify::Start,
align::Items::Start,
concat!(
"Grid::new().with_justify_items(DefaultJustify::Start)",
".with_align_items(align::Items::Start)",
),
),
(
"grid_title_alignment_center",
grid::DefaultJustify::Center,
align::Items::Center,
concat!(
"Grid::new().with_justify_items(DefaultJustify::Center)",
".with_align_items(align::Items::Center)",
),
),
(
"grid_title_alignment_end",
grid::DefaultJustify::End,
align::Items::End,
concat!(
"Grid::new().with_justify_items(DefaultJustify::End)",
".with_align_items(align::Items::End)",
),
),
(
"grid_title_alignment_stretch",
grid::DefaultJustify::Stretch,
align::Items::Stretch,
concat!(
"Grid::new().with_justify_items(DefaultJustify::Stretch)",
".with_align_items(align::Items::Stretch)",
),
),
];
for (title_key, justify_items, align_items, code) in variants {
block = block
.with_child(caption(Lc::t(title_key, &LOC), Lc::n(code)))
.with_child(
demo_grid(
Grid::new()
.with_columns(grid::Tracks::repeat(
3,
grid::AxisTrack::Fixed(UnitValue::Px(96)),
))
.with_rows(grid::Tracks::repeat(
1,
grid::AxisTrack::Fixed(UnitValue::RelRem(4.5)),
))
.with_justify_items(justify_items)
.with_align_items(align_items)
.with_gap(align::Gap::Both(UnitValue::RelRem(0.5))),
)
.with_child(demo_box(grid_box("1")))
.with_child(demo_box(grid_box("2")))
.with_child(demo_box(grid_box("3"))),
);
}
block
}
fn content_alignment_block() -> Block {
let mut block = Block::new().with_title(Lc::t("grid_block_title_content_alignment", &LOC));
let variants: [(&str, grid::ContentJustify, &str); 4] = [
(
"grid_title_content_alignment_start",
grid::ContentJustify::Start,
"Grid::new().with_justify_content(ContentJustify::Start)",
),
(
"grid_title_content_alignment_center",
grid::ContentJustify::Center,
"Grid::new().with_justify_content(ContentJustify::Center)",
),
(
"grid_title_content_alignment_between",
grid::ContentJustify::SpaceBetween,
"Grid::new().with_justify_content(ContentJustify::SpaceBetween)",
),
(
"grid_title_content_alignment_evenly",
grid::ContentJustify::SpaceEvenly,
"Grid::new().with_justify_content(ContentJustify::SpaceEvenly)",
),
];
for (title_key, justify_content, code) in variants {
block = block
.with_child(caption(Lc::t(title_key, &LOC), Lc::n(code)))
.with_child(
demo_grid(
Grid::new()
.with_columns(grid::Tracks::repeat(
3,
grid::AxisTrack::Fixed(UnitValue::Px(72)),
))
.with_justify_content(justify_content)
.with_gap(align::Gap::Both(UnitValue::RelRem(0.5))),
)
.with_prop(PropsOp::add_style("max-width", "26rem"))
.with_child(demo_box(grid_box("1")))
.with_child(demo_box(grid_box("2")))
.with_child(demo_box(grid_box("3"))),
);
}
block
}
fn item_alignment_block() -> Block {
Block::new()
.with_title(Lc::t("grid_block_title_item_alignment", &LOC))
.with_child(caption(
Lc::t("grid_title_item_alignment", &LOC),
Lc::n(concat!(
"GridItem::new()",
".with_justify_self(ItemJustify::End)",
".with_align_self(align::ItemSelf::End)",
)),
))
.with_child(
demo_grid(
Grid::new()
.with_columns(grid::Tracks::repeat(
3,
grid::AxisTrack::Fixed(UnitValue::Px(96)),
))
.with_rows(grid::Tracks::repeat(
1,
grid::AxisTrack::Fixed(UnitValue::RelRem(4.5)),
))
.with_gap(align::Gap::Both(UnitValue::RelRem(0.5))),
)
.with_child(demo_box(grid_box("1")))
.with_child(
demo_box(Lc::t("grid_box_overridden", &LOC)).with_prop(
GridItem::new()
.with_justify_self(grid::ItemJustify::End)
.with_align_self(align::ItemSelf::End),
),
)
.with_child(demo_box(grid_box("3"))),
)
}
fn auto_flow_block() -> Block {
Block::new()
.with_title(Lc::t("grid_block_title_auto_flow", &LOC))
.with_child(caption(
Lc::t("grid_title_auto_flow_row", &LOC),
Lc::n("Grid::new().with_auto_flow(AutoFlow::Row) (por defecto)"),
))
.with_child(dense_demo(grid::AutoFlow::Row))
.with_child(caption(
Lc::t("grid_title_auto_flow_dense", &LOC),
Lc::n("Grid::new().with_auto_flow(AutoFlow::RowDense)"),
))
.with_child(dense_demo(grid::AutoFlow::RowDense))
}
fn dense_demo(auto_flow: grid::AutoFlow) -> Grid {
demo_grid(
Grid::new()
.with_columns(grid::Tracks::repeat(4, grid::AxisTrack::Fraction(1.0)))
.with_auto_flow(auto_flow)
.with_gap(align::Gap::Both(UnitValue::RelRem(0.5))),
)
.with_child(
demo_box(Lc::t("grid_box_span_2", &LOC))
.with_prop(GridItem::new().with_column(grid::ItemPlacement::Span(2))),
)
.with_child(
demo_box(Lc::t("grid_box_span_2", &LOC))
.with_prop(GridItem::new().with_column(grid::ItemPlacement::Span(2))),
)
.with_child(demo_box(grid_box("1")))
.with_child(demo_box(grid_box("2")))
.with_child(demo_box(grid_box("3")))
}
fn auto_tracks_block() -> Block {
Block::new()
.with_title(Lc::t("grid_block_title_auto_tracks", &LOC))
.with_child(caption(
Lc::t("grid_title_auto_tracks", &LOC),
Lc::n(concat!(
"Grid::new()",
".with_columns(Tracks::repeat(3, AxisTrack::Fraction(1.0)))",
".with_auto_rows(AxisTrack::Fixed(UnitValue::RelRem(3.0)))",
)),
))
.with_child(
demo_grid(
Grid::new()
.with_columns(grid::Tracks::repeat(3, grid::AxisTrack::Fraction(1.0)))
.with_auto_rows(grid::AxisTrack::Fixed(UnitValue::RelRem(3.0)))
.with_gap(align::Gap::Both(UnitValue::RelRem(0.5))),
)
.with_child(demo_box(grid_box("1")))
.with_child(demo_box(grid_box("2")))
.with_child(demo_box(grid_box("3")))
.with_child(demo_box(grid_box("4")))
.with_child(demo_box(grid_box("5")))
.with_child(demo_box(grid_box("6"))),
)
}
fn responsive_block() -> Block {
let mut row = demo_grid(
Grid::new()
.with_columns(grid::Tracks::repeat(1, grid::AxisTrack::Fraction(1.0)))
.with_columns_at(
Breakpoint::Sm,
grid::Tracks::repeat(2, grid::AxisTrack::Fraction(1.0)),
)
.with_columns_at(
Breakpoint::Md,
grid::Tracks::repeat(3, grid::AxisTrack::Fraction(1.0)),
)
.with_gap(align::Gap::Both(UnitValue::RelRem(0.5))),
);
for n in 1..=6 {
row = row.with_child(demo_box(card_label(n)));
}
Block::new()
.with_title(Lc::t("grid_block_title_responsive", &LOC))
.with_child(caption(
Lc::t("grid_title_responsive", &LOC),
Lc::n(concat!(
"Grid::new()",
".with_columns(Tracks::repeat(1, AxisTrack::Fraction(1.0)))",
".with_columns_at(Breakpoint::Sm, Tracks::repeat(2, AxisTrack::Fraction(1.0)))",
".with_columns_at(Breakpoint::Md, Tracks::repeat(3, AxisTrack::Fraction(1.0)))",
)),
))
.with_child(row)
}
fn layout_block() -> Block {
Block::new()
.with_title(Lc::t("grid_block_title_layout", &LOC))
.with_child(caption(
Lc::t("grid_title_layout", &LOC),
Lc::t("grid_desc_layout", &LOC),
))
.with_child(
demo_grid(
Grid::new()
.with_columns(
grid::Tracks::new()
.with_track(grid::AxisTrack::Fixed(UnitValue::Px(120)))
.with_track(grid::AxisTrack::Fraction(1.0))
.with_track(grid::AxisTrack::Fixed(UnitValue::Px(120))),
)
.with_rows(
grid::Tracks::new()
.with_track(grid::AxisTrack::Auto)
.with_track(grid::AxisTrack::Fraction(1.0))
.with_track(grid::AxisTrack::Auto),
)
.with_gap(align::Gap::Both(UnitValue::RelRem(0.5))),
)
.with_prop(PropsOp::add_style("min-height", "18rem"))
.with_child(
demo_box(Lc::t("grid_box_header", &LOC))
.with_prop(GridItem::new().with_column(grid::ItemPlacement::Span(3))),
)
.with_child(demo_box(Lc::t("grid_box_nav", &LOC)))
.with_child(demo_box(Lc::t("grid_box_content", &LOC)))
.with_child(demo_box(Lc::t("grid_box_aside", &LOC)))
.with_child(
demo_box(Lc::t("grid_box_footer", &LOC))
.with_prop(GridItem::new().with_column(grid::ItemPlacement::Span(3))),
),
)
}
// **< HELPERS >************************************************************************************
// Aspecto fijo de las cajas de muestra: esquinas rectas y borde claro, a diferencia de las cajas
// redondeadas de `intro-flex.rs`, para distinguir a simple vista los ejemplos de Grid de los de
// Flex.
fn demo_box_styles() -> AssetsOp {
AssetsOp::add_responsive_styles(
None,
"grid-demo-box",
[
("background-color", "#0d6efd"),
("color", "#fff"),
("border", "2px solid #6ea8fe"),
("min-width", "3rem"),
("width", "auto"),
("max-width", "none"),
("margin", "0"),
("text-align", "center"),
],
)
}
// Aspecto fijo de las filas de muestra.
fn demo_row_styles() -> AssetsOp {
AssetsOp::add_responsive_styles(
None,
"grid-demo-row",
[
("background-color", "#f1f3f5"),
("width", "100%"),
("max-width", "none"),
("margin", "0 0 1.5rem"),
("padding", "0.75rem"),
],
)
}
// Caja con el relleno vertical estandar del resto de ejemplos.
fn demo_box(label: Lc) -> Container {
Container::new()
.with_prop(PropsOp::add_classes("grid-demo-box"))
.with_prop(PropsOp::add_style("padding", "0.5rem 1rem"))
.with_child(Html::with(move |cx| html! { (label.using(cx)) }))
}
// Etiqueta "Box N" para las cajas que sólo se distinguen por su posición.
fn grid_box(n: impl Into<CowStr>) -> Lc {
Lc::t("grid_box_label", &LOC).with_arg("n", n)
}
// Etiqueta "Tarjeta N" para las cajas de la rejilla responsive.
fn card_label(n: usize) -> Lc {
Lc::t("grid_box_card", &LOC).with_arg("n", n.to_string())
}
// Rejilla de demostracion con fondo gris para visualizar los limites del propio contenedor grid.
fn demo_grid(grid: Grid) -> Grid {
grid.with_prop(PropsOp::add_classes("grid-demo-row"))
}
// Titulo y fragmento de codigo que introducen cada demostracion.
fn caption(title: Lc, code: Lc) -> Html {
Html::with(move |cx| {
html! {
h3 { (title.using(cx)) }
p { code { (code.using(cx)) } }
}
})
}
#[pagetop::main]
async fn main() -> std::io::Result<()> {
Application::prepare(&IntroGrid).await.run().await
}

View file

@ -0,0 +1,263 @@
use pagetop::prelude::*;
include_locales!(LOC from "examples/locale");
struct IntroResponsive;
#[async_trait]
impl Extension for IntroResponsive {
fn dependencies(&self) -> Vec<ExtensionRef> {
vec![&pagetop_bootsier::Bootsier]
}
fn configure_router(&self, router: Router) -> Router {
router.route("/", web::get(intro_responsive))
}
}
async fn intro_responsive(request: HttpRequest) -> Result<Markup, ErrorPage> {
Page::new(request)
.with_assets(demo_box_styles())
.with_assets(demo_row_styles())
.with_assets(demo_code_styles())
.with_child(
Intro::custom()
.with_title(Lc::n("PageTop"))
.with_slogan(Lc::t("responsive_slogan", &LOC))
.with_child(Html::with(|cx| {
html! {
p class="intro-text-lead" {
(Lc::t("responsive_note", &LOC).using(cx))
}
}
}))
.with_child(activation_block())
.with_child(direction_block())
.with_child(grid_block())
.with_child(justify_align_block())
.with_child(order_block())
.with_child(gap_grow_block()),
)
.render()
.await
}
fn activation_block() -> Block {
Block::new()
.with_title(Lc::t("responsive_block_title_activation", &LOC))
.with_child(caption(
Lc::t("responsive_title_activation", &LOC),
Lc::n("Flex::at(Breakpoint::Md)"),
))
.with_child(
demo_row(Flex::at(Breakpoint::Md).with_gap(align::Gap::Both(UnitValue::RelRem(0.5))))
.with_child(demo_box(Lc::t("responsive_box_nav_home", &LOC)))
.with_child(demo_box(Lc::t("responsive_box_nav_products", &LOC)))
.with_child(demo_box(Lc::t("responsive_box_nav_about", &LOC)))
.with_child(demo_box(Lc::t("responsive_box_nav_contact", &LOC))),
)
}
fn direction_block() -> Block {
Block::new()
.with_title(Lc::t("responsive_block_title_direction", &LOC))
.with_child(caption(
Lc::t("responsive_title_direction", &LOC),
Lc::n(concat!(
"Flex::new()",
".with_direction(Direction::Column)",
".with_direction_at(Breakpoint::Md, Direction::RowReverse)",
)),
))
.with_child(
demo_row(
Flex::new()
.with_direction(flex::Direction::Column)
.with_direction_at(Breakpoint::Md, flex::Direction::RowReverse)
.with_gap(align::Gap::Both(UnitValue::RelRem(0.5))),
)
.with_child(sized_box(
Lc::t("responsive_box_image", &LOC),
"2.5rem 1rem",
))
.with_child(demo_box(Lc::t("responsive_box_text", &LOC))),
)
}
fn grid_block() -> Block {
let mut row = demo_row(Flex::new().with_wrap(flex::Behavior::Wrap));
for n in 1..=6 {
row = row.with_child(
Container::new()
.with_prop(PropsOp::add_style("padding", "0.25rem"))
.with_prop(
FlexItem::new()
.with_size(flex::ItemSize::Percent100)
.with_size_at(Breakpoint::Sm, flex::ItemSize::Percent50)
.with_size_at(Breakpoint::Md, flex::ItemSize::Percent33),
)
.with_child(demo_box(card_label(n))),
);
}
Block::new()
.with_title(Lc::t("responsive_block_title_grid", &LOC))
.with_child(caption(
Lc::t("responsive_title_grid", &LOC),
Lc::n(concat!(
"FlexItem::new()",
".with_size(ItemSize::Percent100)",
".with_size_at(Breakpoint::Sm, ItemSize::Percent50)",
".with_size_at(Breakpoint::Md, ItemSize::Percent33)",
)),
))
.with_child(row)
}
fn justify_align_block() -> Block {
Block::new()
.with_title(Lc::t("responsive_block_title_justify_align", &LOC))
.with_child(caption(
Lc::t("responsive_title_justify_align", &LOC),
Lc::n(concat!(
"Flex::new()",
".with_justify(ContentJustify::Center)",
".with_justify_at(Breakpoint::Md, ContentJustify::SpaceBetween)",
".with_align(align::Items::Center)",
)),
))
.with_child(
demo_row(
Flex::new()
.with_justify(flex::ContentJustify::Center)
.with_justify_at(Breakpoint::Md, flex::ContentJustify::SpaceBetween)
.with_align(align::Items::Center)
.with_gap(align::Gap::Both(UnitValue::RelRem(0.5))),
)
.with_child(demo_box(Lc::t("responsive_box_logo", &LOC)))
.with_child(demo_box(Lc::t("responsive_box_menu", &LOC))),
)
}
fn order_block() -> Block {
Block::new()
.with_title(Lc::t("responsive_block_title_order", &LOC))
.with_child(caption(
Lc::t("responsive_title_order", &LOC),
Lc::n(concat!(
"Flex::at(Breakpoint::Lg)",
" + FlexItem::new().with_order_at(Breakpoint::Lg, ItemOrder::First)",
)),
))
.with_child(
demo_row(Flex::at(Breakpoint::Lg).with_gap(align::Gap::Both(UnitValue::RelRem(0.5))))
.with_child(demo_box(Lc::t("responsive_box_content", &LOC)))
.with_child(demo_box(Lc::t("responsive_box_sidebar", &LOC)).with_prop(
FlexItem::new().with_order_at(Breakpoint::Lg, flex::ItemOrder::First),
)),
)
}
fn gap_grow_block() -> Block {
Block::new()
.with_title(Lc::t("responsive_block_title_gap_grow", &LOC))
.with_child(caption(
Lc::t("responsive_title_gap_grow", &LOC),
Lc::n(concat!(
"Flex::new()",
".with_gap(align::Gap::Both(RelRem(0.5)))",
".with_gap_at(Breakpoint::Md, Gap::Both(RelRem(1.5)))",
" + FlexItem::new().with_grow_at(Breakpoint::Md, ItemGrow::Is1)",
)),
))
.with_child(
demo_row(
Flex::new()
.with_gap(align::Gap::Both(UnitValue::RelRem(0.5)))
.with_gap_at(Breakpoint::Md, align::Gap::Both(UnitValue::RelRem(1.5))),
)
.with_child(demo_box(Lc::t("responsive_box_file", &LOC)))
.with_child(demo_box(Lc::t("responsive_box_edit", &LOC)))
.with_child(
demo_box(Lc::t("responsive_box_search", &LOC))
.with_prop(FlexItem::new().with_grow_at(Breakpoint::Md, flex::ItemGrow::Is1)),
),
)
}
// **< HELPERS >************************************************************************************
// Aspecto fijo de las cajas de muestra.
fn demo_box_styles() -> AssetsOp {
AssetsOp::add_responsive_styles(
None,
"flex-demo-box",
[
("background-color", "#0d6efd"),
("color", "#fff"),
("min-width", "3rem"),
("width", "auto"),
("max-width", "none"),
("margin", "0"),
("border-radius", "0.375rem"),
("text-align", "center"),
],
)
}
// Aspecto fijo de las filas de muestra.
fn demo_row_styles() -> AssetsOp {
AssetsOp::add_responsive_styles(
None,
"flex-demo-row",
[
("background-color", "#f1f3f5"),
("width", "100%"),
("max-width", "none"),
("margin", "0 0 1.5rem"),
("padding", "0.75rem"),
],
)
}
// Evita que los fragmentos de código largos desborden su contenedor.
fn demo_code_styles() -> AssetsOp {
AssetsOp::add_responsive_styles(None, "flex-demo-code", [("overflow-wrap", "anywhere")])
}
// Caja con fondo azul y relleno vertical configurable, para mostrar diferencias de altura.
fn sized_box(label: Lc, padding: &'static str) -> Container {
Container::new()
.with_prop(PropsOp::add_classes("flex-demo-box"))
.with_prop(PropsOp::add_style("padding", padding))
.with_child(Html::with(move |cx| html! { (label.using(cx)) }))
}
// Caja con el relleno vertical estandar del resto de ejemplos.
fn demo_box(label: Lc) -> Container {
sized_box(label, "0.5rem 1rem")
}
// Etiqueta "Tarjeta N" para las cajas de la rejilla responsive.
fn card_label(n: usize) -> Lc {
Lc::t("responsive_box_card", &LOC).with_arg("n", n.to_string())
}
// Fila de demostracion con fondo gris para visualizar los limites del propio contenedor flex.
fn demo_row(flex: Flex) -> Flex {
flex.with_prop(PropsOp::add_classes("flex-demo-row"))
}
// Titulo y fragmento de codigo que introducen cada demostracion.
fn caption(title: Lc, code: Lc) -> Html {
Html::with(move |cx| {
html! {
h3 { (title.using(cx)) }
p { code class="flex-demo-code" { (code.using(cx)) } }
}
})
}
#[pagetop::main]
async fn main() -> std::io::Result<()> {
Application::prepare(&IntroResponsive).await.run().await
}

247
examples/intro-spacing.rs Normal file
View file

@ -0,0 +1,247 @@
use pagetop::prelude::*;
include_locales!(LOC from "examples/locale");
struct IntroSpacing;
#[async_trait]
impl Extension for IntroSpacing {
fn dependencies(&self) -> Vec<ExtensionRef> {
vec![&pagetop_bootsier::Bootsier]
}
fn configure_router(&self, router: Router) -> Router {
router.route("/", web::get(intro_spacing))
}
}
async fn intro_spacing(request: HttpRequest) -> Result<Markup, ErrorPage> {
Page::new(request)
.with_assets(demo_box_styles())
.with_assets(demo_row_styles())
.with_assets(demo_code_styles())
.with_child(
Intro::custom()
.with_title(Lc::n("PageTop"))
.with_slogan(Lc::t("spacing_slogan", &LOC))
.with_child(Html::with(|cx| {
html! {
p class="intro-text-lead" {
(Lc::t("spacing_note", &LOC).using(cx))
}
}
}))
.with_child(padding_block())
.with_child(layout_block())
.with_child(responsive_block())
.with_child(combined_block()),
)
.render()
.await
}
fn padding_block() -> Block {
let mut block = Block::new().with_title(Lc::t("spacing_block_title_padding", &LOC));
let padding_variants: [(&str, UnitValue, &str); 3] = [
(
"spacing_title_uniform_small",
UnitValue::RelRem(0.5),
"Padding::new().with_all(UnitValue::RelRem(0.5))",
),
(
"spacing_title_uniform_medium",
UnitValue::RelRem(1.0),
"Padding::new().with_all(UnitValue::RelRem(1.0))",
),
(
"spacing_title_uniform_large",
UnitValue::RelRem(2.0),
"Padding::new().with_all(UnitValue::RelRem(2.0))",
),
];
for (title_key, size, code) in padding_variants {
block = block
.with_child(caption(Lc::t(title_key, &LOC), Lc::n(code)))
.with_child(
demo_row(Flex::new().with_gap(align::Gap::Both(UnitValue::RelRem(0.5))))
.with_child(demo_box(box_sample()).with_prop(Padding::new().with_all(size))),
);
}
block
.with_child(caption(
Lc::t("spacing_title_sides", &LOC),
Lc::n(concat!(
"Padding::new()",
".with_top(UnitValue::RelRem(0.25))",
".with_end(UnitValue::RelRem(2.5))",
".with_bottom(UnitValue::RelRem(1.5))",
".with_start(UnitValue::RelRem(0.5))",
)),
))
.with_child(
demo_row(Flex::new()).with_child(
demo_box(box_sample()).with_prop(
Padding::new()
.with_top(UnitValue::RelRem(0.25))
.with_end(UnitValue::RelRem(2.5))
.with_bottom(UnitValue::RelRem(1.5))
.with_start(UnitValue::RelRem(0.5)),
),
),
)
}
fn layout_block() -> Block {
Block::new()
.with_title(Lc::t("spacing_block_title_layout", &LOC))
.with_child(caption(
Lc::t("spacing_title_margin_gap", &LOC),
Lc::n("Margin::new().with_x(UnitValue::RelRem(1.0))"),
))
.with_child(
demo_row(Flex::new())
.with_child(demo_box(box_sample()))
.with_child(
demo_box(box_sample()).with_prop(Margin::new().with_x(UnitValue::RelRem(1.0))),
)
.with_child(demo_box(box_sample())),
)
.with_child(caption(
Lc::t("spacing_title_margin_center", &LOC),
Lc::n("Margin::new().with_x(UnitValue::Auto)"),
))
.with_child(
demo_row(Flex::new()).with_child(
demo_box(box_sample())
.with_prop(Margin::new().with_x(UnitValue::Auto))
.with_prop(
FlexItem::new().with_size(flex::ItemSize::Custom(UnitValue::RelRem(8.0))),
),
),
)
}
fn responsive_block() -> Block {
Block::new()
.with_title(Lc::t("spacing_block_title_responsive", &LOC))
.with_child(caption(
Lc::t("spacing_title_responsive", &LOC),
Lc::n(concat!(
"Padding::new()",
".with_all(UnitValue::RelRem(0.5))",
".with_all_at(Breakpoint::Md, UnitValue::RelRem(2.0))",
".with_all_at(Breakpoint::Lg, UnitValue::RelRem(4.0))",
)),
))
.with_child(
demo_row(Flex::new()).with_child(
demo_box(box_sample()).with_prop(
Padding::new()
.with_all(UnitValue::RelRem(0.5))
.with_all_at(Breakpoint::Md, UnitValue::RelRem(2.0))
.with_all_at(Breakpoint::Lg, UnitValue::RelRem(4.0)),
),
),
)
}
fn combined_block() -> Block {
Block::new()
.with_title(Lc::t("spacing_block_title_combined", &LOC))
.with_child(caption(
Lc::t("spacing_title_combined", &LOC),
Lc::n(concat!(
"Container::new()",
".with_prop(Margin::new().with_y(UnitValue::RelRem(1.0)))",
".with_prop(Padding::new().with_all(UnitValue::RelRem(1.5)))",
)),
))
.with_child(
demo_row(Flex::new()).with_child(
Container::new()
.with_prop(PropsOp::add_classes("spacing-demo-box"))
.with_prop(Margin::new().with_y(UnitValue::RelRem(1.0)))
.with_prop(Padding::new().with_all(UnitValue::RelRem(1.5)))
.with_child(
Button::plain(Lc::t("spacing_box_card_button", &LOC))
.with_style(button::Style::Solid(Intent::Warning)),
),
),
)
}
// **< HELPERS >************************************************************************************
// Caja de muestra sin relleno ni margen propios, para que sólo se vea el efecto de `Margin`/
// `Padding` aplicado en cada demostración.
fn demo_box_styles() -> AssetsOp {
AssetsOp::add_responsive_styles(
None,
"spacing-demo-box",
[
("background-color", "#0d6efd"),
("color", "#fff"),
("min-width", "3rem"),
("width", "auto"),
("max-width", "none"),
("margin", "0"),
("padding", "0"),
("border-radius", "0.375rem"),
("text-align", "center"),
],
)
}
// Aspecto fijo de las filas de muestra.
fn demo_row_styles() -> AssetsOp {
AssetsOp::add_responsive_styles(
None,
"spacing-demo-row",
[
("background-color", "#f1f3f5"),
("width", "100%"),
("max-width", "none"),
("margin", "0 0 1.5rem"),
("padding", "0.75rem"),
],
)
}
// Evita que los fragmentos de código largos desborden su contenedor.
fn demo_code_styles() -> AssetsOp {
AssetsOp::add_responsive_styles(None, "spacing-demo-code", [("overflow-wrap", "anywhere")])
}
// Caja azul de muestra, sin relleno ni margen propios.
fn demo_box(label: Lc) -> Container {
Container::new()
.with_prop(PropsOp::add_classes("spacing-demo-box"))
.with_child(Html::with(move |cx| html! { (label.using(cx)) }))
}
// Etiqueta genérica reutilizada en la mayoría de cajas de muestra.
fn box_sample() -> Lc {
Lc::t("spacing_box_sample", &LOC)
}
// Fila de demostración con fondo gris para visualizar los límites de cada caja.
fn demo_row(flex: Flex) -> Flex {
flex.with_prop(PropsOp::add_classes("spacing-demo-row"))
}
// Título y fragmento de código que introducen cada demostración.
fn caption(title: Lc, code: Lc) -> Html {
Html::with(move |cx| {
html! {
h3 { (title.using(cx)) }
p { code class="spacing-demo-code" { (code.using(cx)) } }
}
})
}
#[pagetop::main]
async fn main() -> std::io::Result<()> {
Application::prepare(&IntroSpacing).await.run().await
}

View file

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

View file

@ -2,7 +2,9 @@ title = Form controls
slogan = Bootsier form components showcase
block_selections = Checkboxes, switches and radio buttons
block_text = Text fields, multiline and range
block_lists = Select lists and floating labels
block_lists = Selection lists
block_lists_floating = Select lists and floating labels
block_dialog = Modal dialog
fieldset_text = Text fields
label_name = Full name
@ -16,6 +18,8 @@ label_url = Website
placeholder_url = https://example.com
label_search = Search
placeholder_search = Search term...
help_name = Enter your full name as it appears on your ID.
help_email = We will only use your email to send important notifications.
fieldset_textarea = Multiline text
label_comment = Comment
@ -72,3 +76,8 @@ help_rating = From 1 (very poor) to 10 (excellent).
btn_submit = Submit
btn_reset = Reset
btn_cancel = Cancel
btn_ok = Ok
btn_delete = Delete record
dialog_delete_title = Delete record
dialog_delete_body = Are you sure you want to delete this record? This action cannot be undone.

View file

@ -0,0 +1,21 @@
datetime_slogan = Timezones and date/time formats
datetime_block_world = The same instant, around the world
datetime_zone_utc = UTC
datetime_zone_madrid = Madrid (Europe/Madrid)
datetime_zone_mexico = Mexico City (America/Mexico_City)
datetime_zone_tokyo = Tokyo (Asia/Tokyo)
datetime_block_formats = DateFormat / TimeFormat combinations
datetime_formats_date = DateFormat (date only)
datetime_formats_time = TimeFormat (time only)
datetime_formats_combos = format_datetime() combinations (date + time)
datetime_formats_iso = format_iso_datetime():
datetime_block_relative = RelativeFormat
datetime_relative_col_date = Date
datetime_block_since_until = DatePrecision (format_since / format_until)
datetime_since_until_col_precision = Precision
datetime_since_until_col_since = since
datetime_since_until_col_until = until

View file

@ -0,0 +1,64 @@
flex_slogan = Flexbox positioning
flex_block_title_direction = Direction
flex_block_title_justify = Justify content
flex_block_title_align = Align items
flex_block_title_align_self = Align self
flex_block_title_align_content = Align content
flex_block_title_grow_shrink = Grow and shrink
flex_block_title_other = Other examples
flex_title_direction_row = Row
flex_title_direction_row_reverse = Row reverse
flex_title_direction_column = Column
flex_title_direction_column_reverse = Column reverse
flex_title_justify_start = Justify content: start
flex_title_justify_center = Justify content: center
flex_title_justify_end = Justify content: end
flex_title_justify_between = Justify content: space between
flex_title_justify_around = Justify content: space around
flex_title_justify_evenly = Justify content: space evenly
flex_title_align_start = Align items: start
flex_title_align_center = Align items: center
flex_title_align_end = Align items: end
flex_title_align_stretch = Align items: stretch
flex_title_align_baseline = Align items: baseline
flex_title_align_self_start = Align self: start
flex_title_align_self_end = Align self: end
flex_title_align_self_center = Align self: center
flex_title_align_self_stretch = Align self: stretch
flex_title_align_self_baseline = Align self: baseline
flex_title_align_content_start = Align content: start
flex_title_align_content_end = Align content: end
flex_title_align_content_center = Align content: center
flex_title_align_content_between = Align content: space between
flex_title_align_content_around = Align content: space around
flex_title_align_content_evenly = Align content: space evenly
flex_title_align_content_stretch = Align content: stretch
flex_title_grow = Growth
flex_title_shrink = Shrink
flex_title_push_end = Automatic margin
flex_title_wrap = Line wrapping across multiple rows
flex_title_order = Visual order
flex_title_gap_none = Without spacing
flex_title_gap_some = With spacing
flex_title_grid_thirds = Column grid: three thirds
flex_title_grid_offset = Column grid: centered column with offset
flex_title_toolbar = Composite structure: toolbar
flex_desc_toolbar = FlexItem::push_end() on an intermediate item of the same Container with Flex
flex_item_label = Flex item { $n }
flex_box_tall = Tall
flex_box_medium = Medium
flex_box_short = Short
flex_box_fixed = Fixed
flex_box_grows = Grows to fill the space
flex_box_start_1 = Start 1
flex_box_start_2 = Start 2
flex_box_end = End
flex_box_half_centered = Half, centered
flex_box_file = File
flex_box_edit = Edit
flex_box_view = View
flex_box_profile = Profile
flex_box_logout = Sign out

View file

@ -0,0 +1,49 @@
grid_slogan = CSS Grid positioning
grid_block_title_columns = Columns
grid_block_title_rows_gap = Rows and gap
grid_block_title_placement = Item placement
grid_block_title_alignment = Align items
grid_block_title_content_alignment = Justify content
grid_block_title_item_alignment = Individual alignment
grid_block_title_auto_flow = Auto-placement algorithm
grid_block_title_auto_tracks = Implicit tracks
grid_block_title_responsive = Responsive grid
grid_block_title_layout = Composite structure: page layout
grid_title_columns_thirds = Three equal columns
grid_title_columns_sidebar = Fixed sidebar + flexible content
grid_title_columns_min_max = Min-content, fraction, max-content
grid_title_rows = Fixed-height rows
grid_title_gap_distinct = Different row and column spacing
grid_title_placement = Column spanning and explicit ranges
grid_title_alignment_start = Justify/align items: start
grid_title_alignment_center = Justify/align items: center
grid_title_alignment_end = Justify/align items: end
grid_title_alignment_stretch = Justify/align items: stretch
grid_title_content_alignment_start = Justify content: start
grid_title_content_alignment_center = Justify content: center
grid_title_content_alignment_between = Justify content: space between
grid_title_content_alignment_evenly = Justify content: space evenly
grid_title_item_alignment = Overriding the container's alignment on a single item
grid_title_auto_flow_row = Default flow, leaves a gap
grid_title_auto_flow_dense = Dense flow, fills the gap
grid_title_auto_tracks = Equal-height implicit rows
grid_title_responsive = One column on mobile, two on tablet, three on desktop
grid_title_layout = Header and footer spanning three columns, flexible content area
grid_desc_layout = Grid::new() with fixed side columns, a flexible center column and explicit spans
grid_box_label = Box { $n }
grid_box_card = Card { $n }
grid_box_sidebar = Sidebar
grid_box_content = Content
grid_box_min_content = Min-content
grid_box_fraction = 1fr
grid_box_max_content = Max-content
grid_box_plain = Plain
grid_box_span_2 = Spans 2 columns
grid_box_range = Columns 2 to 3
grid_box_overridden = Overridden
grid_box_header = Header
grid_box_nav = Navigation
grid_box_aside = Aside
grid_box_footer = Footer

View file

@ -0,0 +1,36 @@
responsive_slogan = Responsive behavior with Flex
responsive_note = Resize your browser window to see how the boxes below react. The breakpoints used are the usual Bootstrap ones: sm from 576px, md from 768px, lg from 992px.
responsive_block_title_activation = Flex activation at a breakpoint
responsive_block_title_direction = Direction change
responsive_block_title_grid = Column grid
responsive_block_title_justify_align = Justify and align
responsive_block_title_order = Visual order
responsive_block_title_gap_grow = Spacing and growth
responsive_title_activation = Stacked menu on mobile, in a row from md
responsive_title_direction = Image and text stacked on mobile; in a row, image on the right, from md
responsive_title_grid = From one column on mobile to three on desktop
responsive_title_justify_align = Centered header on mobile, spread out from md
responsive_title_order = Sidebar after the content on mobile, before it from lg
responsive_title_gap_grow = Growing spacing and a search box that only grows from md
responsive_box_nav_home = Home
responsive_box_nav_products = Products
responsive_box_nav_about = About
responsive_box_nav_contact = Contact
responsive_box_image = Image
responsive_box_text = Text
responsive_box_card = Card { $n }
responsive_box_logo = Logo
responsive_box_menu = Menu
responsive_box_content = Content
responsive_box_sidebar = Sidebar
responsive_box_file = File
responsive_box_edit = Edit
responsive_box_search = Search

View file

@ -0,0 +1,19 @@
spacing_slogan = Native margin and padding with Margin and Padding
spacing_note = Margin and Padding have nothing to do with Flexbox: they apply to any component, whether its container uses Flexbox or not. They are combined with Flex here only to lay out the sample boxes in a row.
spacing_block_title_padding = Padding
spacing_block_title_layout = Layout
spacing_block_title_responsive = Padding at a breakpoint
spacing_block_title_combined = Margin and padding combined
spacing_title_uniform_small = Small padding on all four sides
spacing_title_uniform_medium = Medium padding on all four sides
spacing_title_uniform_large = Large padding on all four sides
spacing_title_sides = A different value per side, including the logical start/end sides
spacing_title_margin_gap = The middle box's margin pushes its neighbours away, without using Gap
spacing_title_margin_center = An automatic margin on both sides centers the box in the free space
spacing_title_responsive = Padding grows from md onwards, and again from lg
spacing_title_combined = Outer margin above/below and inner padding on all four sides, on a component with real content
spacing_box_sample = Content
spacing_box_card_button = Accept

View file

@ -1,7 +1,30 @@
menus_slogan = Navbars and menus
menus_note = Narrow the browser window to see how the navbars with a toggle button collapse.
## Blocks
menus_block_title_menus = Menus and dropdowns
menus_block_title_brand = Brand
menus_block_title_content = Other content
menus_block_title_colors = Color schemes
menus_block_title_container = Containers
menus_block_title_position = Placement
menus_block_title_layout = Layout and toggle button
menus_block_title_expand = Expand breakpoint
menus_block_title_offcanvas = Content in a side panel (Offcanvas)
## Menus and dropdowns
menus_title_full = Menu with dropdown, headers, dividers and a second menu at the end
menus_item_home = Home
menus_item_label = Label
menus_item_link = Link
menus_item_blank = External link
menus_item_disabled = Disabled link
menus_item_action = Action
menus_item_another = Another action
menus_item_something = Something else
menus_test_title = Dropdown
@ -21,3 +44,66 @@ menus_plugin_cache = Rust Plugin Cache
menus_item_sign_up = Sign up
menus_item_login = Login
## Brand
menus_title_brand_link = Text only, as a link
menus_title_brand_span = Text only, without a link
menus_title_brand_image = Image only
menus_title_brand_both = Image and text
## Other content
menus_title_form_search = Search form next to the brand
menus_title_form_buttons = Buttons
menus_title_form_group = Input group
menus_title_text = Text
menus_title_brand_and_text = Brand and text
menus_title_nav_text = Menu and text
menus_search_label = Search
menus_search_button = Search
menus_button_main = Main button
menus_button_small = Smaller button
menus_group_placeholder = Username
menus_text_inline = Navbar text
## Color schemes
menus_title_color_dark = Dark background
menus_title_color_primary = Primary color with light text
menus_title_color_light = Light theme background
menus_title_color_custom = Custom color
## Containers
menus_title_container_full = Takes all the available width
menus_title_container_max = Inside a container with a maximum width
## Placement
menus_position_note = Navbars are anchored to the frame of each example, not to the window, so they don't cover the rest of the page. Scroll the frames with sticky navbars.
menus_title_position_fixed_top = Fixed top
menus_title_position_fixed_bottom = Fixed bottom
menus_title_position_sticky_top = Sticky top
menus_title_position_sticky_bottom = Sticky bottom
menus_filler = Page content. Fixed elements are taken out of the document flow, while sticky ones stay at the edge of the scrolling container.
## Layout
menus_title_layout_toggle = Toggle button only, no brand
menus_title_layout_brand_left = Brand on the left and toggle button on the right
menus_title_layout_brand_right = Toggle button on the left and brand on the right
menus_title_layout_brand_always = Always visible brand, no toggle button
## Expand breakpoint
menus_title_expand = Expands from { $name } ({ $width })
## Offcanvas
menus_offcanvas_title = Main menu
menus_title_offcanvas_start = Panel from the start, no brand
menus_title_offcanvas_brand_left = Brand on the left and panel from the end
menus_title_offcanvas_brand_right = Brand on the right and panel without backdrop
menus_title_offcanvas_dark = Dark navbar and panel, with a static backdrop

View file

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

View file

@ -2,7 +2,9 @@ title = Controles de formulario
slogan = Componentes Bootsier para formularios
block_selections = Casillas, interruptores y botones de opción
block_text = Campos de texto, multilínea y rango
block_lists = Listas de selección y etiquetas flotantes
block_lists = Listas de selección
block_lists_floating = Listas de selección y etiquetas flotantes
block_dialog = Diálogo modal
fieldset_text = Campos de texto
label_name = Nombre completo
@ -16,6 +18,8 @@ label_url = Sitio web
placeholder_url = https://ejemplo.com
label_search = Búsqueda
placeholder_search = Término de búsqueda...
help_name = Introduce tu nombre completo tal como aparece en tu documento de identidad.
help_email = Solo usaremos tu correo para enviarte notificaciones importantes.
fieldset_textarea = Texto multilínea
label_comment = Comentario
@ -72,3 +76,8 @@ help_rating = De 1 (muy malo) a 10 (excelente).
btn_submit = Enviar
btn_reset = Restablecer
btn_cancel = Cancelar
btn_ok = Aceptar
btn_delete = Eliminar registro
dialog_delete_title = Eliminar registro
dialog_delete_body = ¿Seguro que quieres eliminar este registro? Esta acción no se puede deshacer.

View file

@ -0,0 +1,21 @@
datetime_slogan = Zonas horarias y formatos de fecha y hora
datetime_block_world = El mismo instante, alrededor del mundo
datetime_zone_utc = UTC
datetime_zone_madrid = Madrid (Europe/Madrid)
datetime_zone_mexico = Ciudad de México (America/Mexico_City)
datetime_zone_tokyo = Tokio (Asia/Tokyo)
datetime_block_formats = Combinaciones de DateFormat / TimeFormat
datetime_formats_date = DateFormat (sólo fecha)
datetime_formats_time = TimeFormat (sólo hora)
datetime_formats_combos = Combinaciones de format_datetime() (fecha + hora)
datetime_formats_iso = format_iso_datetime():
datetime_block_relative = RelativeFormat
datetime_relative_col_date = Fecha
datetime_block_since_until = DatePrecision (format_since / format_until)
datetime_since_until_col_precision = Precisión
datetime_since_until_col_since = desde
datetime_since_until_col_until = hasta

View file

@ -0,0 +1,64 @@
flex_slogan = Posicionamiento con Flexbox
flex_block_title_direction = Dirección
flex_block_title_justify = Justificación de contenido
flex_block_title_align = Alineación de elementos
flex_block_title_align_self = Alineación individual
flex_block_title_align_content = Alineación de contenido
flex_block_title_grow_shrink = Crecimiento y reducción
flex_block_title_other = Otros ejemplos
flex_title_direction_row = Fila
flex_title_direction_row_reverse = Fila invertida
flex_title_direction_column = Columna
flex_title_direction_column_reverse = Columna invertida
flex_title_justify_start = Justificación: inicio
flex_title_justify_center = Justificación: centro
flex_title_justify_end = Justificación: final
flex_title_justify_between = Justificación: espacio entre elementos
flex_title_justify_around = Justificación: espacio alrededor de cada elemento
flex_title_justify_evenly = Justificación: espacio repartido a partes iguales
flex_title_align_start = Alineación: inicio
flex_title_align_center = Alineación: centro
flex_title_align_end = Alineación: final
flex_title_align_stretch = Alineación: estirar
flex_title_align_baseline = Alineación: línea base
flex_title_align_self_start = Alineación individual: inicio
flex_title_align_self_end = Alineación individual: final
flex_title_align_self_center = Alineación individual: centro
flex_title_align_self_stretch = Alineación individual: estirar
flex_title_align_self_baseline = Alineación individual: línea base
flex_title_align_content_start = Alineación de contenido: inicio
flex_title_align_content_end = Alineación de contenido: final
flex_title_align_content_center = Alineación de contenido: centro
flex_title_align_content_between = Alineación de contenido: espacio entre líneas
flex_title_align_content_around = Alineación de contenido: espacio alrededor de cada línea
flex_title_align_content_evenly = Alineación de contenido: espacio repartido a partes iguales
flex_title_align_content_stretch = Alineación de contenido: estirar
flex_title_grow = Crecimiento
flex_title_shrink = Reducción
flex_title_push_end = Margen automático
flex_title_wrap = Ajuste de línea con múltiples filas
flex_title_order = Orden visual
flex_title_gap_none = Sin espaciado
flex_title_gap_some = Con espaciado
flex_title_grid_thirds = Rejilla de columnas: tres tercios
flex_title_grid_offset = Rejilla de columnas: columna centrada con desplazamiento
flex_title_toolbar = Estructura compuesta: barra de herramientas
flex_desc_toolbar = FlexItem::push_end() sobre un elemento intermedio del propio Container con Flex
flex_item_label = Flex ítem { $n }
flex_box_tall = Alto
flex_box_medium = Medio
flex_box_short = Bajo
flex_box_fixed = Fijo
flex_box_grows = Crece para llenar el espacio
flex_box_start_1 = Inicio 1
flex_box_start_2 = Inicio 2
flex_box_end = Final
flex_box_half_centered = Mitad, centrada
flex_box_file = Archivo
flex_box_edit = Editar
flex_box_view = Ver
flex_box_profile = Perfil
flex_box_logout = Salir

View file

@ -0,0 +1,49 @@
grid_slogan = Posicionamiento con CSS Grid
grid_block_title_columns = Columnas
grid_block_title_rows_gap = Filas y espaciado
grid_block_title_placement = Colocación de elementos
grid_block_title_alignment = Alineación de elementos
grid_block_title_content_alignment = Justificación de contenido
grid_block_title_item_alignment = Alineación individual
grid_block_title_auto_flow = Algoritmo de colocación automática
grid_block_title_auto_tracks = Pistas implícitas
grid_block_title_responsive = Rejilla responsive
grid_block_title_layout = Estructura compuesta: maquetación de página
grid_title_columns_thirds = Tres columnas iguales
grid_title_columns_sidebar = Barra lateral fija + contenido flexible
grid_title_columns_min_max = Contenido mínimo, fracción, contenido máximo
grid_title_rows = Filas de altura fija
grid_title_gap_distinct = Espaciado distinto entre filas y columnas
grid_title_placement = Columnas abarcadas y rangos explícitos
grid_title_alignment_start = Justificación/alineación de elementos: inicio
grid_title_alignment_center = Justificación/alineación de elementos: centro
grid_title_alignment_end = Justificación/alineación de elementos: final
grid_title_alignment_stretch = Justificación/alineación de elementos: estirar
grid_title_content_alignment_start = Justificación de contenido: inicio
grid_title_content_alignment_center = Justificación de contenido: centro
grid_title_content_alignment_between = Justificación de contenido: espacio entre elementos
grid_title_content_alignment_evenly = Justificación de contenido: espacio repartido a partes iguales
grid_title_item_alignment = Sobrescribir la alineación del contenedor en un único elemento
grid_title_auto_flow_row = Flujo por defecto, deja un hueco
grid_title_auto_flow_dense = Flujo denso, rellena el hueco
grid_title_auto_tracks = Filas implícitas de igual altura
grid_title_responsive = Una columna en móvil, dos en tableta, tres en escritorio
grid_title_layout = Cabecera y pie abarcando tres columnas, área de contenido flexible
grid_desc_layout = Grid::new() con columnas laterales fijas, una columna central flexible y abarcados explícitos
grid_box_label = Caja { $n }
grid_box_card = Tarjeta { $n }
grid_box_sidebar = Barra lateral
grid_box_content = Contenido
grid_box_min_content = Contenido mínimo
grid_box_fraction = 1fr
grid_box_max_content = Contenido máximo
grid_box_plain = Normal
grid_box_span_2 = Abarca 2 columnas
grid_box_range = Columnas 2 a 3
grid_box_overridden = Sobrescrita
grid_box_header = Cabecera
grid_box_nav = Navegación
grid_box_aside = Aparte
grid_box_footer = Pie

View file

@ -0,0 +1,36 @@
responsive_slogan = Comportamiento responsive con Flex
responsive_note = Cambia el ancho de la ventana del navegador para ver cómo reaccionan las siguientes cajas. Los puntos de corte usados son los habituales de Bootstrap: sm a partir de 576px, md a partir de 768px, lg a partir de 992px.
responsive_block_title_activation = Activación de Flex por punto de corte
responsive_block_title_direction = Cambio de dirección
responsive_block_title_grid = Rejilla de columnas
responsive_block_title_justify_align = Justificación y alineación
responsive_block_title_order = Orden visual
responsive_block_title_gap_grow = Espaciado y crecimiento
responsive_title_activation = Menú apilado en móvil, en fila a partir de md
responsive_title_direction = Imagen y texto apilados en móvil; en fila, con la imagen a la derecha, a partir de md
responsive_title_grid = De una columna en móvil a tres en escritorio
responsive_title_justify_align = Cabecera centrada en móvil, distribuida a partir de md
responsive_title_order = Barra lateral después del contenido en móvil, antes a partir de lg
responsive_title_gap_grow = Espaciado creciente y buscador que sólo crece a partir de md
responsive_box_nav_home = Inicio
responsive_box_nav_products = Productos
responsive_box_nav_about = Nosotros
responsive_box_nav_contact = Contacto
responsive_box_image = Imagen
responsive_box_text = Texto
responsive_box_card = Tarjeta { $n }
responsive_box_logo = Logotipo
responsive_box_menu = Menú
responsive_box_content = Contenido
responsive_box_sidebar = Barra lateral
responsive_box_file = Archivo
responsive_box_edit = Editar
responsive_box_search = Buscador

View file

@ -0,0 +1,19 @@
spacing_slogan = Márgenes y relleno nativos con Margin y Padding
spacing_note = Margin y Padding no tienen relación con Flexbox: se aplican sobre cualquier componente, use o no Flexbox su contenedor. Aquí se combinan con Flex sólo para colocar las cajas de muestra en fila.
spacing_block_title_padding = Rellenos
spacing_block_title_layout = Composición
spacing_block_title_responsive = Relleno por punto de corte
spacing_block_title_combined = Margin y padding combinados
spacing_title_uniform_small = Relleno pequeño en los cuatro lados
spacing_title_uniform_medium = Relleno medio en los cuatro lados
spacing_title_uniform_large = Relleno grande en los cuatro lados
spacing_title_sides = Un valor distinto para cada lado, incluidos los lados lógicos de inicio y fin
spacing_title_margin_gap = El margen de la caja central empuja a sus vecinas, sin usar Gap
spacing_title_margin_center = Un margen automático en ambos lados centra la caja en el espacio libre
spacing_title_responsive = El relleno crece a partir de md, y de nuevo a partir de lg
spacing_title_combined = Margen exterior arriba/abajo y relleno interno por los cuatro lados, sobre un componente con contenido real
spacing_box_sample = Contenido
spacing_box_card_button = Aceptar

View file

@ -1,7 +1,30 @@
menus_slogan = Barras de navegación y menús
menus_note = Reduce el ancho de la ventana para ver cómo colapsan las barras que tienen botón de despliegue.
## Bloques
menus_block_title_menus = Menús y desplegables
menus_block_title_brand = Marca de identidad
menus_block_title_content = Otros contenidos
menus_block_title_colors = Esquemas de color
menus_block_title_container = Contenedores
menus_block_title_position = Posición
menus_block_title_layout = Disposición y botón de despliegue
menus_block_title_expand = Punto de corte de expansión
menus_block_title_offcanvas = Contenido en un panel lateral (Offcanvas)
## Menús y desplegables
menus_title_full = Menú con desplegable, cabeceras, separadores y un segundo menú al final
menus_item_home = Inicio
menus_item_label = Etiqueta
menus_item_link = Enlace
menus_item_blank = Enlace externo
menus_item_disabled = Enlace deshabilitado
menus_item_blank = Externo
menus_item_disabled = Deshabilitado
menus_item_action = Acción
menus_item_another = Otra acción
menus_item_something = Algo más
menus_test_title = Desplegable
@ -21,3 +44,66 @@ menus_plugin_cache = Plugin Rust de caché
menus_item_sign_up = Registrarse
menus_item_login = Iniciar sesión
## Marca de identidad
menus_title_brand_link = Sólo texto, como enlace
menus_title_brand_span = Sólo texto, sin enlace
menus_title_brand_image = Sólo imagen
menus_title_brand_both = Imagen y texto
## Otros contenidos
menus_title_form_search = Formulario de búsqueda junto a la marca
menus_title_form_buttons = Botones
menus_title_form_group = Grupo de campos
menus_title_text = Texto
menus_title_brand_and_text = Marca y texto
menus_title_nav_text = Menú y texto
menus_search_label = Buscar
menus_search_button = Buscar
menus_button_main = Botón principal
menus_button_small = Botón pequeño
menus_group_placeholder = Usuario
menus_text_inline = Texto de la barra de navegación
## Esquemas de color
menus_title_color_dark = Fondo oscuro
menus_title_color_primary = Color primario con texto claro
menus_title_color_light = Fondo claro del tema
menus_title_color_custom = Color personalizado
## Contenedores
menus_title_container_full = Ocupa todo el ancho disponible
menus_title_container_max = Dentro de un contenedor de ancho máximo
## Posición
menus_position_note = Las barras se anclan al marco de cada ejemplo, no a la ventana, para no tapar el resto de la página. Desplaza el contenido de los marcos con barras pegajosas.
menus_title_position_fixed_top = Fija arriba
menus_title_position_fixed_bottom = Fija abajo
menus_title_position_sticky_top = Pegajosa arriba
menus_title_position_sticky_bottom = Pegajosa abajo
menus_filler = Contenido de la página. Los elementos con posición fija salen del flujo del documento, y los pegajosos se quedan en el borde del contenedor que se desplaza.
## Disposición
menus_title_layout_toggle = Sólo botón de despliegue, sin marca
menus_title_layout_brand_left = Marca a la izquierda y botón de despliegue a la derecha
menus_title_layout_brand_right = Botón de despliegue a la izquierda y marca a la derecha
menus_title_layout_brand_always = Marca siempre visible, sin botón de despliegue
## Punto de corte
menus_title_expand = Se expande a partir de { $name } ({ $width })
## Offcanvas
menus_offcanvas_title = Menú principal
menus_title_offcanvas_start = Panel desde el inicio, sin marca
menus_title_offcanvas_brand_left = Marca a la izquierda y panel desde el final
menus_title_offcanvas_brand_right = Marca a la derecha y panel sin capa de fondo
menus_title_offcanvas_dark = Barra y panel oscuros, con capa de fondo estática

View file

@ -1,106 +1,783 @@
use pagetop::prelude::*;
use pagetop_bootsier::theme::*;
include_locales!(LOC from "examples/locale");
struct SuperMenu;
struct NavbarMenus;
impl Extension for SuperMenu {
#[async_trait]
impl Extension for NavbarMenus {
fn dependencies(&self) -> Vec<ExtensionRef> {
vec![
&pagetop_aliner::Aliner,
&pagetop_bootsier::Bootsier,
&pagetop::base::extension::Welcome,
]
vec![&pagetop_bootsier::Bootsier]
}
fn initialize(&self) {
let navbar_menu = Navbar::brand_left(navbar::Brand::new())
.with_expand(BreakPoint::LG)
fn configure_router(&self, router: Router) -> Router {
router.route("/", web::get(navbar_menus))
}
}
async fn navbar_menus(request: HttpRequest) -> Result<Markup, ErrorPage> {
let page = Page::new(request)
.with_assets(demo_title_styles())
.with_assets(demo_code_styles())
.with_assets(demo_frame_styles())
.with_assets(demo_scroll_styles());
let mut intro = Intro::custom()
.with_width(intro::Width::Full)
.with_title(Lc::n("PageTop"))
.with_slogan(Lc::t("menus_slogan", &LOC))
.with_child(Html::with(|cx| {
html! {
p class="intro-text-lead" { (Lc::t("menus_note", &LOC).using(cx)) }
}
}))
.with_child(menus_block())
.with_child(brand_block())
.with_child(container_block())
.with_child(layout_block())
.with_child(expand_block())
.with_child(content_block())
.with_child(position_block());
// Demostraciones asociadas a Bootsier: usan sus tipos, o clases y variables CSS de Bootstrap
// que otros temas no definen. Sólo se muestran con el tema Bootsier activo.
if is_bootsier() {
intro = intro
.with_child(colors_block())
.with_child(offcanvas_block());
}
page.with_child(intro).render().await
}
// **< Menús >**************************************************************************************
fn menus_block() -> Block {
let full_menu = Navbar::brand_left(demo_brand())
.with_prop(light_bg())
.with_expand(Breakpoint::Lg)
.with_item(navbar::Item::nav(
Nav::new()
.with_item(nav::Item::link(L10n::t("menus_item_link", &LOC), |cx| {
cx.route("/")
}))
.with_item(nav::Item::link(Lc::t("menus_item_home", &LOC), "/").with_active(true))
.with_item(nav::Item::link_blank(
L10n::t("menus_item_blank", &LOC),
|_| "https://docs.rs/pagetop".into(),
Lc::t("menus_item_blank", &LOC),
"https://docs.rs/pagetop",
))
.with_item(nav::Item::dropdown(
Dropdown::new()
.with_title(L10n::t("menus_test_title", &LOC))
.with_item(dropdown::Item::header(L10n::t("menus_dev_header", &LOC)))
.with_title(Lc::t("menus_test_title", &LOC))
.with_item(dropdown::Item::header(Lc::t("menus_dev_header", &LOC)))
.with_item(dropdown::Item::link(
L10n::t("menus_dev_getting_started", &LOC),
|cx| cx.route("/dev/getting-started"),
Lc::t("menus_dev_getting_started", &LOC),
"/dev/getting-started",
))
.with_item(dropdown::Item::link(
L10n::t("menus_dev_guides", &LOC),
|cx| cx.route("/dev/guides"),
Lc::t("menus_dev_guides", &LOC),
"/dev/guides",
))
.with_item(dropdown::Item::link_blank(
L10n::t("menus_dev_forum", &LOC),
|_| "https://forum.example.dev".into(),
Lc::t("menus_dev_forum", &LOC),
"https://forum.example.dev",
))
.with_item(dropdown::Item::divider())
.with_item(dropdown::Item::header(L10n::t("menus_sdk_header", &LOC)))
.with_item(dropdown::Item::header(Lc::t("menus_sdk_header", &LOC)))
.with_item(dropdown::Item::link(
L10n::t("menus_sdk_rust", &LOC),
|cx| cx.route("/dev/sdks/rust"),
Lc::t("menus_sdk_rust", &LOC),
"/dev/sdks/rust",
))
.with_item(dropdown::Item::link(L10n::t("menus_sdk_js", &LOC), |cx| {
cx.route("/dev/sdks/js")
}))
.with_item(dropdown::Item::link(
L10n::t("menus_sdk_python", &LOC),
|cx| cx.route("/dev/sdks/python"),
Lc::t("menus_sdk_js", &LOC),
"/dev/sdks/js",
))
.with_item(dropdown::Item::link(
Lc::t("menus_sdk_python", &LOC),
"/dev/sdks/python",
))
.with_item(dropdown::Item::divider())
.with_item(dropdown::Item::header(L10n::t("menus_plugin_header", &LOC)))
.with_item(dropdown::Item::header(Lc::t("menus_plugin_header", &LOC)))
.with_item(dropdown::Item::link(
L10n::t("menus_plugin_auth", &LOC),
|cx| cx.route("/dev/sdks/rust/plugins/auth"),
Lc::t("menus_plugin_auth", &LOC),
"/dev/sdks/rust/plugins/auth",
))
.with_item(dropdown::Item::link(
L10n::t("menus_plugin_cache", &LOC),
|cx| cx.route("/dev/sdks/rust/plugins/cache"),
Lc::t("menus_plugin_cache", &LOC),
"/dev/sdks/rust/plugins/cache",
))
.with_item(dropdown::Item::divider())
.with_item(dropdown::Item::label(L10n::t("menus_item_label", &LOC)))
.with_item(dropdown::Item::label(Lc::t("menus_item_label", &LOC)))
.with_item(dropdown::Item::link_disabled(
L10n::t("menus_item_disabled", &LOC),
|cx| cx.route("#"),
Lc::t("menus_item_disabled", &LOC),
"#",
)),
))
.with_item(nav::Item::link_disabled(
L10n::t("menus_item_disabled", &LOC),
|cx| cx.route("#"),
Lc::t("menus_item_disabled", &LOC),
"#",
)),
))
.with_item(navbar::Item::nav(
Nav::new()
.with_classes(
ClassesOp::Add,
classes::Margin::with(Side::Start, ScaleSize::Auto).to_class(),
)
.with_item(nav::Item::link(L10n::t("menus_item_sign_up", &LOC), |cx| {
cx.route("/auth/sign-up")
}))
.with_item(nav::Item::link(L10n::t("menus_item_login", &LOC), |cx| {
cx.route("/auth/login")
})),
// Empuja este menú (y lo que le siga) al extremo final de la barra.
.with_prop(FlexItem::push_end())
.with_item(nav::Item::link(
Lc::t("menus_item_sign_up", &LOC),
"/auth/sign-up",
))
.with_item(nav::Item::link(
Lc::t("menus_item_login", &LOC),
"/auth/login",
)),
));
InRegion::Global(&DefaultRegion::Header).add(
Container::new()
.with_width(container::Width::FluidMax(UnitValue::RelRem(75.0)))
.with_child(navbar_menu),
add_demo(
Block::new().with_title(Lc::t("menus_block_title_menus", &LOC)),
Lc::t("menus_title_full", &LOC),
concat!(
"Navbar::brand_left(brand)",
".with_item(navbar::Item::nav(Nav::new()...with_item(nav::Item::dropdown(...))))",
".with_item(navbar::Item::nav(Nav::new().with_prop(FlexItem::push_end())...))",
),
full_menu,
)
}
// **< Marca de identidad >*************************************************************************
fn brand_block() -> Block {
// Una barra sin contenidos no se renderiza, así que la marca se añade como un elemento más.
let brand_variants: [(&str, Brand, &str); 4] = [
(
"menus_title_brand_link",
demo_brand(),
"navbar::Item::brand(Brand::new().with_title(...).with_route(Route::from(\"/\")))",
),
(
"menus_title_brand_span",
demo_brand().with_route(None::<Route>),
"navbar::Item::brand(Brand::new().with_title(...).with_route(None))",
),
(
"menus_title_brand_image",
Brand::new()
.with_title(Lc::none())
.with_image(demo_logo())
.with_route(Route::from("/")),
"navbar::Item::brand(Brand::new().with_title(Lc::none()).with_image(image))",
),
(
"menus_title_brand_both",
demo_brand().with_image(demo_logo()),
"navbar::Item::brand(Brand::new().with_title(...).with_image(image))",
),
];
let mut block = Block::new().with_title(Lc::t("menus_block_title_brand", &LOC));
for (title_key, brand, code) in brand_variants {
block = add_demo(
block,
Lc::t(title_key, &LOC),
code,
Navbar::simple()
.with_prop(light_bg())
.with_item(navbar::Item::brand(brand)),
);
}
block
}
// **< Otros contenidos >***************************************************************************
fn content_block() -> Block {
let gap = align::Gap::Both(UnitValue::RelRem(0.5));
let search_form = Form::new()
.with_action("/")
.with_method(form::Method::Get)
.with_child(
Flex::new()
.with_align(align::Items::Center)
.with_gap(gap)
.with_child(
form::input::Field::search()
.with_name("q")
.with_placeholder(Lc::t("menus_search_label", &LOC))
// Sin el margen inferior con que algunos temas separan los campos.
.with_prop(Margin::new().with_bottom(UnitValue::Zero)),
)
.with_child(
Button::submit(Lc::t("menus_search_button", &LOC))
.with_style(button::Style::Outline(Intent::Success)),
),
);
let buttons = Form::new().with_child(
Flex::new()
.with_align(align::Items::Center)
.with_gap(gap)
.with_child(
Button::plain(Lc::t("menus_button_main", &LOC))
.with_style(button::Style::Outline(Intent::Success)),
)
.with_child(
Button::plain(Lc::t("menus_button_small", &LOC))
.with_size(button::Size::Small)
.with_style(button::Style::Outline(Intent::Neutral)),
),
);
let mut content_variants: Vec<(&str, Navbar, &str)> = vec![
(
"menus_title_form_search",
Navbar::simple_brand_left(demo_brand()).with_item(navbar::Item::form(search_form)),
"Navbar::simple_brand_left(brand).with_item(navbar::Item::form(Form::new()...))",
),
(
"menus_title_form_buttons",
Navbar::simple().with_item(navbar::Item::form(buttons)),
"Navbar::simple().with_item(navbar::Item::form(Form::new()...))",
),
(
"menus_title_text",
Navbar::simple().with_item(navbar::Item::text(Lc::t("menus_text_inline", &LOC))),
"Navbar::simple().with_item(navbar::Item::text(Lc::t(...)))",
),
(
"menus_title_brand_and_text",
Navbar::simple()
.with_item(navbar::Item::brand(demo_brand()))
.with_item(navbar::Item::text(Lc::t("menus_text_inline", &LOC))),
concat!(
"Navbar::simple().with_item(navbar::Item::brand(brand))",
".with_item(navbar::Item::text(...))",
),
),
(
"menus_title_nav_text",
Navbar::brand_left(demo_brand())
.with_expand(Breakpoint::Lg)
.with_item(navbar::Item::nav(
demo_nav().with_prop(Margin::new().with_end(UnitValue::Auto)),
))
.with_item(navbar::Item::text(Lc::t("menus_text_inline", &LOC))),
concat!(
"Navbar::brand_left(brand).with_item(navbar::Item::nav(",
"Nav::new().with_prop(Margin::new().with_end(UnitValue::Auto))...))",
".with_item(navbar::Item::text(...))",
),
),
];
// El núcleo no tiene un componente de grupo de entrada, así que esta demostración usa clases de
// Bootstrap y sólo se muestra con el tema Bootsier.
if is_bootsier() {
let input_group = Nav::new().with_item(nav::Item::html(Html::with(|cx| {
html! {
form {
div class="input-group" {
span class="input-group-text" { "@" }
input
class="form-control"
type="text"
placeholder=[Lc::t("menus_group_placeholder", &LOC).lookup(cx)]
aria-label=[Lc::t("menus_group_placeholder", &LOC).lookup(cx)];
}
}
}
})));
content_variants.insert(
2,
(
"menus_title_form_group",
Navbar::simple().with_item(navbar::Item::nav(input_group)),
"Navbar::simple().with_item(navbar::Item::nav(Nav::new()...nav::Item::html(...)))",
),
);
}
let mut block = Block::new().with_title(Lc::t("menus_block_title_content", &LOC));
for (title_key, navbar, code) in content_variants {
block = add_demo(
block,
Lc::t(title_key, &LOC),
code,
navbar.with_prop(light_bg()),
);
}
block
}
// **< Esquemas de color >**************************************************************************
fn colors_block() -> Block {
let color_variants: [(&str, Vec<PropsOp>, &str); 4] = [
(
"menus_title_color_dark",
vec![
PropsOp::add_classes(class::Bg::with(BootsierColors::Dark)),
PropsOp::set("data-bs-theme", "dark"),
],
concat!(
"Navbar::brand_left(brand)",
".with_prop(PropsOp::add_classes(class::Bg::with(BootsierColors::Dark)))",
".with_prop(PropsOp::set(\"data-bs-theme\", \"dark\"))",
),
),
(
"menus_title_color_primary",
vec![
PropsOp::add_classes(class::Bg::with(BootsierColors::Primary)),
PropsOp::set("data-bs-theme", "dark"),
],
concat!(
"Navbar::brand_left(brand)",
".with_prop(PropsOp::add_classes(class::Bg::with(BootsierColors::Primary)))",
".with_prop(PropsOp::set(\"data-bs-theme\", \"dark\"))",
),
),
(
"menus_title_color_light",
vec![light_bg()],
concat!(
"Navbar::brand_left(brand)",
".with_prop(PropsOp::add_classes(class::Bg::with(class::BgColor::BodyTertiary)))",
),
),
(
"menus_title_color_custom",
vec![PropsOp::add_style("background-color", "#e3f2fd")],
concat!(
"Navbar::brand_left(brand)",
".with_prop(PropsOp::add_style(\"background-color\", \"#e3f2fd\"))",
),
),
];
let mut block = Block::new().with_title(Lc::t("menus_block_title_colors", &LOC));
for (title_key, props, code) in color_variants {
let mut navbar = Navbar::brand_left(demo_brand())
.with_expand(Breakpoint::Lg)
.with_item(navbar::Item::nav(demo_nav()));
for op in props {
navbar = navbar.with_prop(op);
}
block = add_demo(block, Lc::t(title_key, &LOC), code, navbar);
}
block
}
// **< Contenedores >*******************************************************************************
fn container_block() -> Block {
let navbar = || {
Navbar::brand_left(demo_brand())
.with_prop(light_bg())
.with_expand(Breakpoint::Lg)
.with_item(navbar::Item::nav(demo_nav()))
};
let block = Block::new().with_title(Lc::t("menus_block_title_container", &LOC));
let block = add_demo(
block,
Lc::t("menus_title_container_full", &LOC),
"Navbar::brand_left(brand)...",
navbar(),
);
add_demo(
block,
Lc::t("menus_title_container_max", &LOC),
concat!(
"Container::new()",
".with_width(container::Width::FluidMax(UnitValue::RelRem(40.0)))",
".with_child(Navbar::brand_left(brand)...)",
),
Container::new()
.with_width(container::Width::FluidMax(UnitValue::RelRem(40.0)))
.with_child(navbar()),
)
}
// **< Posición >***********************************************************************************
fn position_block() -> Block {
let navbar = |position| {
Navbar::simple()
.with_position(position)
.with_prop(light_bg())
.with_item(navbar::Item::nav(demo_nav()))
};
// Las barras fijas se anclan al marco y no a la ventana, para no tapar el resto de la página.
let fixed_variants: [(&str, navbar::Position, &str); 2] = [
(
"menus_title_position_fixed_top",
navbar::Position::FixedTop,
"Navbar::simple().with_position(navbar::Position::FixedTop)",
),
(
"menus_title_position_fixed_bottom",
navbar::Position::FixedBottom,
"Navbar::simple().with_position(navbar::Position::FixedBottom)",
),
];
let mut block = Block::new()
.with_title(Lc::t("menus_block_title_position", &LOC))
.with_child(Html::with(|cx| {
html! {
p { (Lc::t("menus_position_note", &LOC).using(cx)) }
}
}));
for (title_key, position, code) in fixed_variants {
block = add_demo(
block,
Lc::t(title_key, &LOC),
code,
Container::new()
.with_width(container::Width::Fluid)
.with_prop(PropsOp::add_classes("menus-demo-frame"))
.with_child(navbar(position))
.with_child(filler()),
);
}
// Con `sticky` la barra ocupa su lugar en el flujo y se queda pegada al borde del contenedor
// que se desplaza (aquí un marco con scroll).
block = add_demo(
block,
Lc::t("menus_title_position_sticky_top", &LOC),
"Navbar::simple().with_position(navbar::Position::StickyTop)",
Container::new()
.with_width(container::Width::Fluid)
.with_prop(PropsOp::add_classes("menus-demo-scroll"))
.with_child(navbar(navbar::Position::StickyTop))
.with_child(filler())
.with_child(filler())
.with_child(filler())
.with_child(filler()),
);
add_demo(
block,
Lc::t("menus_title_position_sticky_bottom", &LOC),
"Navbar::simple().with_position(navbar::Position::StickyBottom)",
Container::new()
.with_width(container::Width::Fluid)
.with_prop(PropsOp::add_classes("menus-demo-scroll"))
.with_child(filler())
.with_child(filler())
.with_child(filler())
.with_child(filler())
.with_child(navbar(navbar::Position::StickyBottom)),
)
}
// **< Disposición >********************************************************************************
fn layout_block() -> Block {
let layout_variants: [(&str, Navbar, &str); 4] = [
(
"menus_title_layout_toggle",
Navbar::simple_toggle(),
"Navbar::simple_toggle()",
),
(
"menus_title_layout_brand_left",
Navbar::brand_left(demo_brand()),
"Navbar::brand_left(brand)",
),
(
"menus_title_layout_brand_right",
Navbar::brand_right(demo_brand()),
"Navbar::brand_right(brand)",
),
(
"menus_title_layout_brand_always",
Navbar::simple_brand_left(demo_brand()),
"Navbar::simple_brand_left(brand)",
),
];
let mut block = Block::new().with_title(Lc::t("menus_block_title_layout", &LOC));
for (title_key, navbar, code) in layout_variants {
block = add_demo(
block,
Lc::t(title_key, &LOC),
code,
navbar
.with_prop(light_bg())
.with_expand(Breakpoint::Lg)
.with_item(navbar::Item::nav(demo_nav())),
);
}
block
}
// **< Punto de corte >*****************************************************************************
fn expand_block() -> Block {
let expand_variants: [(Lc, Breakpoint, &str); 5] = [
(
expand_title("sm", "576px"),
Breakpoint::Sm,
"Navbar::brand_left(brand).with_expand(Breakpoint::Sm)",
),
(
expand_title("md", "768px"),
Breakpoint::Md,
"Navbar::brand_left(brand).with_expand(Breakpoint::Md)",
),
(
expand_title("lg", "992px"),
Breakpoint::Lg,
"Navbar::brand_left(brand).with_expand(Breakpoint::Lg)",
),
(
expand_title("xl", "1200px"),
Breakpoint::Xl,
"Navbar::brand_left(brand).with_expand(Breakpoint::Xl)",
),
(
expand_title("xxl", "1400px"),
Breakpoint::Xxl,
"Navbar::brand_left(brand).with_expand(Breakpoint::Xxl)",
),
];
let mut block = Block::new().with_title(Lc::t("menus_block_title_expand", &LOC));
for (title, breakpoint, code) in expand_variants {
block = add_demo(
block,
title,
code,
Navbar::brand_left(demo_brand())
.with_prop(light_bg())
.with_expand(breakpoint)
.with_item(navbar::Item::nav(demo_nav())),
);
}
block
}
fn expand_title(name: &'static str, width: &'static str) -> Lc {
Lc::t("menus_title_expand", &LOC)
.with_arg("name", name)
.with_arg("width", width)
}
// **< Offcanvas >**********************************************************************************
fn offcanvas_block() -> Block {
let panel = |placement, backdrop| {
bs::navbar::Panel::new()
.with_title(Lc::t("menus_offcanvas_title", &LOC))
.with_placement(placement)
.with_backdrop(backdrop)
};
let offcanvas_variants: [(&str, Navbar, &str); 4] = [
(
"menus_title_offcanvas_start",
Navbar::offcanvas(panel(
bs::offcanvas::Placement::Start,
bs::offcanvas::Backdrop::Enabled,
)),
concat!(
"Navbar::offcanvas(bs::navbar::Panel::new()",
".with_placement(bs::offcanvas::Placement::Start))",
),
),
(
"menus_title_offcanvas_brand_left",
Navbar::offcanvas_brand_left(
demo_brand(),
panel(
bs::offcanvas::Placement::End,
bs::offcanvas::Backdrop::Enabled,
),
),
concat!(
"Navbar::offcanvas_brand_left(brand, bs::navbar::Panel::new()",
".with_placement(bs::offcanvas::Placement::End))",
),
),
(
"menus_title_offcanvas_brand_right",
Navbar::offcanvas_brand_right(
demo_brand(),
panel(
bs::offcanvas::Placement::Start,
bs::offcanvas::Backdrop::Disabled,
),
),
concat!(
"Navbar::offcanvas_brand_right(brand, bs::navbar::Panel::new()",
".with_backdrop(bs::offcanvas::Backdrop::Disabled))",
),
),
(
"menus_title_offcanvas_dark",
Navbar::offcanvas_brand_left(
demo_brand(),
panel(
bs::offcanvas::Placement::End,
bs::offcanvas::Backdrop::Static,
),
)
.with_prop(PropsOp::add_classes(class::Bg::with(BootsierColors::Dark)))
.with_prop(PropsOp::set("data-bs-theme", "dark")),
concat!(
"Navbar::offcanvas_brand_left(brand, bs::navbar::Panel::new()",
".with_backdrop(bs::offcanvas::Backdrop::Static))",
".with_prop(PropsOp::set(\"data-bs-theme\", \"dark\"))",
),
),
];
let mut block = Block::new().with_title(Lc::t("menus_block_title_offcanvas", &LOC));
for (title_key, navbar, code) in offcanvas_variants {
let navbar = navbar
.with_expand(Breakpoint::Lg)
.with_item(navbar::Item::nav(demo_nav()));
// La barra oscura ya define su propio fondo.
let navbar = if title_key == "menus_title_offcanvas_dark" {
navbar
} else {
navbar.with_prop(light_bg())
};
block = add_demo(block, Lc::t(title_key, &LOC), code, navbar);
}
block
}
// **< HELPERS >************************************************************************************
// Marca de identidad de las demostraciones.
fn demo_brand() -> Brand {
Brand::new()
.with_title(Lc::n("PageTop"))
.with_route(Route::from("/"))
}
// Logotipo de las demostraciones, con el tamaño habitual de una marca en una barra.
fn demo_logo() -> Image {
Image::with(image::Source::logo(PageTopSvg::Tile))
.with_size(image::Size::Both(UnitValue::Px(36)))
}
// Menú común a la mayoría de las demostraciones: activo, enlace, desplegable y deshabilitado.
fn demo_nav() -> Nav {
Nav::new()
.with_item(nav::Item::link(Lc::t("menus_item_home", &LOC), "/").with_active(true))
.with_item(nav::Item::link(Lc::t("menus_item_link", &LOC), "/link"))
.with_item(nav::Item::dropdown(
Dropdown::new()
.with_title(Lc::t("menus_test_title", &LOC))
.with_item(dropdown::Item::link(
Lc::t("menus_item_action", &LOC),
"/action",
))
.with_item(dropdown::Item::link(
Lc::t("menus_item_another", &LOC),
"/another",
))
.with_item(dropdown::Item::divider())
.with_item(dropdown::Item::link(
Lc::t("menus_item_something", &LOC),
"/something",
)),
))
.with_item(nav::Item::link_disabled(
Lc::t("menus_item_disabled", &LOC),
"#",
))
}
fn is_bootsier() -> bool {
global::SETTINGS.app.theme.eq_ignore_ascii_case("bootsier")
}
// Fondo claro para que las barras destaquen sobre el fondo de la página. Basic no da fondo a
// `.navbar`, así que con otros temas se usa el color de fondo de Basic (o un gris claro si el tema
// activo tampoco lo define).
fn light_bg() -> PropsOp {
if is_bootsier() {
PropsOp::add_classes(class::Bg::with(class::BgColor::BodyTertiary))
} else {
PropsOp::add_style("background-color", "var(--val-color--bg, #f8f9fa)")
}
}
// Título y fragmento de código que introducen cada demostración, seguidos de ésta.
fn add_demo(block: Block, title: Lc, code: &'static str, demo: impl Into<ChildOp>) -> Block {
block
.with_child(caption(title, Lc::n(code)))
.with_child(demo)
}
// Párrafo de relleno para que los marcos de demostración tengan contenido que desplazar.
fn filler() -> Html {
Html::with(|cx| {
html! {
p { (Lc::t("menus_filler", &LOC).using(cx)) }
}
})
}
// Título y fragmento de código que introducen cada demostración.
fn caption(title: Lc, code: Lc) -> Html {
Html::with(move |cx| {
html! {
h3 class="menus-demo-title" { (title.using(cx)) }
p { code class="menus-demo-code" { (code.using(cx)) } }
}
})
}
// Separa el título de cada demostración de la barra (o marco) que le precede.
fn demo_title_styles() -> AssetsOp {
AssetsOp::add_responsive_styles(None, "menus-demo-title", [("margin-top", "1.5rem")])
}
// Evita que los fragmentos de código largos desborden su contenedor.
fn demo_code_styles() -> AssetsOp {
AssetsOp::add_responsive_styles(None, "menus-demo-code", [("overflow-wrap", "anywhere")])
}
// Marco de tamaño fijo para las barras con posición `fixed`.
fn demo_frame_styles() -> AssetsOp {
AssetsOp::add_responsive_styles(
None,
"menus-demo-frame",
[
("position", "relative"),
// Un `transform` convierte al marco en el bloque contenedor de sus descendientes con
// `position: fixed`, así la barra se ancla al marco en vez de a la ventana.
("transform", "translateZ(0)"),
("height", "12rem"),
("padding", "4rem 1rem"),
("overflow", "hidden"),
("border", "1px solid #dee2e6"),
("margin", "0 0 1.5rem"),
],
)
}
// Marco de tamaño fijo con desplazamiento, para las barras con posición `sticky`.
fn demo_scroll_styles() -> AssetsOp {
AssetsOp::add_responsive_styles(
None,
"menus-demo-scroll",
[
("height", "12rem"),
("overflow-y", "auto"),
("padding", "0 1rem"),
("border", "1px solid #dee2e6"),
("margin", "0 0 1.5rem"),
],
)
}
#[pagetop::main]
async fn main() -> std::io::Result<()> {
Application::prepare(&SuperMenu).run()?.await
Application::prepare(&NavbarMenus).await.run().await
}

View file

@ -0,0 +1,19 @@
[package]
name = "pagetop-admin"
description = "Panel de administración extensible para PageTop."
version = "0.1.0"
categories = ["web-programming", "development-tools"]
keywords = ["pagetop", "admin", "cms", "settings", "ssr"]
repository.workspace = true
homepage.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
pagetop.workspace = true
pagetop-seaorm.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true

View file

@ -0,0 +1,286 @@
//! Tipos de acción que `pagetop-admin` expone para que otras extensiones registren
//! secciones, páginas, tareas y acciones en el panel de administración.
//!
//! El flujo de `registry::build()` despacha estas acciones en este orden:
//!
//! 1. `DeclareAdminSections` - secciones (agrupaciones del sidebar).
//! 2. `DeclareAdminPages` - páginas del panel.
//! 3. `DeclareAdminTasks` - tareas (pestañas) locales por página.
//! 4. `DeclareAdminActions` - acciones locales (botones) por página.
use pagetop::prelude::*;
use crate::registry::{AdminAction, AdminPage, AdminSection, AdminTask};
// **< Tipo de callbacks >**************************************************************************
pub type FnSectionBag = fn(&mut SectionBag);
pub type FnPageBag = fn(&mut PageBag);
pub type FnTaskBag = fn(&mut TaskBag);
pub type FnActionBag = fn(&mut ActionBag);
// **< Bolsas de declaración >**********************************************************************
/// Bolsa de secciones para [`DeclareAdminSections`].
pub struct SectionBag {
pub(crate) sections: Vec<AdminSection>,
}
impl SectionBag {
/// Añade una sección al registro.
pub fn add(&mut self, section: AdminSection) {
self.sections.push(section);
}
}
/// Bolsa de páginas para [`DeclareAdminPages`].
pub struct PageBag {
pub(crate) pages: Vec<AdminPage>,
}
impl PageBag {
/// Añade una página al registro.
pub fn add(&mut self, page: AdminPage) {
self.pages.push(page);
}
}
/// Bolsa de tareas para [`DeclareAdminTasks`].
pub struct TaskBag {
pub(crate) tasks: Vec<AdminTask>,
}
impl TaskBag {
/// Añade una tarea local al registro.
pub fn add(&mut self, task: AdminTask) {
self.tasks.push(task);
}
}
/// Bolsa de acciones locales para [`DeclareAdminActions`].
pub struct ActionBag {
pub(crate) actions: Vec<AdminAction>,
}
impl ActionBag {
/// Añade una acción local al registro.
pub fn add(&mut self, action: AdminAction) {
self.actions.push(action);
}
}
// **< DeclareAdminSections >***********************************************************************
/// Acción para declarar secciones del panel de administración.
///
/// Se despacha durante `registry::build()` (dentro de `initialize()`). El callback
/// recibe un [`SectionBag`] y puede llamar a `add()` para registrar secciones.
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::locale::Lc;
/// use pagetop_admin::action::{DeclareAdminSections, SectionBag};
/// use pagetop_admin::registry::AdminSection;
///
/// fn declare_sections(bag: &mut SectionBag) {
/// bag.add(AdminSection {
/// key: "tools".to_owned(),
/// path: "/admin/tools".to_owned(),
/// title: Lc::n("Tools"),
/// weight: 60,
/// });
/// }
/// // En Extension::actions():
/// // DeclareAdminSections::new(declare_sections)
/// ```
pub struct DeclareAdminSections {
f: FnSectionBag,
weight: Weight,
}
impl ActionDispatcher for DeclareAdminSections {
fn weight(&self) -> Weight {
self.weight
}
}
impl DeclareAdminSections {
pub fn new(f: FnSectionBag) -> Self {
DeclareAdminSections { f, weight: 0 }
}
pub fn with_weight(mut self, w: Weight) -> Self {
self.weight = w;
self
}
pub(crate) fn dispatch(bag: &mut SectionBag) {
dispatch_actions(|action: &Self| (action.f)(bag));
}
}
// **< DeclareAdminPages >**************************************************************************
/// Acción para declarar páginas del panel de administración.
///
/// Se despacha durante `registry::build()`. El callback recibe un [`PageBag`].
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_admin::action::{DeclareAdminPages, PageBag};
/// use pagetop_admin::registry::{AdminPage, AdminPageKind};
///
/// struct ExportPermission;
///
/// impl Permission for ExportPermission {
/// fn key(&self) -> CowStr {
/// "tools:export".into()
/// }
/// }
///
/// fn declare_pages(bag: &mut PageBag) {
/// bag.add(AdminPage {
/// path: "/admin/tools/export".to_owned(),
/// section: "tools".to_owned(),
/// title: Lc::n("Export"),
/// description: Some(Lc::n("Export site data.")),
/// weight: 0,
/// permission: &ExportPermission,
/// kind: AdminPageKind::View,
/// });
/// }
/// // En Extension::actions():
/// // DeclareAdminPages::new(declare_pages)
/// ```
pub struct DeclareAdminPages {
f: FnPageBag,
weight: Weight,
}
impl ActionDispatcher for DeclareAdminPages {
fn weight(&self) -> Weight {
self.weight
}
}
impl DeclareAdminPages {
pub fn new(f: FnPageBag) -> Self {
DeclareAdminPages { f, weight: 0 }
}
pub fn with_weight(mut self, w: Weight) -> Self {
self.weight = w;
self
}
pub(crate) fn dispatch(bag: &mut PageBag) {
dispatch_actions(|action: &Self| (action.f)(bag));
}
}
// **< DeclareAdminTasks >**************************************************************************
/// Acción para declarar tareas (pestañas) locales en páginas del panel.
///
/// Se despacha durante `registry::build()`. El callback recibe un [`TaskBag`].
/// Cada [`AdminTask`] indica en `parent_path` la página a la que pertenece.
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::locale::Lc;
/// use pagetop_admin::action::{DeclareAdminTasks, TaskBag};
/// use pagetop_admin::registry::AdminTask;
///
/// fn declare_tasks(bag: &mut TaskBag) {
/// bag.add(AdminTask {
/// path: "/admin/tools/export/csv".to_owned(),
/// parent_path: "/admin/tools/export".to_owned(),
/// title: Lc::n("CSV"),
/// weight: 0,
/// is_default: true,
/// permission: None,
/// });
/// }
/// // En Extension::actions():
/// // DeclareAdminTasks::new(declare_tasks)
/// ```
pub struct DeclareAdminTasks {
f: FnTaskBag,
weight: Weight,
}
impl ActionDispatcher for DeclareAdminTasks {
fn weight(&self) -> Weight {
self.weight
}
}
impl DeclareAdminTasks {
pub fn new(f: FnTaskBag) -> Self {
DeclareAdminTasks { f, weight: 0 }
}
pub fn with_weight(mut self, w: Weight) -> Self {
self.weight = w;
self
}
pub(crate) fn dispatch(bag: &mut TaskBag) {
dispatch_actions(|action: &Self| (action.f)(bag));
}
}
// **< DeclareAdminActions >************************************************************************
/// Acción para declarar acciones locales (botones de acción) en páginas del panel.
///
/// Se despacha durante `registry::build()`. El callback recibe un [`ActionBag`].
/// Cada [`AdminAction`] indica en `for_path` la página en la que aparece el botón.
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::locale::Lc;
/// use pagetop_admin::action::{DeclareAdminActions, ActionBag};
/// use pagetop_admin::registry::AdminAction;
///
/// fn declare_actions(bag: &mut ActionBag) {
/// bag.add(AdminAction {
/// url: "/admin/tools/export/new".to_owned(),
/// for_path: "/admin/tools/export".to_owned(),
/// title: Lc::n("Add export"),
/// weight: 0,
/// });
/// }
/// // En Extension::actions():
/// // DeclareAdminActions::new(declare_actions)
/// ```
pub struct DeclareAdminActions {
f: FnActionBag,
weight: Weight,
}
impl ActionDispatcher for DeclareAdminActions {
fn weight(&self) -> Weight {
self.weight
}
}
impl DeclareAdminActions {
pub fn new(f: FnActionBag) -> Self {
DeclareAdminActions { f, weight: 0 }
}
pub fn with_weight(mut self, w: Weight) -> Self {
self.weight = w;
self
}
pub(crate) fn dispatch(bag: &mut ActionBag) {
dispatch_actions(|action: &Self| (action.f)(bag));
}
}

View file

@ -0,0 +1,9 @@
//! Componentes de renderizado del panel de administración.
mod admin_frame;
mod admin_menu;
mod config_form;
pub use admin_frame::AdminFrame;
pub use admin_menu::AdminMenu;
pub use config_form::ConfigForm;

View file

@ -0,0 +1,150 @@
use pagetop::prelude::*;
use crate::LOCALES_ADMIN;
use crate::registry;
/// Componente de layout principal de una página del panel de administración.
///
/// Renderiza breadcrumb, encabezado (título + acciones locales), tareas locales (pestañas) y el
/// contenido de la página. No incluye ningún menú de navegación: mostrar las secciones del panel
/// (el "sidebar" o el "menú superior", según el tema) es responsabilidad del tema que intercepte
/// [`CoreTemplates::Admin`](pagetop::core::theme::CoreTemplates::Admin) (por ejemplo
/// `pagetop-bootsier`) leyendo directamente [`crate::registry::global()`]. El tema básico de
/// PageTop no lo hace, y una página de administración sigue siendo completamente navegable sin él,
/// a través del *dashboard* (`/admin`), las páginas de sección y este mismo breadcrumb.
///
/// ```html
/// <nav class="admin-breadcrumb">...</nav>
/// <header class="admin-header">
/// <h1 class="admin-page-title">Title</h1>
/// <ul class="admin-actions-list">...</ul>
/// </header>
/// <nav class="admin-local-tasks">...</nav>
/// <div class="admin-content">...</div>
/// ```
///
/// El contenido son sus propios hijos ([`Children`]), igual que en cualquier otro componente
/// contenedor (p. ej. [`Block`](pagetop::base::component::Block)): admite tantos `with_child(...)`
/// como haga falta, cada uno renderizado en el ciclo normal de componentes.
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct AdminFrame {
/// Devuelve el título de la página.
title: Lc,
/// Devuelve la lista de componentes hijo de la página.
children: Children,
}
#[async_trait]
impl Component for AdminFrame {
fn new() -> Self {
Self::default()
}
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let path = cx
.request()
.map(|r| r.path().to_string())
.unwrap_or_else(|| "/".to_string());
let breadcrumbs = render_breadcrumbs(cx, &path).await;
let local_tasks = render_local_tasks(cx, &path);
let local_acts = render_local_actions(cx, &path);
let body = self.children().render(cx).await;
Ok(html! {
(breadcrumbs)
header class="admin-header" {
h1 class="admin-page-title" { (self.title().using(cx)) }
(local_acts)
}
@if !local_tasks.0.is_empty() {
nav class="admin-local-tasks" { (local_tasks) }
}
div class="admin-content" {
(body)
}
})
}
}
#[builder_impl]
impl AdminFrame {
/// Establece el título de la página.
pub fn with_title(mut self, title: Lc) -> Self {
self.title = title;
self
}
/// Añade un componente hijo al contenido de la página.
pub fn with_child(mut self, op: impl Into<ChildOp>) -> Self {
self.children.alter_child(op.into());
self
}
}
// **< Breadcrumbs >********************************************************************************
async fn render_breadcrumbs(cx: &mut Context, current_path: &str) -> Markup {
let reg = registry::global();
let base = crate::ADMIN_BASE_PATH;
let home_label = Lc::t("admin-breadcrumb-home", &LOCALES_ADMIN);
let mut bc = Breadcrumb::new().with_crumb(breadcrumb::Crumb::new(home_label, base));
if let Some(page) = reg.pages().get(current_path) {
if let Some(section) = reg.sections().get(&page.section) {
bc = bc.with_crumb(breadcrumb::Crumb::new(
section.title.clone(),
section.path.as_str(),
));
}
bc = bc.with_crumb(breadcrumb::Crumb::current(page.title.clone()));
} else if let Some(section) = reg.sections().values().find(|s| s.path == current_path) {
// La ruta actual es la propia sección (su página de aterrizaje), no una `AdminPage`
// registrada dentro de ella.
bc = bc.with_crumb(breadcrumb::Crumb::current(section.title.clone()));
}
bc.render(cx).await
}
// **< Tareas locales >*****************************************************************************
fn render_local_tasks(cx: &Context, current_path: &str) -> Markup {
let reg = registry::global();
let tasks = reg.tasks_for(current_path);
if tasks.is_empty() {
return html! {};
}
html! {
ul class="admin-tasks-list" {
@for task in tasks {
@let is_active = current_path == task.path;
li class=(if is_active { "admin-task admin-task-active" } else { "admin-task" }) {
a href=(cx.route(task.path.as_str())) { (task.title.using(cx)) }
}
}
}
}
}
// **< Acciones locales >***************************************************************************
fn render_local_actions(cx: &Context, current_path: &str) -> Markup {
let reg = registry::global();
let actions = reg.actions_for(current_path);
if actions.is_empty() {
return html! {};
}
html! {
ul class="admin-actions-list" {
@for action in actions {
li class="admin-action" {
a class="admin-action-link" href=(cx.route(action.url.as_str())) {
(action.title.using(cx))
}
}
}
}
}
}

View file

@ -0,0 +1,38 @@
use pagetop::prelude::*;
use crate::registry;
/// Componente que renderiza la barra de navegación del panel de administración.
///
/// Construye la [`Navbar`] en cada petición a partir de [`registry::admin_navbar()`], ya filtrada
/// por el usuario de la petición actual. Pensado para que un tema la registre en su propia región
/// de navegación e intercepte `Navbar`/`Nav`/`Dropdown` en
/// [`Theme::render_component()`](pagetop::core::theme::Theme::render_component) si quiere darle un
/// aspecto propio (p. ej. `pagetop-bootsier` la reconoce automáticamente al ser componentes del
/// núcleo); sin intercepción, se renderiza con el marcado por defecto de cada componente.
///
/// Sólo se renderiza en páginas creadas con
/// [`Page::admin()`](pagetop::response::Page::admin) (plantilla
/// [`CoreTemplates::Admin`](pagetop::core::theme::CoreTemplates::Admin)). Es necesario comprobarlo
/// explícitamente porque, si se registra en una región de propósito general como
/// [`CoreRegions::Aside`](pagetop::core::theme::CoreRegions::Aside), se renderizaría también en
/// páginas `Standard` si no se autolimitara.
#[derive(AutoDefault, Clone, Debug)]
pub struct AdminMenu;
#[async_trait]
impl Component for AdminMenu {
fn new() -> Self {
Self
}
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
if !matches!(
cx.template().downcast_ref::<CoreTemplates>(),
Some(CoreTemplates::Admin)
) {
return Ok(html! {});
}
Ok(registry::admin_navbar(cx).render(cx).await)
}
}

View file

@ -0,0 +1,228 @@
use pagetop::prelude::*;
use crate::LOCALES_ADMIN;
use crate::settings::{SettingFieldType, SettingsSchema, get_or};
/// Componente que renderiza un formulario de configuración persistente.
///
/// Genera campos HTML a partir de un [`SettingsSchema`] y carga los valores
/// actuales desde `settings`. El POST es procesado por el handler interno
/// `config_form_post`.
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_admin::component::{AdminFrame, ConfigForm};
/// use pagetop_admin::settings::{SettingField, SettingsSchema};
///
/// async fn settings_handler(request: HttpRequest) -> Result<Markup, ErrorPage> {
/// let schema = SettingsSchema::new("myapp.general")
/// .with_field(SettingField::text("site_name", "Site name").with_required(true));
///
/// let title = "General settings";
/// let mut form = ConfigForm::with_schema(schema);
/// let mut cx = Context::new(request.clone());
/// let content = form.render(&mut cx).await;
///
/// Page::admin(request)
/// .with_title(Lc::n(title))
/// .with_child(
/// AdminFrame::new()
/// .with_title(Lc::n(title))
/// .with_child(Html::with(move |_| content.clone())),
/// )
/// .render()
/// .await
/// }
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct ConfigForm {
/// Devuelve el esquema del formulario, si se ha establecido uno.
schema: Option<SettingsSchema>,
/// Devuelve la ruta de destino del formulario, si se ha personalizado.
action_path: Option<Route>,
/// Devuelve si el último envío se guardó correctamente.
saved: bool,
/// Devuelve si el último envío produjo un error al guardar.
error: bool,
}
#[async_trait]
impl Component for ConfigForm {
fn new() -> Self {
Self::default()
}
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let Some(schema) = self.schema() else {
return Ok(html! {});
};
let action = match self.action_path() {
Some(route) => route.resolve(cx),
None => cx.route(cx.request().map(|r| r.path()).unwrap_or("/")),
};
let save_label = Lc::t("config-save-label", &LOCALES_ADMIN)
.lookup(cx)
.unwrap_or_else(|| "Save configuration".into());
let saved_msg = Lc::t("config-saved-ok", &LOCALES_ADMIN)
.lookup(cx)
.unwrap_or_else(|| "Configuration saved.".into());
let error_msg = Lc::t("config-saved-error", &LOCALES_ADMIN)
.lookup(cx)
.unwrap_or_else(|| "Could not save configuration.".into());
// Pre-computa los valores de BD para cada campo antes del macro html! (que es síncrono).
struct FieldVals {
raw_val: String,
num_val: String,
checked: bool,
}
let mut prepared: Vec<FieldVals> = Vec::with_capacity(schema.fields().len());
for field in schema.fields() {
let key = schema.key_for(field.name());
let raw_val =
get_or::<String>(&key, field.default_value().cloned().unwrap_or_default()).await;
let num_val = get_or::<f64>(&key, 0.0).await.to_string();
let checked = get_or::<bool>(&key, false).await;
prepared.push(FieldVals {
raw_val,
num_val,
checked,
});
}
Ok(html! {
@if *self.saved() {
div class="admin-message admin-message-ok" { (&saved_msg) }
}
@if *self.error() {
div class="admin-message admin-message-error" { (&error_msg) }
}
form class="admin-config-form" method="post" action=(action) {
@for (i, field) in schema.fields().iter().enumerate() {
@let pf = &prepared[i];
div class="admin-form-field" {
label class="admin-form-label" for=(field.name()) { (field.label()) }
@match field.field_type() {
SettingFieldType::Text { max_length } => {
@if let Some(max) = max_length {
input class="admin-form-input"
type="text"
id=(field.name())
name=(field.name())
value=(&pf.raw_val)
maxlength=(max)
required[*field.required()];
} @else {
input class="admin-form-input"
type="text"
id=(field.name())
name=(field.name())
value=(&pf.raw_val)
required[*field.required()];
}
}
SettingFieldType::Number { min, max } => {
@if let (Some(lo), Some(hi)) = (min, max) {
input class="admin-form-input"
type="number"
id=(field.name())
name=(field.name())
value=(&pf.num_val)
min=(lo)
max=(hi)
required[*field.required()];
} @else if let Some(lo) = min {
input class="admin-form-input"
type="number"
id=(field.name())
name=(field.name())
value=(&pf.num_val)
min=(lo)
required[*field.required()];
} @else if let Some(hi) = max {
input class="admin-form-input"
type="number"
id=(field.name())
name=(field.name())
value=(&pf.num_val)
max=(hi)
required[*field.required()];
} @else {
input class="admin-form-input"
type="number"
id=(field.name())
name=(field.name())
value=(&pf.num_val)
required[*field.required()];
}
}
SettingFieldType::Boolean => {
input class="admin-form-checkbox"
type="checkbox"
id=(field.name())
name=(field.name())
value="true"
checked[pf.checked];
}
SettingFieldType::Select { options } => {
select class="admin-form-select"
id=(field.name())
name=(field.name())
required[*field.required()]
{
@for (val, label) in options {
@if &pf.raw_val == val {
option value=(val) selected { (label) }
} @else {
option value=(val) { (label) }
}
}
}
}
}
@if let Some(help) = field.help_text() {
small class="admin-form-help" { (help) }
}
}
}
div class="admin-form-actions" {
button class="admin-btn admin-btn-primary" type="submit" {
(&save_label)
}
}
}
})
}
}
#[builder_impl]
impl ConfigForm {
/// Crea el componente con el [`SettingsSchema`] dado.
#[builder_skip]
pub fn with_schema(schema: SettingsSchema) -> Self {
ConfigForm {
schema: Some(schema),
..Self::default()
}
}
pub fn with_action_path(mut self, v: impl Into<Option<Route>>) -> Self {
if let Some(v) = v.into() {
self.action_path = Some(v);
}
self
}
#[builder_skip]
pub(crate) fn with_saved(mut self, saved: bool, error: bool) -> Self {
self.saved = saved;
self.error = error;
self
}
}

View file

@ -0,0 +1,3 @@
//! Entidades SeaORM de `pagetop-admin`.
pub(crate) mod setting;

View file

@ -0,0 +1,18 @@
use pagetop_seaorm::db::*;
/// Entidad SeaORM para la tabla `settings`.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "settings")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub key: String,
pub scope: String,
pub value: String,
pub updated_at: DateTimeUtc,
pub updated_by: Option<i32>,
}
#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -0,0 +1,14 @@
use thiserror::Error;
/// Errores que puede producir la extensión `pagetop-admin`.
#[derive(Debug, Error)]
pub enum AdminError {
#[error("settings DB error: {0}")]
DbError(#[from] pagetop_seaorm::db::DbErr),
#[error("settings serialization error: {0}")]
SerializeError(#[from] serde_json::Error),
#[error("setting key not found: {0}")]
NotFound(String),
}

View file

@ -0,0 +1,214 @@
use std::collections::HashMap;
use pagetop::prelude::*;
use crate::LOCALES_ADMIN;
use crate::component::{AdminFrame, ConfigForm};
use crate::registry::{self, AdminPageKind, AdminSection};
use crate::settings;
// **< Dashboard >**********************************************************************************
/// GET /admin - Dashboard de administración: lista todas las secciones disponibles.
///
/// Devuelve [`ErrorPage::AccessDenied`] si el usuario actual no tiene acceso a ninguna página.
pub async fn dashboard(request: HttpRequest) -> Result<Markup, ErrorPage> {
let cx = Context::new(request.clone());
let sections = registry::global().ordered_sections();
let title = Lc::t("dashboard-title", &LOCALES_ADMIN);
let content = render_sections(&cx, &sections)
.ok_or_else(|| ErrorPage::AccessDenied(Some(request.clone())))?;
Page::admin(request)
.with_title(title.clone())
.with_child(AdminFrame::new().with_title(title).with_child(content))
.render()
.await
}
// **< Sección >************************************************************************************
/// GET /admin/{section} - Página de aterrizaje de una sección: lista sus páginas.
///
/// Sólo se monta (ver `configure_router()` en `lib.rs`) para las secciones cuyo `path` no coincida
/// con ninguna [`AdminPage`](crate::registry::AdminPage) registrada explícitamente por una
/// extensión; si una extensión reclama esa ruta con su propia página, esa página gana y este
/// handler nunca llega a montarse para ella.
///
/// Devuelve [`ErrorPage::AccessDenied`] si el usuario actual no tiene acceso a ninguna de sus
/// páginas.
pub async fn section_page(request: HttpRequest) -> Result<Markup, ErrorPage> {
let path = request.path().to_owned();
let reg = registry::global();
let section = reg
.sections()
.values()
.find(|s| s.path == path)
.ok_or_else(|| ErrorPage::NotFound(Some(request.clone())))?;
let cx = Context::new(request.clone());
let title = section.title.clone();
let content = render_sections(&cx, &[section])
.ok_or_else(|| ErrorPage::AccessDenied(Some(request.clone())))?;
Page::admin(request)
.with_title(title.clone())
.with_child(AdminFrame::new().with_title(title).with_child(content))
.render()
.await
}
// **< Render compartido >**************************************************************************
// Rejilla de secciones con sus páginas (título + descripción). La usan tanto `dashboard()` (todas
// las secciones) como `section_page()` (una sola), filtrando siempre por permiso del usuario
// actual. Devuelve `None` si no queda ninguna sección visible con páginas accesibles.
fn render_sections(cx: &Context, candidates: &[&'static AdminSection]) -> Option<Container> {
let reg = registry::global();
let sections_with_pages: Vec<_> = candidates
.iter()
.copied()
.map(|section| {
let pages: Vec<_> = reg
.pages_for_section(&section.key)
.into_iter()
.filter(|p| p.is_accessible(cx))
.collect();
(section, pages)
})
.filter(|(_, pages)| !pages.is_empty())
.collect();
if sections_with_pages.is_empty() {
return None;
}
let mut sections = Container::new().with_prop(PropsOp::add_classes("admin-dashboard-sections"));
for (section, pages) in sections_with_pages {
sections = sections.with_child(
Container::new()
.with_prop(PropsOp::add_classes("admin-dashboard-section"))
.with_child(Html::with(move |cx| {
html! {
h2 class="admin-dashboard-section-title" {
a href=(cx.route(section.path.as_str())) { (section.title.using(cx)) }
}
ul class="admin-dashboard-section-links" {
@for page in &pages {
li class="admin-dashboard-link" {
a href=(cx.route(page.path.as_str())) {
(page.title.using(cx))
}
@if let Some(description) = &page.description {
span class="admin-dashboard-link-desc" {
" - " (description.using(cx))
}
}
}
}
}
}
})),
);
}
Some(sections)
}
// **< Config form - GET >**************************************************************************
/// GET de una página [`AdminPageKind::ConfigForm`].
///
/// Lee el schema del registro y renderiza el formulario con los valores actuales.
pub async fn config_form_get(request: HttpRequest) -> Result<Markup, ErrorPage> {
let path = request.path().to_owned();
let reg = registry::global();
let page = reg
.pages()
.get(&path)
.ok_or_else(|| ErrorPage::NotFound(Some(request.clone())))?;
let AdminPageKind::ConfigForm(ref schema) = page.kind else {
return Err(ErrorPage::NotFound(Some(request.clone())));
};
require_permission(&request, page.permission)?;
let title = page.title.clone();
let mut form = ConfigForm::with_schema(schema.clone());
let mut cx = Context::new(request.clone());
let content = form.render(&mut cx).await;
Page::admin(request)
.with_title(title.clone())
.with_child(
AdminFrame::new()
.with_title(title)
.with_child(Html::with(move |_| content.clone())),
)
.render()
.await
}
// **< Config form - POST >*************************************************************************
/// POST de una página [`AdminPageKind::ConfigForm`].
///
/// Valida y persiste los valores en `settings`, luego redirige al GET con
/// estado `saved=true`.
pub async fn config_form_post(
request: HttpRequest,
web::Form(data): web::Form<HashMap<String, String>>,
) -> Result<Markup, ErrorPage> {
let path = request.path().to_owned();
let reg = registry::global();
let page = reg
.pages()
.get(&path)
.ok_or_else(|| ErrorPage::NotFound(Some(request.clone())))?;
let AdminPageKind::ConfigForm(ref schema) = page.kind else {
return Err(ErrorPage::NotFound(Some(request.clone())));
};
require_permission(&request, page.permission)?;
let mut save_error = false;
for field in schema.fields() {
let key = schema.key_for(field.name());
// Los checkbox HTML sólo envían el campo si están marcados.
let raw = match field.field_type() {
crate::settings::SettingFieldType::Boolean => data
.get(field.name())
.map(|s| s.as_str())
.unwrap_or("false")
.to_owned(),
_ => {
let Some(raw) = data.get(field.name()) else {
if *field.required() {
save_error = true;
}
continue;
};
raw.clone()
}
};
settings::set::<String>(&key, &raw, schema.scope(), None).await;
}
let title = page.title.clone();
let mut form = ConfigForm::with_schema(schema.clone()).with_saved(!save_error, save_error);
let mut cx = Context::new(request.clone());
let content = form.render(&mut cx).await;
Page::admin(request)
.with_title(title.clone())
.with_child(
AdminFrame::new()
.with_title(title)
.with_child(Html::with(move |_| content.clone())),
)
.render()
.await
}

View file

@ -0,0 +1,195 @@
/*!
<div align="center">
<h1>PageTop Admin</h1>
<p>Panel de administración extensible para <strong>PageTop</strong>.</p>
</div>
## Guía rápida
Declara la dependencia en tu `Cargo.toml` activando el motor de base de datos:
```toml
[dependencies]
pagetop-admin = { version = "...", features = ["sqlite"] }
```
Añade `&pagetop_admin::Admin` a las dependencias de tu extensión y declara tus
secciones, páginas o configuración:
```rust,no_run
use pagetop::prelude::*;
use pagetop_admin::prelude::*;
use pagetop_admin::settings::{SettingField, SettingsSchema};
pub struct MyApp;
#[async_trait]
impl Extension for MyApp {
fn dependencies(&self) -> Vec<ExtensionRef> {
vec![&pagetop_admin::Admin]
}
fn actions(&self) -> Vec<ActionBox> {
actions![
DeclareAdminPages::new(declare_pages),
]
}
fn configure_router(&self, router: Router) -> Router {
router.route("/admin/config/myapp", web::get(config_handler))
}
}
fn declare_pages(bag: &mut PageBag) {
bag.add(AdminPage {
path: "/admin/config/myapp".to_owned(),
section: "config".to_owned(),
title: Lc::n("My App"),
description: Some(Lc::n("Configure My App.")),
weight: 0,
permission: &MyPermission::Config,
kind: AdminPageKind::View,
});
}
#[derive(Clone, Copy, Debug)]
enum MyPermission {
Config,
}
impl Permission for MyPermission {
fn key(&self) -> CowStr {
match self {
Self::Config => "myapp:config".into(),
}
}
}
async fn config_handler(request: HttpRequest) -> Result<Markup, ErrorPage> {
let schema = SettingsSchema::new("myapp.config")
.with_field(SettingField::text("site_name", "Site name").with_required(true));
let title = "My App configuration";
let mut form = ConfigForm::with_schema(schema);
let mut cx = Context::new(request.clone());
let content = form.render(&mut cx).await;
Page::admin(request)
.with_title(Lc::n(title))
.with_child(
AdminFrame::new()
.with_title(Lc::n(title))
.with_child(Html::with(move |_| content.clone())),
)
.render()
.await
}
```
Para formularios de configuración enteramente automáticos (GET y POST gestionados
por `pagetop-admin`), usa `AdminPageKind::ConfigForm(schema)` en lugar de `View` y
no registres la ruta tú mismo.
*/
use pagetop::prelude::*;
use pagetop_seaorm::install_migrations;
include_locales!(LOCALES_ADMIN);
/// Ruta raíz del panel de administración. No es configurable: otras piezas del ecosistema (p. ej.
/// las rutas de `pagetop-user`, `/admin/user/...`) ya asumen este valor de forma literal. Es
/// pública para que otras extensiones enlacen al panel sin repetir la ruta.
pub const ADMIN_BASE_PATH: &str = "/admin";
pub mod action;
pub mod component;
pub mod error;
pub mod registry;
pub mod settings;
pub(crate) mod entity;
pub(crate) mod handlers;
pub(crate) mod migration;
pub(crate) mod seed;
pub use action::{
ActionBag, DeclareAdminActions, DeclareAdminPages, DeclareAdminSections, DeclareAdminTasks,
PageBag, SectionBag, TaskBag,
};
pub use component::{AdminFrame, AdminMenu, ConfigForm};
pub use registry::{AdminAction, AdminPage, AdminPageKind, AdminSection, AdminTask};
pub use settings::{SettingField, SettingFieldType, SettingsSchema};
/// Prelude de `pagetop-admin`.
pub mod prelude {
pub use crate::action::{
ActionBag, DeclareAdminActions, DeclareAdminPages, DeclareAdminSections, DeclareAdminTasks,
PageBag, SectionBag, TaskBag,
};
pub use crate::component::{AdminFrame, AdminMenu, ConfigForm};
pub use crate::error::AdminError;
pub use crate::registry::{AdminAction, AdminPage, AdminPageKind, AdminSection, AdminTask};
pub use crate::settings::{SettingField, SettingFieldType, SettingsSchema};
}
// **< Extension >**********************************************************************************
/// Implementa la extensión `pagetop-admin`.
pub struct Admin;
#[async_trait]
impl Extension for Admin {
fn name(&self) -> Lc {
Lc::t("extension_name", &LOCALES_ADMIN)
}
fn description(&self) -> Lc {
Lc::t("extension_description", &LOCALES_ADMIN)
}
fn dependencies(&self) -> Vec<ExtensionRef> {
vec![&pagetop_seaorm::SeaORM]
}
fn actions(&self) -> Vec<ActionBox> {
actions![action::DeclareAdminSections::new(seed::declare_default_sections).with_weight(-99),]
}
async fn initialize(&self) {
install_migrations!(m20260629_000001_create_settings);
registry::build();
// Región neutra del core: cualquier tema puede decidir renderizarla (o no) sin que
// `pagetop-admin` dependa de ninguno en concreto (ver `CoreRegions::Aside`).
InRegion::Global(&CoreRegions::Aside).add(component::AdminMenu::new());
}
fn configure_router(&self, router: Router) -> Router {
let base = ADMIN_BASE_PATH;
let reg = registry::global();
let mut r = router.route(base, web::get(handlers::dashboard));
// Página de aterrizaje de cada sección, salvo que una extensión ya haya reclamado esa
// misma ruta con su propia `AdminPage` (esa página gana y se monta más abajo).
for section in reg.sections().values() {
if !reg.pages().contains_key(&section.path) {
r = r.route(section.path.as_str(), web::get(handlers::section_page));
}
}
for (path, page) in reg.pages() {
if let AdminPageKind::ConfigForm(_) = &page.kind {
r = r.route(
path.as_str(),
web::get(handlers::config_form_get).post(handlers::config_form_post),
);
}
}
r
}
}

View file

@ -0,0 +1,21 @@
extension_name = PageTop Admin
extension_description = Extensible administration panel for PageTop.
# Dashboard
dashboard-title = Administration
dashboard-description = Manage your site.
# Sections
section-people = People
section-structure = Structure
section-config = Configuration
section-reports = Reports
section-help = Help
# Config form
config-save-label = Save configuration
config-saved-ok = Configuration saved.
config-saved-error = Could not save configuration.
# Navigation
admin-breadcrumb-home = Administration

View file

@ -0,0 +1,21 @@
extension_name = PageTop Admin
extension_description = Panel de administración extensible para PageTop.
# Dashboard
dashboard-title = Administración
dashboard-description = Gestiona tu sitio.
# Sections
section-people = Personas
section-structure = Estructura
section-config = Configuración
section-reports = Informes
section-help = Ayuda
# Config form
config-save-label = Guardar configuración
config-saved-ok = Configuración guardada.
config-saved-error = No se pudo guardar la configuración.
# Navigation
admin-breadcrumb-home = Administración

View file

@ -0,0 +1,3 @@
//! Migraciones de base de datos de `pagetop-admin`.
pub(crate) mod m20260629_000001_create_settings;

View file

@ -0,0 +1,49 @@
use pagetop_seaorm::migration::*;
/// Tabla `settings`: almacén clave-valor JSON para configuración persistente.
pub struct Migration;
#[pagetop::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(Settings::Table)
.if_not_exists()
.col(string_len(Settings::Key, 190).primary_key())
.col(string_len(Settings::Scope, 64))
.col(text(Settings::Value))
.col(timestamp(Settings::UpdatedAt).default(Expr::current_timestamp()))
.col(integer_null(Settings::UpdatedBy))
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_settings_scope")
.table(Settings::Table)
.col(Settings::Scope)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Settings::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum Settings {
Table,
Key,
Scope,
Value,
UpdatedAt,
UpdatedBy,
}

View file

@ -0,0 +1,279 @@
//! Registro global de secciones, páginas, tareas y acciones del panel de administración.
//!
//! Se construye una única vez durante `Extension::initialize()` y permanece
//! inmutable durante la vida de la aplicación.
use std::collections::BTreeMap;
use std::sync::OnceLock;
use pagetop::prelude::*;
use crate::action::{
ActionBag, DeclareAdminActions, DeclareAdminPages, DeclareAdminSections, DeclareAdminTasks,
PageBag, SectionBag, TaskBag,
};
use crate::settings::SettingsSchema;
// **< Tipos del registro >*************************************************************************
/// Sección del panel de administración (agrupación de páginas en el menú y en el *dashboard*).
///
/// Una sección no tiene permiso propio: se muestra a quien pueda acceder a alguna de sus páginas, y
/// se oculta si no le queda ninguna.
#[derive(Clone)]
pub struct AdminSection {
/// Identificador único de la sección (p. ej. `"config"`).
pub key: String,
/// Ruta base de la sección (p. ej. `"/admin/config"`).
pub path: String,
/// Título visible en el sidebar.
pub title: Lc,
/// Peso para ordenar en el sidebar (menor = antes).
pub weight: i32,
}
/// Página del panel de administración.
#[derive(Clone)]
pub struct AdminPage {
/// Ruta exacta de la página (p. ej. `"/admin/config/site"`).
pub path: String,
/// Clave de la sección a la que pertenece.
pub section: String,
/// Título visible en el sidebar y encabezado de página.
pub title: Lc,
/// Descripción breve (para el dashboard y listas de páginas), si tiene una.
pub description: Option<Lc>,
/// Peso dentro de la sección (menor = antes).
pub weight: i32,
/// Permiso requerido para acceder. Es obligatorio: cada página declara el suyo, y es también
/// el que decide si su sección se muestra.
pub permission: PermissionRef,
/// Tipo de página y datos asociados.
pub kind: AdminPageKind,
}
impl AdminPage {
/// Devuelve `true` si el usuario actual puede acceder a esta página.
pub fn is_accessible(&self, cx: &Context) -> bool {
cx.request()
.is_some_and(|request| has_permission(request, self.permission))
}
}
/// Variantes de comportamiento de una [`AdminPage`].
#[derive(Clone, Debug)]
pub enum AdminPageKind {
/// Página genérica cuyo handler se registra externamente.
View,
/// Formulario de configuración gestionado automáticamente por `pagetop-admin`.
ConfigForm(SettingsSchema),
}
/// Tarea (pestaña) local dentro de una página de administración.
#[derive(Clone)]
pub struct AdminTask {
/// Ruta de la tarea.
pub path: String,
/// Ruta de la página padre.
pub parent_path: String,
/// Etiqueta de la pestaña.
pub title: Lc,
/// Peso (menor = primera pestaña).
pub weight: i32,
/// Si es la tarea por defecto (pestaña activa al entrar a la página padre).
pub is_default: bool,
/// Permiso requerido (`None` = mismo que la página padre).
pub permission: Option<PermissionRef>,
}
/// Acción local (botón de acción) en una página de administración.
#[derive(Clone, Debug)]
pub struct AdminAction {
/// Ruta de destino de la acción.
pub url: String,
/// Página en la que aparece el botón.
pub for_path: String,
/// Etiqueta del botón.
pub title: Lc,
/// Peso (menor = primero).
pub weight: i32,
}
// **< AdminRegistry >******************************************************************************
/// Registro global del panel de administración, construido una sola vez en `initialize()`.
#[derive(Getters)]
pub struct AdminRegistry {
/// Devuelve las secciones indexadas por clave (`BTreeMap` para orden estable por clave).
sections: BTreeMap<String, AdminSection>,
/// Devuelve las páginas indexadas por ruta.
pages: BTreeMap<String, AdminPage>,
/// Devuelve las tareas indexadas por ruta de página padre.
tasks: BTreeMap<String, Vec<AdminTask>>,
/// Devuelve las acciones indexadas por ruta de página.
actions: BTreeMap<String, Vec<AdminAction>>,
}
impl AdminRegistry {
fn new() -> Self {
AdminRegistry {
sections: BTreeMap::new(),
pages: BTreeMap::new(),
tasks: BTreeMap::new(),
actions: BTreeMap::new(),
}
}
/// Devuelve las páginas de una sección ordenadas por peso.
pub fn pages_for_section(&self, section_key: &str) -> Vec<&AdminPage> {
let mut pages: Vec<&AdminPage> = self
.pages
.values()
.filter(|p| p.section == section_key)
.collect();
pages.sort_by_key(|p| p.weight);
pages
}
/// Devuelve las secciones ordenadas por peso.
pub fn ordered_sections(&self) -> Vec<&AdminSection> {
let mut sections: Vec<&AdminSection> = self.sections.values().collect();
sections.sort_by_key(|s| s.weight);
sections
}
/// Devuelve las tareas de una página ordenadas por peso.
pub fn tasks_for(&self, path: &str) -> Vec<&AdminTask> {
let Some(tasks) = self.tasks.get(path) else {
return vec![];
};
let mut t: Vec<&AdminTask> = tasks.iter().collect();
t.sort_by_key(|t| t.weight);
t
}
/// Devuelve las acciones de una página ordenadas por peso.
pub fn actions_for(&self, path: &str) -> Vec<&AdminAction> {
let Some(actions) = self.actions.get(path) else {
return vec![];
};
let mut a: Vec<&AdminAction> = actions.iter().collect();
a.sort_by_key(|a| a.weight);
a
}
}
// **< Registro global >****************************************************************************
static REGISTRY: OnceLock<AdminRegistry> = OnceLock::new();
/// Construye el registro despachando todas las acciones de declaración.
///
/// Se llama una sola vez desde `Admin::initialize()`.
pub(crate) fn build() {
let mut registry = AdminRegistry::new();
// Secciones
let mut section_bag = SectionBag {
sections: Vec::new(),
};
DeclareAdminSections::dispatch(&mut section_bag);
for s in section_bag.sections {
registry.sections.insert(s.key.clone(), s);
}
// Páginas
let mut page_bag = PageBag { pages: Vec::new() };
DeclareAdminPages::dispatch(&mut page_bag);
for p in page_bag.pages {
registry.pages.insert(p.path.clone(), p);
}
// Tareas
let mut task_bag = TaskBag { tasks: Vec::new() };
DeclareAdminTasks::dispatch(&mut task_bag);
for t in task_bag.tasks {
registry
.tasks
.entry(t.parent_path.clone())
.or_default()
.push(t);
}
// Acciones locales
let mut action_bag = ActionBag {
actions: Vec::new(),
};
DeclareAdminActions::dispatch(&mut action_bag);
for a in action_bag.actions {
registry
.actions
.entry(a.for_path.clone())
.or_default()
.push(a);
}
REGISTRY.set(registry).ok();
}
/// Accede al registro global del panel de administración.
///
/// # Panics
///
/// Entra en pánico si se llama antes de que `Admin::initialize()` haya completado.
pub fn global() -> &'static AdminRegistry {
REGISTRY.get().expect("AdminRegistry not initialized")
}
/// Devuelve `true` si el usuario de la petición actual tiene acceso a alguna página del panel, es
/// decir, si `/admin` no le devolvería un acceso denegado.
///
/// Permite a otras extensiones (p. ej. el menú de cuenta de `pagetop-user`) ofrecer el enlace a
/// la administración sólo cuando procede.
pub fn can_access_admin(cx: &Context) -> bool {
let reg = global();
reg.ordered_sections().into_iter().any(|section| {
reg.pages_for_section(&section.key)
.into_iter()
.any(|page| page.is_accessible(cx))
})
}
// **< Menú de administración >*********************************************************************
/// Construye la barra de navegación de administración para el usuario de la petición actual: un
/// enlace al *dashboard* seguido de un desplegable por sección visible, con sus páginas accesibles
/// dentro.
///
/// Pensado para que un tema la use como navegación de `CoreTemplates::Admin` -- ver
/// [`crate::component::AdminMenu`]. `pagetop-admin` no impone ningún marcado propio: la
/// [`Navbar`] resultante se renderiza con su aspecto por defecto salvo que el tema lo intercepte
/// en [`Theme::render_component()`](pagetop::core::theme::Theme::render_component). Se construye
/// enteramente con componentes del núcleo (`Navbar`/`Nav`/`Dropdown`), sin depender de ningún
/// tema concreto.
pub fn admin_navbar(cx: &Context) -> Navbar {
let reg = global();
let mut nav = Nav::new().with_item(nav::Item::link(
Lc::t("dashboard-title", &crate::LOCALES_ADMIN),
crate::ADMIN_BASE_PATH,
));
for section in reg.ordered_sections() {
let pages: Vec<_> = reg
.pages_for_section(&section.key)
.into_iter()
.filter(|p| p.is_accessible(cx))
.collect();
if pages.is_empty() {
continue;
}
let mut menu = Dropdown::new().with_title(section.title.clone());
for page in pages {
menu = menu.with_item(dropdown::Item::link(page.title.clone(), page.path.clone()));
}
nav = nav.with_item(nav::Item::dropdown(menu));
}
Navbar::simple().with_item(navbar::Item::nav(nav))
}

View file

@ -0,0 +1,45 @@
use pagetop::prelude::*;
use crate::ADMIN_BASE_PATH;
use crate::LOCALES_ADMIN;
use crate::action::SectionBag;
use crate::registry::AdminSection;
/// Declara las secciones incorporadas del panel de administración.
///
/// Se registra en `Admin::actions()` como `DeclareAdminSections::new(declare_default_sections)`
/// con peso negativo para ejecutarse antes que las extensiones de terceros.
pub(crate) fn declare_default_sections(bag: &mut SectionBag) {
let base = ADMIN_BASE_PATH;
bag.add(AdminSection {
key: "people".to_owned(),
path: format!("{}/people", base),
title: Lc::t("section-people", &LOCALES_ADMIN),
weight: 10,
});
bag.add(AdminSection {
key: "structure".to_owned(),
path: format!("{}/structure", base),
title: Lc::t("section-structure", &LOCALES_ADMIN),
weight: 20,
});
bag.add(AdminSection {
key: "config".to_owned(),
path: format!("{}/config", base),
title: Lc::t("section-config", &LOCALES_ADMIN),
weight: 30,
});
bag.add(AdminSection {
key: "reports".to_owned(),
path: format!("{}/reports", base),
title: Lc::t("section-reports", &LOCALES_ADMIN),
weight: 40,
});
bag.add(AdminSection {
key: "help".to_owned(),
path: format!("{}/help", base),
title: Lc::t("section-help", &LOCALES_ADMIN),
weight: 50,
});
}

View file

@ -0,0 +1,235 @@
//! Almacén de configuración persistente basado en `settings`.
//!
//! Proporciona una API async para leer y escribir valores JSON en la tabla `settings`.
use pagetop::prelude::*;
use pagetop_seaorm::db::{
ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter, dbconn,
};
use serde::{Serialize, de::DeserializeOwned};
use crate::entity::setting::{ActiveModel, Column, Entity};
use crate::error::AdminError;
// **< API pública >********************************************************************************
/// Lee un valor persistido, devolviendo el `Default` del tipo si no existe.
pub async fn get<T: DeserializeOwned + Default>(key: &str) -> T {
get_async(key).await.unwrap_or_default()
}
/// Lee un valor persistido, devolviendo `default` si no existe o hay error.
pub async fn get_or<T: DeserializeOwned>(key: &str, default: T) -> T {
get_async(key).await.unwrap_or(default)
}
/// Escribe un valor en la tabla `settings`.
///
/// Si la clave no existe se inserta; si existe, se actualiza.
pub async fn set<T: Serialize>(key: &str, value: &T, scope: &str, user_id: Option<i32>) {
set_async(key, value, scope, user_id).await.ok();
}
/// Elimina una entrada de `settings` por clave.
pub async fn delete(key: &str) {
delete_async(key).await.ok();
}
/// Devuelve todos los pares `(key, value_json)` de un `scope` dado.
pub async fn list_scope(scope: &str) -> Vec<(String, String)> {
list_scope_async(scope).await.unwrap_or_default()
}
// **< Implementación asíncrona >*******************************************************************
async fn get_async<T: DeserializeOwned>(key: &str) -> Result<T, AdminError> {
let model = Entity::find_by_id(key)
.one(dbconn())
.await?
.ok_or_else(|| AdminError::NotFound(key.to_owned()))?;
Ok(serde_json::from_str(&model.value)?)
}
async fn set_async<T: Serialize>(
key: &str,
value: &T,
scope: &str,
user_id: Option<i32>,
) -> Result<(), AdminError> {
let value_json = serde_json::to_string(value)?;
let now = Utc::now();
let existing = Entity::find_by_id(key).one(dbconn()).await?;
if existing.is_some() {
let model = ActiveModel {
key: ActiveValue::Unchanged(key.to_owned()),
scope: ActiveValue::Set(scope.to_owned()),
value: ActiveValue::Set(value_json),
updated_at: ActiveValue::Set(now),
updated_by: ActiveValue::Set(user_id),
};
model.update(dbconn()).await?;
} else {
let model = ActiveModel {
key: ActiveValue::Set(key.to_owned()),
scope: ActiveValue::Set(scope.to_owned()),
value: ActiveValue::Set(value_json),
updated_at: ActiveValue::Set(now),
updated_by: ActiveValue::Set(user_id),
};
model.insert(dbconn()).await?;
}
Ok(())
}
async fn delete_async(key: &str) -> Result<(), AdminError> {
Entity::delete_by_id(key).exec(dbconn()).await?;
Ok(())
}
async fn list_scope_async(scope: &str) -> Result<Vec<(String, String)>, AdminError> {
let rows = Entity::find()
.filter(Column::Scope.eq(scope))
.all(dbconn())
.await?;
Ok(rows.into_iter().map(|m| (m.key, m.value)).collect())
}
// **< Tipos de esquema >***************************************************************************
/// Tipo de campo de configuración para un [`SettingsSchema`].
#[derive(Clone, Debug)]
pub enum SettingFieldType {
/// Texto libre con longitud máxima opcional.
Text { max_length: Option<usize> },
/// Número (entero o decimal).
Number { min: Option<f64>, max: Option<f64> },
/// Casilla de verificación (booleano).
Boolean,
/// Lista de opciones `(valor, etiqueta)`.
Select { options: Vec<(String, String)> },
}
/// Definición de un campo dentro de un [`SettingsSchema`].
#[derive(Clone, Debug, Getters)]
pub struct SettingField {
/// Devuelve el nombre del campo (clave dentro del scope, p. ej. `"site_name"`).
name: String,
/// Devuelve la etiqueta visible en el formulario.
label: String,
/// Devuelve el tipo de campo.
field_type: SettingFieldType,
/// Devuelve si el campo es obligatorio.
required: bool,
/// Devuelve el texto de ayuda opcional bajo el campo.
help_text: Option<String>,
/// Devuelve el valor por defecto como cadena JSON.
default_value: Option<String>,
}
#[builder_impl]
impl SettingField {
/// Crea un campo de texto con nombre y etiqueta.
pub fn text(name: impl Into<String>, label: impl Into<String>) -> Self {
SettingField {
name: name.into(),
label: label.into(),
field_type: SettingFieldType::Text { max_length: None },
required: false,
help_text: None,
default_value: None,
}
}
/// Crea un campo numérico con nombre y etiqueta.
pub fn number(name: impl Into<String>, label: impl Into<String>) -> Self {
SettingField {
name: name.into(),
label: label.into(),
field_type: SettingFieldType::Number {
min: None,
max: None,
},
required: false,
help_text: None,
default_value: None,
}
}
/// Crea un campo booleano con nombre y etiqueta.
pub fn boolean(name: impl Into<String>, label: impl Into<String>) -> Self {
SettingField {
name: name.into(),
label: label.into(),
field_type: SettingFieldType::Boolean,
required: false,
help_text: None,
default_value: None,
}
}
/// Crea un campo de selección con nombre, etiqueta y opciones.
pub fn select(
name: impl Into<String>,
label: impl Into<String>,
options: Vec<(String, String)>,
) -> Self {
SettingField {
name: name.into(),
label: label.into(),
field_type: SettingFieldType::Select { options },
required: false,
help_text: None,
default_value: None,
}
}
/// Establece si el campo es obligatorio.
pub fn with_required(mut self, required: bool) -> Self {
self.required = required;
self
}
/// Añade texto de ayuda bajo el campo.
pub fn with_help(mut self, text: impl Into<String>) -> Self {
self.help_text = Some(text.into());
self
}
/// Establece el valor por defecto (como valor JSON serializado).
pub fn with_default<T: Serialize>(mut self, value: &T) -> Self {
self.default_value = serde_json::to_string(value).ok();
self
}
}
/// Esquema de formulario de configuración: describe un grupo de ajustes en un scope.
#[derive(Clone, Debug, Getters)]
pub struct SettingsSchema {
/// Devuelve el identificador del grupo (prefijo de las claves en `settings`).
scope: String,
/// Devuelve los campos del formulario en orden de presentación.
fields: Vec<SettingField>,
}
#[builder_impl]
impl SettingsSchema {
/// Crea un nuevo esquema vacío para el `scope` dado.
pub fn new(scope: impl Into<String>) -> Self {
SettingsSchema {
scope: scope.into(),
fields: Vec::new(),
}
}
/// Añade un campo al esquema.
pub fn with_field(mut self, field: SettingField) -> Self {
self.fields.push(field);
self
}
/// Devuelve la clave completa de un campo: `"{scope}.{field_name}"`.
pub fn key_for(&self, field_name: &str) -> String {
util::join!(&self.scope, ".", field_name)
}
}

View file

@ -17,8 +17,5 @@ authors.workspace = true
[dependencies]
pagetop.workspace = true
[dev-dependencies]
tokio.workspace = true
[build-dependencies]
pagetop-build.workspace = true

View file

@ -11,14 +11,13 @@
</div>
## 🧭 Sobre PageTop
## Sobre PageTop
[PageTop](https://docs.rs/pagetop) es un entorno de desarrollo que reivindica la esencia de la web
clásica para crear soluciones web SSR (*renderizadas en el servidor*) modulares, extensibles y
configurables, basadas en HTML, CSS y JavaScript.
## ⚡️ Guía rápida
## Guía rápida
Igual que con otras extensiones, **añade la dependencia** a tu `Cargo.toml`:
@ -35,6 +34,7 @@ use pagetop::prelude::*;
struct MyApp;
#[async_trait]
impl Extension for MyApp {
fn dependencies(&self) -> Vec<ExtensionRef> {
vec![
@ -44,11 +44,6 @@ impl Extension for MyApp {
]
}
}
#[pagetop::main]
async fn main() -> std::io::Result<()> {
Application::prepare(&MyApp).run()?.await
}
```
Y **selecciona el tema en la configuración** de la aplicación:
@ -67,26 +62,24 @@ use pagetop_aliner::Aliner;
async fn homepage(request: HttpRequest) -> Result<Markup, ErrorPage> {
Page::new(request)
.with_theme(&Aliner)
.add_child(
.with_child(
Block::new()
.with_title(L10n::l("sample_title"))
.add_child(Html::with(|cx| html! {
p { (L10n::l("sample_content").using(cx)) }
.with_title(Lc::l("sample_title"))
.with_child(Html::with(|cx| html! {
p { (Lc::l("sample_content").using(cx)) }
})),
)
.render()
.render().await
}
```
## 🚧 Advertencia
## Advertencia
**PageTop** es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su
ecosistema. Su API está sujeta a cambios frecuentes. No se recomienda su uso en producción, al menos
hasta que se libere la versión **1.0.0**.
## 📜 Licencia
## Licencia
El código está disponible bajo una doble licencia:

View file

@ -3,14 +3,13 @@
<h1>PageTop Aliner</h1>
<p>Tema para <strong>PageTop</strong> que muestra esquemáticamente la composición de las páginas HTML.</p>
<p>Tema de <strong>PageTop</strong> que muestra esquemáticamente la composición de las páginas HTML.</p>
[![Doc API](https://img.shields.io/docsrs/pagetop-aliner?label=Doc%20API&style=for-the-badge&logo=Docs.rs)](https://docs.rs/pagetop-aliner)
[![Crates.io](https://img.shields.io/crates/v/pagetop-aliner.svg?style=for-the-badge&logo=ipfs)](https://crates.io/crates/pagetop-aliner)
[![Descargas](https://img.shields.io/crates/d/pagetop-aliner.svg?label=Descargas&style=for-the-badge&logo=transmission)](https://crates.io/crates/pagetop-aliner)
[![Licencia](https://img.shields.io/badge/license-MIT%2FApache-blue.svg?label=Licencia&style=for-the-badge)](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/extensions/pagetop-aliner#licencia)
<br>
</div>
## Sobre PageTop
@ -19,8 +18,7 @@
clásica para crear soluciones web SSR (*renderizadas en el servidor*) modulares, extensibles y
configurables, basadas en HTML, CSS y JavaScript.
# ⚡️ Guía rápida
## Guía rápida
Igual que con otras extensiones, **añade la dependencia** a tu `Cargo.toml`:
@ -37,6 +35,7 @@ use pagetop::prelude::*;
struct MyApp;
#[async_trait]
impl Extension for MyApp {
fn dependencies(&self) -> Vec<ExtensionRef> {
vec![
@ -46,11 +45,6 @@ impl Extension for MyApp {
]
}
}
#[pagetop::main]
async fn main() -> std::io::Result<()> {
Application::prepare(&MyApp).run()?.await
}
```
Y **selecciona el tema en la configuración** de la aplicación:
@ -71,12 +65,12 @@ async fn homepage(request: HttpRequest) -> Result<Markup, ErrorPage> {
.with_theme(&Aliner)
.with_child(
Block::new()
.with_title(L10n::l("sample_title"))
.with_title(Lc::l("sample_title"))
.with_child(Html::with(|cx| html! {
p { (L10n::l("sample_content").using(cx)) }
p { (Lc::l("sample_content").using(cx)) }
})),
)
.render()
.render().await
}
```
*/
@ -96,13 +90,14 @@ include_locales!(LOCALES_ALINER);
/// - Preparar ejemplos y documentación, sin dependencias visuales (CSS/JS) innecesarias.
pub struct Aliner;
#[async_trait]
impl Extension for Aliner {
fn name(&self) -> L10n {
L10n::t("extension_name", &LOCALES_ALINER)
fn name(&self) -> Lc {
Lc::t("extension_name", &LOCALES_ALINER)
}
fn description(&self) -> L10n {
L10n::t("extension_description", &LOCALES_ALINER)
fn description(&self) -> Lc {
Lc::t("extension_description", &LOCALES_ALINER)
}
fn theme(&self) -> Option<ThemeRef> {
@ -115,25 +110,21 @@ impl Extension for Aliner {
}
}
#[async_trait]
impl Theme for Aliner {
fn before_render_page_body(&self, page: &mut Page) {
page.alter_assets(AssetsOp::AddStyleSheet(
StyleSheet::from("/pagetop/css/normalize.css")
.with_version("8.0.1")
.with_weight(-99),
))
.alter_assets(AssetsOp::AddStyleSheet(
StyleSheet::from("/pagetop/css/basic.css")
page.alter_assets(
StyleSheet::from("/pagetop/css/basic.min.css")
.with_version(PAGETOP_VERSION)
.with_weight(-99),
))
.alter_assets(AssetsOp::AddStyleSheet(
)
.alter_assets(
StyleSheet::from("/aliner/css/styles.css")
.with_version(env!("CARGO_PKG_VERSION"))
.with_weight(-99),
))
)
.alter_child_in(
&DefaultRegion::Footer,
&CoreRegions::Footer,
ChildOp::AddIfEmpty(PoweredBy::new().into()),
);
}

View file

@ -18,8 +18,5 @@ authors.workspace = true
pagetop.workspace = true
serde.workspace = true
[dev-dependencies]
tokio.workspace = true
[build-dependencies]
pagetop-build.workspace = true

View file

@ -11,14 +11,13 @@
</div>
## 🧭 Sobre PageTop
## Sobre PageTop
[PageTop](https://docs.rs/pagetop) es un entorno de desarrollo que reivindica la esencia de la web
clásica para crear soluciones web SSR (*renderizadas en el servidor*) modulares, extensibles y
configurables, basadas en HTML, CSS y JavaScript.
## ⚡️ Guía rápida
## Guía rápida
Igual que con otras extensiones, **añade la dependencia** a tu `Cargo.toml`:
@ -35,6 +34,7 @@ use pagetop::prelude::*;
struct MyApp;
#[async_trait]
impl Extension for MyApp {
fn dependencies(&self) -> Vec<ExtensionRef> {
vec![
@ -44,11 +44,6 @@ impl Extension for MyApp {
]
}
}
#[pagetop::main]
async fn main() -> std::io::Result<()> {
Application::prepare(&MyApp).run()?.await
}
```
Y **selecciona el tema en la configuración** de la aplicación:
@ -67,33 +62,76 @@ use pagetop_bootsier::Bootsier;
async fn homepage(request: HttpRequest) -> Result<Markup, ErrorPage> {
Page::new(request)
.with_theme(&Bootsier)
.add_child(
.with_child(
Block::new()
.with_title(L10n::l("sample_title"))
.add_child(Html::with(|cx| html! {
p { (L10n::l("sample_content").using(cx)) }
.with_title(Lc::l("sample_title"))
.with_child(Html::with(|cx| html! {
p { (Lc::l("sample_content").using(cx)) }
})),
)
.render().await
}
```
## Plantillas
Bootsier maqueta las dos plantillas de PageTop (`CoreTemplates`) con la misma cabecera, contenido
y pie: `Standard`, la plantilla por defecto de cualquier página; y `Admin`, que se activa creando
la página con `Page::admin()` en lugar de `Page::new()`. Ambas se maquetan igual hoy -- la
distinción entre página "normal" y de administración no vive en el maquetado, sino en qué
extensión registra contenido en cada región y para qué plantilla se autolimita (por ejemplo, el
menú de `pagetop-admin`, ver más abajo).
```rust,no_run
use pagetop::prelude::*;
async fn about(request: HttpRequest) -> Result<Markup, ErrorPage> {
Page::new(request)
.with_child(Html::with(|_| html! {
h1 { "Sobre nosotros" }
p { "Texto de presentación." }
}))
.render()
}
```
## Menú de administración
## 📚 Créditos
`pagetop-admin` (si está presente en la aplicación) registra su propio menú de secciones y
páginas en `CoreRegions::Aside`, autolimitado a páginas con la plantilla `Admin` -- no requiere
ningún registro por parte de Bootsier. Se construye con `Navbar`/`Nav`/`Dropdown`, los mismos
componentes disponibles para cualquier página de la aplicación.
## Selector de modo de color
`bs::theme_toggle()` crea un ítem de menú con el selector de modo de color (claro / oscuro /
automático), pensado para añadirse a cualquier `Nav` de la aplicación, no sólo a las páginas de
administración:
```rust,no_run
use pagetop::prelude::*;
use pagetop_bootsier::theme::*;
let navbar = bs::Navbar::simple().with_item(bs::navbar::Item::nav(
bs::Nav::new()
.with_item(bs::nav::Item::link(Lc::n("Home"), "/"))
.with_item(bs::theme_toggle()),
));
```
## Créditos
Este *crate* integra la biblioteca de estilos [Bootstrap 5.3.8](https://getbootstrap.com/) para
definir el comportamiento, la apariencia y los componentes de la interfaz. Bootstrap se distribuye
bajo licencia [MIT](https://github.com/twbs/bootstrap/blob/main/LICENSE).
## 🚧 Advertencia
## Advertencia
**PageTop** es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su
ecosistema. Su API está sujeta a cambios frecuentes. No se recomienda su uso en producción, al menos
hasta que se libere la versión **1.0.0**.
## 📜 Licencia
## Licencia
El código está disponible bajo una doble licencia:

View file

@ -0,0 +1,244 @@
// Bootsier CSS rules: self-hosted fonts, form elements, regions and components.
// Self-hosted Source Sans 3 (SIL OFL 1.1), served from /bootsier/fonts. Declared as the primary
// font family in $font-family-sans-serif (_bootsier-variables.scss).
// Font URLs must match the Preload declarations in src/lib.rs; a mismatch causes double downloads.
@font-face {
font-family: "Source Sans 3";
src: url("/bootsier/fonts/bootsier.font.woff2") format("woff2");
font-weight: 200 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Source Sans 3";
src: url("/bootsier/fonts/bootsier.font.italic.woff2") format("woff2");
font-weight: 200 900;
font-style: italic;
font-display: swap;
}
// Font-relative top offset to keep the skip-link hidden at any font size.
.skip-link {
top: -3em;
}
// Required field indicator in forms.
.form-required {
color: var(--bs-danger);
margin: 0 0.25rem;
}
// Form field with consistent bottom margin.
.form-field {
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
}
// Button set (button::ButtonSet): spaces its buttons with a gap instead of relying on Bootstrap's
// .btn-group, which merges adjacent buttons into a single joined control.
.button-set {
display: flex;
flex-wrap: wrap;
gap: $spacer * .5;
margin: 1rem 0;
}
// Fieldset with border and floating legend.
fieldset {
position: relative;
background-color: var(--bs-body-bg);
border: var(--bs-border-width) solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
padding: 2rem 1rem 1rem;
margin: 2rem 0 1rem;
}
fieldset > legend {
position: absolute;
top: 0;
left: 1rem;
transform: translateY(-50%);
color: var(--bs-secondary-color);
background-color: var(--bs-body-bg);
border: var(--bs-border-width) solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
padding: 0.125rem 0.75rem;
font-size: ($font-size-sm + $font-size-base) / 2;
font-weight: var(--bs-font-weight-normal);
line-height: 1.25;
width: fit-content;
max-width: 75%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fieldset-description {
margin-bottom: 1rem;
}
// Gap between group label and first inline check.
.form-label + .form-check-inline {
margin-left: 1rem;
}
// Footer region.
.region-footer {
padding: 0.75rem 0 3rem;
text-align: center;
}
// Messages component. Classes are fixed by pagetop core (`.message`, `.message-info`,
// `.message-warning`, `.message-error`); reusing Bootstrap's own `.alert`/`.alert-*` styles keeps
// the palette in sync with the active Bootstrap theme instead of duplicating it here.
.message {
@extend .alert;
}
.message-info {
@extend .alert-info;
}
.message-warning {
@extend .alert-warning;
}
.message-error {
@extend .alert-danger;
}
// Sort indicator on sortable table headers (`table::SortLink`), using Bootstrap Icons'
// sort-up/sort-down glyphs instead of the plain triangle from basic.css.
.table-sort::after {
font-family: $bootstrap-icons-font;
content: "\f575"; // sort-down
margin-left: 0.25rem;
font-size: 0.8em;
opacity: 0.35;
}
.table-sort-asc::after {
content: "\f57b"; // sort-up
opacity: 1;
}
.table-sort-desc::after {
content: "\f575"; // sort-down
opacity: 1;
}
// Pager component.
// `.pagination`, `.page-item`, `.page-link`, `.active` and `.disabled` already come styled by
// Bootstrap, since Pager reuses that same markup (the ellipsis is a disabled `.page-link` span,
// same as any other cell). Only what Pager adds on top needs rules here: the <nav> wrapper and
// the jump-to-page form.
// <nav> wrapper: flex row that also holds the jump-to-page form.
.pager {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0 $spacer; // row-gap column-gap: no vertical gap when the summary drops to its own line.
margin-block: $spacer;
}
// Horizontal alignment set via PagerAlign.
.pager-align-start {
justify-content: flex-start;
}
.pager-align-center {
justify-content: center;
}
.pager-align-end {
justify-content: flex-end;
}
// Pushes the rest of .pager (pagination, jump form) to the opposite side. With
// PagerAlign::Start the summary swaps to the other end instead, since otherwise it would sit
// right next to a pagination that's already at the start. With PagerAlign::Center an auto margin
// wouldn't work -it always claims all the free space, so the pagination could never be genuinely
// centered- so instead the summary drops to its own line below, centered too.
.pager-summary {
color: var(--bs-secondary-color);
font-size: $font-size-sm;
align-self: flex-start;
margin-inline-end: auto;
}
.pager-align-start .pager-summary {
order: 1;
margin-inline-end: 0;
margin-inline-start: auto;
}
.pager-align-center .pager-summary {
order: 1;
flex-basis: 100%;
text-align: center;
margin-inline-end: 0;
}
// By default, .page-link-icon shows the text of pager_previous_label/pager_next_label
// ("Previous"/"Next"). The full accessible text lives separately, in the link's aria-label, so
// replacing the visible text here with a symbol does not affect accessibility. To use "‹"/"›"
// instead, uncomment:
/*
.page-link-icon {
display: inline-block;
font-size: 0;
}
.page-previous .page-link-icon::before {
content: "‹";
font-size: var(--bs-pagination-font-size);
display: inline-block;
transform: translateY(-0.1em) scale(1.5);
}
.page-next .page-link-icon::before {
content: "›";
font-size: var(--bs-pagination-font-size);
display: inline-block;
transform: translateY(-0.1em) scale(1.5);
}
*/
// Jump-to-page field and submit button, presented as a single joined control. Same block margins
// as `ol`/`ul`/`dl` in Bootstrap's reboot (literal values there too, not variables).
.pager-jump {
display: flex;
align-items: center;
margin-top: 0;
margin-bottom: 1rem;
}
// One focus ring for the whole control instead of one per element.
.pager-jump:focus-within {
border-radius: $input-border-radius;
box-shadow: $input-focus-box-shadow;
}
.pager-jump-input > .form-control:focus,
.pager-jump-button:focus {
box-shadow: none;
}
.pager-jump-input > .form-control {
// Extra width equals twice the field's own horizontal padding.
width: calc(var(--pager-jump-width, 1ch) + #{$input-padding-x * 4});
text-align: center;
appearance: textfield;
-moz-appearance: textfield;
// Logical (not physical) corners, so the shape flips correctly under RTL.
border-start-end-radius: 0;
border-end-end-radius: 0;
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
}
.pager-jump-input {
margin: 0;
}
.pager-jump-button {
@extend .btn-primary;
border-start-start-radius: 0;
border-end-start-radius: 0;
}

View file

@ -0,0 +1,5 @@
// Bootstrap Icons v1.13.1, fonts served from /bootsier/fonts.
$bootstrap-icons-font: "bootsier.icons";
$bootstrap-icons-font-dir: "/bootsier/fonts";
@import "bootstrap-icons-1.13.1/bootstrap-icons";

View file

@ -1,80 +1,14 @@
// Enable CSS Grid
// Bootstrap utility extensions. Imported just before utilities/api so variable changes take effect.
// CSS Grid (Bootstrap 5): disable legacy grid classes and enable native CSS Grid.
$enable-grid-classes: false;
$enable-cssgrid: true;
// Opacity
.bg-opacity-0 {
--bs-bg-opacity: 0;
}
.border-opacity-0 {
--bs-border-opacity: 0;
}
.text-opacity-0 {
--bs-text-opacity: 0;
}
.text-opacity-10 {
--bs-text-opacity: 0.1;
}
// FORMS
// Required field indicator
.form-required {
color: var(--bs-danger);
margin: 0 0.25rem;
}
// Form fields
.form-field {
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
}
// Fieldset
fieldset {
position: relative;
background-color: var(--bs-body-bg);
border: var(--bs-border-width) solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
padding: 2rem 1rem 1rem;
margin: 2rem 0 1rem;
}
fieldset > legend {
position: absolute;
top: 0;
left: 1rem;
transform: translateY(-50%);
background-color: var(--bs-body-bg);
border: var(--bs-border-width) solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
padding: 0.125rem 0.75rem;
font-size: $font-size-sm;
line-height: 1.25;
width: fit-content;
max-width: 75%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fieldset-description {
margin-bottom: 1rem;
}
// Check buttons, gap between label and first inline check
.form-label + .form-check-inline {
margin-left: 1rem;
}
// Extending utilities
// Extend the $utilities map with additional classes.
$utilities: map-merge(
$utilities,
(
// Individual border widths
// Individual border widths per side.
"border-top": (
property: border-top-width,
class: border-top,
@ -95,7 +29,7 @@ $utilities: map-merge(
class: border-start,
values: $border-widths
),
// Individual rounded values
// Individual corner radii.
"rounded-top-start": (
property: border-top-left-radius,
class: rounded-top-start,
@ -156,11 +90,18 @@ $utilities: map-merge(
pill: var(--#{$prefix}border-radius-pill)
)
),
// Opacity: add missing values (0 for bg/border/text; 10 for text) not in Bootstrap defaults.
"bg-opacity": map-merge(
map-get($utilities, "bg-opacity"),
(values: map-merge(map-get(map-get($utilities, "bg-opacity"), "values"), (0: 0)))
),
"border-opacity": map-merge(
map-get($utilities, "border-opacity"),
(values: map-merge(map-get(map-get($utilities, "border-opacity"), "values"), (0: 0)))
),
"text-opacity": map-merge(
map-get($utilities, "text-opacity"),
(values: map-merge(map-get(map-get($utilities, "text-opacity"), "values"), (0: 0, 10: .1)))
),
)
);
// Region Footer
.region-footer {
padding: .75rem 0 3rem;
text-align: center;
}

View file

@ -0,0 +1,11 @@
// Bootsier overrides for Bootstrap variables. Imported before bootstrap/scss/variables
// so that Bootstrap's !default declarations do not override these values.
$font-size-base: 1.125rem;
$navbar-brand-font-size: 1.75rem;
// Self-hosted Source Sans 3 (SIL OFL 1.1) as the primary font family; see the @font-face
// declarations in _bootsier-custom.scss.
$font-family-sans-serif: "Source Sans 3", system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji",
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";

View file

@ -0,0 +1,62 @@
(function () {
'use strict';
// htmx dispatches `htmx:confirm` in place of `window.confirm()` whenever the element that
// would issue a request carries `hx-confirm`. Intercepting it here upgrades every
// `hx-confirm` in the project to a Bootstrap modal, without touching the element that
// requested it or the request itself: cancelling is a no-op, confirming resumes the exact
// same request via `evt.detail.issueRequest(true)`.
if (typeof bootstrap === 'undefined' || !bootstrap.Modal) { return; }
var modalEl = null;
var questionEl = null;
var pendingEvent = null;
function ensureModal() {
if (modalEl) { return; }
var okLabel = document.body.dataset.confirmOk || 'OK';
var cancelLabel = document.body.dataset.confirmCancel || 'Cancel';
modalEl = document.createElement('div');
modalEl.className = 'modal fade';
modalEl.tabIndex = -1;
modalEl.setAttribute('aria-hidden', 'true');
modalEl.innerHTML =
'<div class="modal-dialog">' +
'<div class="modal-content">' +
'<div class="modal-body"></div>' +
'<div class="modal-footer">' +
'<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"></button>' +
'<button type="button" class="btn btn-danger" data-confirm-accept></button>' +
'</div>' +
'</div>' +
'</div>';
document.body.appendChild(modalEl);
questionEl = modalEl.querySelector('.modal-body');
modalEl.querySelector('[data-bs-dismiss]').textContent = cancelLabel;
var acceptBtn = modalEl.querySelector('[data-confirm-accept]');
acceptBtn.textContent = okLabel;
acceptBtn.addEventListener('click', function () {
var evt = pendingEvent;
pendingEvent = null;
bootstrap.Modal.getInstance(modalEl).hide();
if (evt) { evt.detail.issueRequest(true); }
});
modalEl.addEventListener('hidden.bs.modal', function () {
pendingEvent = null;
});
}
document.addEventListener('htmx:confirm', function (evt) {
if (!evt.detail.question) { return; }
evt.preventDefault();
ensureModal();
questionEl.textContent = evt.detail.question;
pendingEvent = evt;
bootstrap.Modal.getOrCreateInstance(modalEl).show();
});
}());

View file

@ -0,0 +1,18 @@
(function () {
'use strict';
// Bootstrap's `.modal` is a plain `position: fixed` element: it never leaves the CSS stacking
// context of whatever ancestor it was rendered inside. If any ancestor sets its own `z-index`
// (e.g. pagetop's `Intro` component, for its decorative layering), the modal's `z-index` only
// ranks it among the elements of *that* ancestor's context, so `.modal-backdrop` -always a
// direct child of `<body>`, added by Bootstrap itself- can end up rendering above it and
// swallowing every click, even though `.modal`'s own `z-index` is numerically higher. Basic's
// native `<dialog>` escapes this for free via the browser's "top layer"; Bootsier's `.modal`
// does not, so it is moved to `<body>` here, exactly like Bootstrap already does with its own
// backdrop.
document.querySelectorAll('.modal.dialog').forEach(function (modal) {
if (modal.parentElement !== document.body) {
document.body.appendChild(modal);
}
});
}());

View file

@ -0,0 +1,67 @@
// Bootstrap 5.3.8 - PageTop Bootsier Styles.
// Bootstrap Configuration
// ---------------------------------------------------
@import "bootstrap-5.3.8/scss/functions";
// Bootsier Bootstrap variable overrides
// ---------------------------------------------------
@import "bootsier-variables";
// Bootstrap Configuration
// ---------------------------------------------------
@import "bootstrap-5.3.8/scss/variables";
@import "bootstrap-5.3.8/scss/variables-dark";
@import "bootstrap-5.3.8/scss/maps";
@import "bootstrap-5.3.8/scss/mixins";
@import "bootstrap-5.3.8/scss/utilities";
// Bootstrap Layout & components
@import "bootstrap-5.3.8/scss/root";
@import "bootstrap-5.3.8/scss/reboot";
@import "bootstrap-5.3.8/scss/type";
@import "bootstrap-5.3.8/scss/images";
@import "bootstrap-5.3.8/scss/containers";
@import "bootstrap-5.3.8/scss/grid";
@import "bootstrap-5.3.8/scss/tables";
@import "bootstrap-5.3.8/scss/forms";
@import "bootstrap-5.3.8/scss/buttons";
@import "bootstrap-5.3.8/scss/transitions";
@import "bootstrap-5.3.8/scss/dropdown";
@import "bootstrap-5.3.8/scss/button-group";
@import "bootstrap-5.3.8/scss/nav";
@import "bootstrap-5.3.8/scss/navbar";
@import "bootstrap-5.3.8/scss/card";
@import "bootstrap-5.3.8/scss/accordion";
@import "bootstrap-5.3.8/scss/breadcrumb";
@import "bootstrap-5.3.8/scss/pagination";
@import "bootstrap-5.3.8/scss/badge";
@import "bootstrap-5.3.8/scss/alert";
@import "bootstrap-5.3.8/scss/progress";
@import "bootstrap-5.3.8/scss/list-group";
@import "bootstrap-5.3.8/scss/close";
@import "bootstrap-5.3.8/scss/toasts";
@import "bootstrap-5.3.8/scss/modal";
@import "bootstrap-5.3.8/scss/tooltip";
@import "bootstrap-5.3.8/scss/popover";
@import "bootstrap-5.3.8/scss/carousel";
@import "bootstrap-5.3.8/scss/spinners";
@import "bootstrap-5.3.8/scss/offcanvas";
@import "bootstrap-5.3.8/scss/placeholders";
// Bootstrap Helpers
@import "bootstrap-5.3.8/scss/helpers";
// Bootsier Utilities (must precede utilities/api to extend $utilities)
@import "bootsier-utilities";
// Bootstrap Utilities
@import "bootstrap-5.3.8/scss/utilities/api";
// Bootstrap Icons 1.13.1
// ---------------------------------------------------
@import "bootsier-icons";
// Bootsier customizations
// ---------------------------------------------------
@import "bootsier-custom";

Some files were not shown because too many files have changed in this diff Show more