Compare commits
2 commits
6df71885cc
...
1254e650f1
| Author | SHA1 | Date | |
|---|---|---|---|
| 1254e650f1 | |||
| 11c2bdffb1 |
89 changed files with 201 additions and 9461 deletions
10
Cargo.lock
generated
10
Cargo.lock
generated
|
|
@ -1871,16 +1871,6 @@ dependencies = [
|
||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pagetop-menu"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"pagetop",
|
|
||||||
"pagetop-seaorm",
|
|
||||||
"serde",
|
|
||||||
"thiserror",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pagetop-minimal"
|
name = "pagetop-minimal"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ members = [
|
||||||
"extensions/pagetop-bootsier",
|
"extensions/pagetop-bootsier",
|
||||||
"extensions/pagetop-htmx",
|
"extensions/pagetop-htmx",
|
||||||
"extensions/pagetop-admin",
|
"extensions/pagetop-admin",
|
||||||
"extensions/pagetop-menu",
|
|
||||||
"extensions/pagetop-seaorm",
|
"extensions/pagetop-seaorm",
|
||||||
"extensions/pagetop-user",
|
"extensions/pagetop-user",
|
||||||
]
|
]
|
||||||
|
|
@ -74,7 +73,6 @@ pagetop-admin = { version = "0.1", path = "extensions/pagetop-admin" }
|
||||||
pagetop-aliner = { version = "0.1", path = "extensions/pagetop-aliner" }
|
pagetop-aliner = { version = "0.1", path = "extensions/pagetop-aliner" }
|
||||||
pagetop-bootsier = { version = "0.1", path = "extensions/pagetop-bootsier" }
|
pagetop-bootsier = { version = "0.1", path = "extensions/pagetop-bootsier" }
|
||||||
pagetop-htmx = { version = "0.1", path = "extensions/pagetop-htmx" }
|
pagetop-htmx = { version = "0.1", path = "extensions/pagetop-htmx" }
|
||||||
pagetop-menu = { version = "0.1", path = "extensions/pagetop-menu" }
|
|
||||||
pagetop-seaorm = { version = "0.0", path = "extensions/pagetop-seaorm" }
|
pagetop-seaorm = { version = "0.0", path = "extensions/pagetop-seaorm" }
|
||||||
pagetop-user = { version = "0.1", path = "extensions/pagetop-user" }
|
pagetop-user = { version = "0.1", path = "extensions/pagetop-user" }
|
||||||
# PageTop
|
# PageTop
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,14 @@ use pagetop::prelude::*;
|
||||||
|
|
||||||
use crate::registry;
|
use crate::registry;
|
||||||
|
|
||||||
/// Componente que renderiza el menú de secciones visibles del panel de administración.
|
/// Componente que renderiza la barra de navegación del panel de administración.
|
||||||
///
|
///
|
||||||
/// Construye el [`Nav`] en cada petición a partir de [`registry::admin_menu()`], ya filtrado por
|
/// Construye la [`Navbar`] en cada petición a partir de [`registry::admin_navbar()`], ya filtrada
|
||||||
/// el usuario de la petición actual. Pensado para que un tema lo registre en su propia región de
|
/// por el usuario de la petición actual. Pensado para que un tema la registre en su propia región
|
||||||
/// navegación (p. ej. `pagetop-bootsier` lo añade a su sidebar) e intercepte `Nav`/`nav::Item` en
|
/// de navegación e intercepte `Navbar`/`Nav`/`Dropdown` en
|
||||||
/// [`Theme::render_component()`](pagetop::core::theme::Theme::render_component) si quiere darle un
|
/// [`Theme::render_component()`](pagetop::core::theme::Theme::render_component) si quiere darle un
|
||||||
/// aspecto propio; sin intercepción, se renderiza con el marcado por defecto de [`Nav`].
|
/// 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
|
/// Sólo se renderiza en páginas creadas con
|
||||||
/// [`Page::admin()`](pagetop::response::Page::admin) (plantilla
|
/// [`Page::admin()`](pagetop::response::Page::admin) (plantilla
|
||||||
|
|
@ -32,6 +33,6 @@ impl Component for AdminMenu {
|
||||||
) {
|
) {
|
||||||
return Ok(html! {});
|
return Ok(html! {});
|
||||||
}
|
}
|
||||||
Ok(registry::admin_menu(cx).render(cx).await)
|
Ok(registry::admin_navbar(cx).render(cx).await)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -271,25 +271,44 @@ pub fn global() -> &'static AdminRegistry {
|
||||||
|
|
||||||
// **< Menú de administración >*********************************************************************
|
// **< Menú de administración >*********************************************************************
|
||||||
|
|
||||||
/// Construye el menú plano de secciones visibles para el usuario de la petición actual.
|
/// 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 lo use como navegación de `CoreTemplates::Admin` (p. ej. un sidebar) --
|
/// Pensado para que un tema la use como navegación de `CoreTemplates::Admin` -- ver
|
||||||
/// ver [`crate::component::AdminMenu`]. `pagetop-admin` no impone ningún marcado propio: el
|
/// [`crate::component::AdminMenu`]. `pagetop-admin` no impone ningún marcado propio: la
|
||||||
/// [`Nav`] resultante se renderiza con su aspecto por defecto salvo que el tema lo intercepte en
|
/// [`Navbar`] resultante se renderiza con su aspecto por defecto salvo que el tema lo intercepte
|
||||||
/// [`Theme::render_component()`](pagetop::core::theme::Theme::render_component).
|
/// en [`Theme::render_component()`](pagetop::core::theme::Theme::render_component). Se construye
|
||||||
pub fn admin_menu(cx: &Context) -> Nav {
|
/// 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 reg = global();
|
||||||
let current_path = cx.request().map(|r| r.path()).unwrap_or("");
|
|
||||||
|
|
||||||
let mut result = Nav::new();
|
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() {
|
for section in reg.ordered_sections() {
|
||||||
if !section.is_visible(cx) {
|
if !section.is_visible(cx) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let active = current_path.starts_with(section.path.as_str());
|
let pages: Vec<_> = reg
|
||||||
result = result.with_item(
|
.pages_for_section(§ion.key)
|
||||||
nav::Item::link(section.title.clone(), section.path.clone()).with_active(active),
|
.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));
|
||||||
}
|
}
|
||||||
result
|
|
||||||
|
Navbar::simple()
|
||||||
|
.with_position(navbar::Position::StickyTop)
|
||||||
|
.with_item(navbar::Item::nav(nav))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -75,10 +75,12 @@ async fn homepage(request: HttpRequest) -> Result<Markup, ErrorPage> {
|
||||||
|
|
||||||
## Plantillas
|
## Plantillas
|
||||||
|
|
||||||
Bootsier ofrece dos plantillas (`BootsierTemplates`): `Standard`, con cabecera, contenido y pie,
|
Bootsier maqueta las dos plantillas de PageTop (`CoreTemplates`) con la misma cabecera, contenido
|
||||||
que es la plantilla por defecto de cualquier página; y `Admin`, con la shell completa de
|
y pie: `Standard`, la plantilla por defecto de cualquier página; y `Admin`, que se activa creando
|
||||||
AdminLTE 4 (barra superior + barra lateral + área de contenido), que se activa creando la página
|
la página con `Page::admin()` en lugar de `Page::new()`. Ambas se maquetan igual hoy -- la
|
||||||
con `Page::admin()` en lugar de `Page::new()`.
|
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
|
```rust,no_run
|
||||||
use pagetop::prelude::*;
|
use pagetop::prelude::*;
|
||||||
|
|
@ -93,59 +95,28 @@ async fn about(request: HttpRequest) -> Result<Markup, ErrorPage> {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Barra lateral
|
## Menú de administración
|
||||||
|
|
||||||
Registra elementos en `BootsierRegion::Sidebar` para poblar la barra lateral de la shell. Los
|
`pagetop-admin` (si está presente en la aplicación) registra su propio menú de secciones y
|
||||||
elementos esperados son `theme::sidebar::Item` y `theme::sidebar::Section`.
|
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.
|
||||||
|
|
||||||
De forma **global** (visibles en todas las páginas de administració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
|
```rust,no_run
|
||||||
use pagetop::prelude::*;
|
use pagetop::prelude::*;
|
||||||
use pagetop_bootsier::theme::{BootsierRegion, sidebar};
|
use pagetop_bootsier::theme::*;
|
||||||
|
|
||||||
fn register_navigation() {
|
let navbar = bs::Navbar::simple().with_item(bs::navbar::Item::nav(
|
||||||
InRegion::Global(&BootsierRegion::Sidebar)
|
bs::Nav::new()
|
||||||
.add(sidebar::Section::titled(Lc::n("Administración")))
|
.with_item(bs::nav::Item::link(Lc::n("Home"), "/"))
|
||||||
.add(sidebar::Item::link(Lc::n("Usuarios"), "/users", "people"))
|
.with_item(bs::theme_toggle()),
|
||||||
.add(sidebar::Item::link(Lc::n("Roles"), "/roles", "shield-check"));
|
));
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
O de forma **por página**:
|
|
||||||
|
|
||||||
```rust,no_run
|
|
||||||
use pagetop::prelude::*;
|
|
||||||
use pagetop_bootsier::theme::{BootsierRegion, sidebar};
|
|
||||||
|
|
||||||
async fn settings(request: HttpRequest) -> Result<Markup, ErrorPage> {
|
|
||||||
Page::admin(request)
|
|
||||||
.with_child_in(
|
|
||||||
&BootsierRegion::Sidebar,
|
|
||||||
sidebar::Item::link(Lc::n("Ajustes"), "/settings", "gear"),
|
|
||||||
)
|
|
||||||
.with_child(Html::with(|_| html! { h3 { "Ajustes" } }))
|
|
||||||
.render()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Barra de navegación superior
|
|
||||||
|
|
||||||
La barra superior incluye por defecto los controles de pantalla completa y selector
|
|
||||||
de tema. Para añadir elementos adicionales en el lado derecho (por ejemplo, el
|
|
||||||
dropdown de usuario de `pagetop-user`), registra componentes en
|
|
||||||
`BootsierRegion::Navbar`:
|
|
||||||
|
|
||||||
```rust,no_run
|
|
||||||
use pagetop::prelude::*;
|
|
||||||
use pagetop_bootsier::theme::BootsierRegion;
|
|
||||||
|
|
||||||
InRegion::Global(&BootsierRegion::Navbar)
|
|
||||||
.add(Html::with(|_| html! {
|
|
||||||
li class="nav-item" {
|
|
||||||
a class="nav-link" href="/logout" { "Cerrar sesión" }
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Créditos
|
## Créditos
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// Bootsier CSS rules: self-hosted fonts, form elements, regions and components.
|
// Bootsier CSS rules: self-hosted fonts, form elements, regions and components.
|
||||||
|
|
||||||
// Self-hosted Source Sans 3 (SIL OFL 1.1), served from /bootsier/fonts.
|
// Self-hosted Source Sans 3 (SIL OFL 1.1), served from /bootsier/fonts. Declared as the primary
|
||||||
// Required by AdminLTE 4, which declares it as the primary font family in $font-family-sans-serif.
|
// 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 URLs must match the Preload declarations in src/lib.rs; a mismatch causes double downloads.
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Source Sans 3";
|
font-family: "Source Sans 3";
|
||||||
|
|
|
||||||
|
|
@ -3,3 +3,9 @@
|
||||||
|
|
||||||
$font-size-base: 1.125rem;
|
$font-size-base: 1.125rem;
|
||||||
$navbar-brand-font-size: 1.75rem;
|
$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";
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1,337 +0,0 @@
|
||||||
/* ==========================================================================
|
|
||||||
AdminLTE Accessibility Styles - WCAG 2.1 AA Compliance
|
|
||||||
========================================================================== */
|
|
||||||
|
|
||||||
/* Skip Links - WCAG 2.4.1: Bypass Blocks */
|
|
||||||
.skip-link {
|
|
||||||
position: absolute;
|
|
||||||
top: -40px;
|
|
||||||
left: 6px;
|
|
||||||
z-index: 999999;
|
|
||||||
padding: 8px 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--bs-white);
|
|
||||||
text-decoration: none;
|
|
||||||
background: var(--bs-primary);
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
top: 0;
|
|
||||||
outline: 3px solid var(--bs-warning);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--bs-white);
|
|
||||||
text-decoration: none;
|
|
||||||
background: var(--bs-primary-emphasis);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Enhanced Focus Indicators - WCAG 2.4.7: Focus Visible */
|
|
||||||
.focus-enhanced {
|
|
||||||
&:focus {
|
|
||||||
outline: 3px solid var(--bs-focus-ring-color, #0d6efd);
|
|
||||||
outline-offset: 2px;
|
|
||||||
box-shadow: 0 0 0 .25rem rgba(13, 110, 253, .25);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* High Contrast Mode Support */
|
|
||||||
@media (prefers-contrast: high) {
|
|
||||||
.card {
|
|
||||||
border: 2px solid;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
border-width: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
border: 1px solid transparent;
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:focus {
|
|
||||||
border-color: currentcolor;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Reduced Motion Support - WCAG 2.3.3: Animation from Interactions */
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
transition-duration: .01ms !important;
|
|
||||||
animation-duration: .01ms !important;
|
|
||||||
animation-iteration-count: 1 !important;
|
|
||||||
scroll-behavior: auto !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade {
|
|
||||||
opacity: 1 !important;
|
|
||||||
/* stylelint-disable-next-line property-disallowed-list */
|
|
||||||
transition: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collapse {
|
|
||||||
/* stylelint-disable-next-line property-disallowed-list */
|
|
||||||
transition: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal.fade .modal-dialog {
|
|
||||||
transform: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Screen Reader Only Content */
|
|
||||||
.sr-only {
|
|
||||||
position: absolute !important;
|
|
||||||
width: 1px !important;
|
|
||||||
height: 1px !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
margin: -1px !important;
|
|
||||||
overflow: hidden !important;
|
|
||||||
clip: rect(0, 0, 0, 0) !important;
|
|
||||||
white-space: nowrap !important;
|
|
||||||
border: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sr-only-focusable:focus {
|
|
||||||
position: static !important;
|
|
||||||
width: auto !important;
|
|
||||||
height: auto !important;
|
|
||||||
padding: inherit !important;
|
|
||||||
margin: inherit !important;
|
|
||||||
overflow: visible !important;
|
|
||||||
clip: auto !important;
|
|
||||||
white-space: normal !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Focus Trap Utilities */
|
|
||||||
.focus-trap {
|
|
||||||
&:focus {
|
|
||||||
box-shadow: 0 0 0 .25rem rgba(13, 110, 253, .25);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Accessible Color Combinations - WCAG 1.4.3: Contrast (Minimum) */
|
|
||||||
.text-accessible-primary {
|
|
||||||
color: #003d82; /* 4.5:1 contrast on white */
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-accessible-success {
|
|
||||||
color: #0f5132; /* 4.5:1 contrast on white */
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-accessible-danger {
|
|
||||||
color: #842029; /* 4.5:1 contrast on white */
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-accessible-warning {
|
|
||||||
color: #664d03; /* 4.5:1 contrast on white */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ARIA Live Regions */
|
|
||||||
.live-region {
|
|
||||||
position: absolute;
|
|
||||||
left: -10000px;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
&.live-region-visible {
|
|
||||||
position: static;
|
|
||||||
left: auto;
|
|
||||||
width: auto;
|
|
||||||
height: auto;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Enhanced Error States - WCAG 3.3.1: Error Identification */
|
|
||||||
.form-control.is-invalid {
|
|
||||||
border-color: var(--bs-danger);
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
border-color: var(--bs-danger);
|
|
||||||
box-shadow: 0 0 0 .25rem rgba(220, 53, 69, .25);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.invalid-feedback {
|
|
||||||
&[role="alert"] {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Target Size - WCAG 2.5.8: Target Size (Minimum) */
|
|
||||||
.touch-target {
|
|
||||||
min-width: 44px;
|
|
||||||
min-height: 44px;
|
|
||||||
|
|
||||||
&.touch-target-small {
|
|
||||||
min-width: 24px;
|
|
||||||
min-height: 24px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Table Accessibility */
|
|
||||||
.table-accessible {
|
|
||||||
th {
|
|
||||||
font-weight: 600;
|
|
||||||
background-color: var(--bs-secondary-bg);
|
|
||||||
|
|
||||||
&[scope="col"] {
|
|
||||||
border-bottom: 2px solid var(--bs-border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
&[scope="row"] {
|
|
||||||
border-right: 2px solid var(--bs-border-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
caption {
|
|
||||||
padding: .75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--bs-secondary);
|
|
||||||
text-align: left;
|
|
||||||
caption-side: top;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Navigation Landmarks */
|
|
||||||
nav[role="navigation"] {
|
|
||||||
&:not([aria-label]):not([aria-labelledby]) {
|
|
||||||
&::before {
|
|
||||||
position: absolute;
|
|
||||||
left: -10000px;
|
|
||||||
content: "Navigation";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form Fieldset Styling */
|
|
||||||
fieldset {
|
|
||||||
padding: 1rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
border: 1px solid var(--bs-border-color);
|
|
||||||
|
|
||||||
legend {
|
|
||||||
padding: 0 .5rem;
|
|
||||||
margin-bottom: .5rem;
|
|
||||||
font-size: 1.1em;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loading States */
|
|
||||||
.loading[aria-busy="true"] {
|
|
||||||
position: relative;
|
|
||||||
pointer-events: none;
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
margin-top: -10px;
|
|
||||||
margin-left: -10px;
|
|
||||||
content: "";
|
|
||||||
border: 2px solid var(--bs-primary);
|
|
||||||
border-top-color: transparent;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
&::after {
|
|
||||||
border-top-color: var(--bs-primary);
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark Mode Accessibility */
|
|
||||||
[data-bs-theme="dark"] {
|
|
||||||
.text-accessible-primary {
|
|
||||||
color: #6ea8fe;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-accessible-success {
|
|
||||||
color: #75b798;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-accessible-danger {
|
|
||||||
color: #f1aeb5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-accessible-warning {
|
|
||||||
color: #ffda6a;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Print Accessibility */
|
|
||||||
@media print {
|
|
||||||
.skip-link,
|
|
||||||
.btn,
|
|
||||||
.nav-link {
|
|
||||||
color: #000 !important;
|
|
||||||
background: transparent !important;
|
|
||||||
border: 1px solid #000 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
a[href^="http"]::after {
|
|
||||||
font-size: .8em;
|
|
||||||
content: " (" attr(href) ")";
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Print Layout Fix - Ensure sidebar and main content are both visible */
|
|
||||||
.app-wrapper {
|
|
||||||
display: grid !important;
|
|
||||||
grid-template-rows: auto 1fr auto !important;
|
|
||||||
grid-template-columns: auto 1fr !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-overlay {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar {
|
|
||||||
position: static !important;
|
|
||||||
display: block !important;
|
|
||||||
min-width: 200px !important;
|
|
||||||
max-width: 200px !important;
|
|
||||||
max-height: none !important;
|
|
||||||
margin-left: 0 !important;
|
|
||||||
overflow: visible !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-wrapper {
|
|
||||||
height: auto !important;
|
|
||||||
overflow: visible !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header {
|
|
||||||
position: static !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-main {
|
|
||||||
width: auto !important;
|
|
||||||
max-width: 100% !important;
|
|
||||||
overflow: visible !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-content {
|
|
||||||
overflow: visible !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-footer {
|
|
||||||
position: static !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
.app-content {
|
|
||||||
padding: $lte-content-padding-y $lte-content-padding-x;
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
//
|
|
||||||
// Core: Main Footer
|
|
||||||
//
|
|
||||||
|
|
||||||
.app-footer {
|
|
||||||
grid-area: #{$lte-prefix}app-footer;
|
|
||||||
width: inherit;
|
|
||||||
max-width: 100vw;
|
|
||||||
min-height: 3rem;
|
|
||||||
padding: $lte-app-footer-padding;
|
|
||||||
color: $lte-app-footer-color;
|
|
||||||
background-color: $lte-app-footer-bg;
|
|
||||||
border-top: $lte-app-footer-border-top;
|
|
||||||
@include transition($lte-transition-speed $lte-transition-fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fixed-footer {
|
|
||||||
.app-footer {
|
|
||||||
position: sticky;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: $lte-zindex-fixed-footer;
|
|
||||||
}
|
|
||||||
|
|
||||||
// When layout-fixed is used, app-main has overflow: auto which prevents
|
|
||||||
// position: sticky from working on the footer. Ensure the grid keeps the
|
|
||||||
// footer pinned at the bottom while app-main scrolls independently.
|
|
||||||
&.layout-fixed {
|
|
||||||
.app-main {
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
//
|
|
||||||
// Core: Main Header
|
|
||||||
//
|
|
||||||
|
|
||||||
.app-header {
|
|
||||||
z-index: $lte-zindex-app-header;
|
|
||||||
grid-area: #{$lte-prefix}app-header;
|
|
||||||
max-width: 100vw;
|
|
||||||
border-bottom: $lte-app-header-bottom-border;
|
|
||||||
@include transition($lte-transition-speed $lte-transition-fn);
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
position: relative;
|
|
||||||
height: $nav-link-height;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navbar badge
|
|
||||||
.navbar-badge {
|
|
||||||
position: absolute;
|
|
||||||
top: 9px;
|
|
||||||
right: 5px;
|
|
||||||
padding: 2px 4px;
|
|
||||||
font-size: .6rem;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fixed-header {
|
|
||||||
.app-header {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: $lte-zindex-fixed-header;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fixes #6020: when only `fixed-header` is used (without `layout-fixed`),
|
|
||||||
// the sidebar — including its branding — used to scroll with the page
|
|
||||||
// because nothing made it sticky. Pin the sidebar so the brand and menu
|
|
||||||
// stay visible while the page content scrolls beneath the fixed header.
|
|
||||||
// Only applied on `sidebar-expand-*` breakpoints, since the mobile sidebar
|
|
||||||
// is an off-canvas overlay that handles its own positioning.
|
|
||||||
@each $breakpoint in map-keys($grid-breakpoints) {
|
|
||||||
$next: breakpoint-next($breakpoint, $grid-breakpoints);
|
|
||||||
$infix: breakpoint-infix($next, $grid-breakpoints);
|
|
||||||
|
|
||||||
&.sidebar-expand#{$infix} {
|
|
||||||
@include media-breakpoint-up($next) {
|
|
||||||
.app-sidebar {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
max-height: 100vh;
|
|
||||||
|
|
||||||
.sidebar-wrapper {
|
|
||||||
height: subtract(100vh, add($lte-app-header-height, 1px));
|
|
||||||
overflow-x: hidden;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
.app-main {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
grid-area: #{$lte-prefix}app-main;
|
|
||||||
max-width: 100vw;
|
|
||||||
padding-bottom: $lte-app-main-padding-bottom;
|
|
||||||
@include transition($lte-transition-speed $lte-transition-fn);
|
|
||||||
|
|
||||||
.app-content-header {
|
|
||||||
padding: 1rem $lte-content-padding-x;
|
|
||||||
|
|
||||||
.breadcrumb {
|
|
||||||
padding: 0;
|
|
||||||
margin-bottom: 0;
|
|
||||||
line-height: 2.5rem;
|
|
||||||
|
|
||||||
a {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-content-top-area,
|
|
||||||
.app-content-bottom-area {
|
|
||||||
color: $lte-app-content-bottom-area-color;
|
|
||||||
background-color: $lte-app-content-bottom-area-bg;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-content-top-area {
|
|
||||||
padding: $lte-app-content-top-area-padding-y $lte-app-content-top-area-padding-x;
|
|
||||||
border-bottom: $lte-app-content-top-area-top-border;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-content-bottom-area {
|
|
||||||
padding: $lte-app-content-bottom-area-padding-y $lte-app-content-bottom-area-padding-x;
|
|
||||||
margin-top: auto;
|
|
||||||
margin-bottom: $lte-app-content-bottom-area-margin-bottom;
|
|
||||||
border-top: $lte-app-content-bottom-area-top-border;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,619 +0,0 @@
|
||||||
.app-sidebar {
|
|
||||||
--#{$lte-prefix}sidebar-hover-bg: #{$lte-sidebar-hover-bg};
|
|
||||||
--#{$lte-prefix}sidebar-color: #{$lte-sidebar-color};
|
|
||||||
--#{$lte-prefix}sidebar-hover-color: #{$lte-sidebar-hover-color};
|
|
||||||
--#{$lte-prefix}sidebar-active-color: #{$lte-sidebar-active-color};
|
|
||||||
--#{$lte-prefix}sidebar-menu-active-bg: #{$lte-sidebar-menu-active-bg};
|
|
||||||
--#{$lte-prefix}sidebar-menu-active-color: #{$lte-sidebar-menu-active-color};
|
|
||||||
--#{$lte-prefix}sidebar-submenu-bg: #{$lte-sidebar-submenu-bg};
|
|
||||||
--#{$lte-prefix}sidebar-submenu-color: #{$lte-sidebar-submenu-color};
|
|
||||||
--#{$lte-prefix}sidebar-submenu-hover-color: #{$lte-sidebar-submenu-hover-color};
|
|
||||||
--#{$lte-prefix}sidebar-submenu-hover-bg: #{$lte-sidebar-submenu-hover-bg};
|
|
||||||
--#{$lte-prefix}sidebar-submenu-active-color: #{$lte-sidebar-submenu-active-color};
|
|
||||||
--#{$lte-prefix}sidebar-submenu-active-bg: #{$lte-sidebar-submenu-active-bg};
|
|
||||||
--#{$lte-prefix}sidebar-header-color: #{$lte-sidebar-header-color};
|
|
||||||
|
|
||||||
z-index: $lte-zindex-sidebar;
|
|
||||||
grid-area: #{$lte-prefix}app-sidebar;
|
|
||||||
min-width: var(--#{$lte-prefix}sidebar-width);
|
|
||||||
max-width: var(--#{$lte-prefix}sidebar-width);
|
|
||||||
@include transition($lte-sidebar-transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-brand {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: $lte-app-header-height;
|
|
||||||
padding: $lte-brand-link-padding-y $lte-brand-link-padding-x;
|
|
||||||
overflow: hidden;
|
|
||||||
font-size: $navbar-brand-font-size;
|
|
||||||
white-space: nowrap;
|
|
||||||
border-bottom: $lte-brand-link-border-buttom solid var(--#{$prefix}border-color);
|
|
||||||
@include transition(width $lte-transition-speed $lte-transition-fn);
|
|
||||||
|
|
||||||
.brand-link {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
.brand-image {
|
|
||||||
float: left;
|
|
||||||
width: auto;
|
|
||||||
max-height: 33px;
|
|
||||||
line-height: .8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-image-xs {
|
|
||||||
float: left;
|
|
||||||
width: auto;
|
|
||||||
max-height: 33px;
|
|
||||||
margin-top: -.1rem;
|
|
||||||
line-height: .8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-image-xl {
|
|
||||||
width: auto;
|
|
||||||
max-height: 40px;
|
|
||||||
line-height: .8;
|
|
||||||
|
|
||||||
&.single {
|
|
||||||
margin-top: -.3rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-text {
|
|
||||||
margin-left: .5rem;
|
|
||||||
color: rgba(var(--#{$prefix}emphasis-color-rgb), .8);
|
|
||||||
@include transition(flex $lte-transition-speed $lte-transition-fn, width $lte-transition-speed $lte-transition-fn);
|
|
||||||
&:hover {
|
|
||||||
color: var(--#{$prefix}emphasis-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-wrapper {
|
|
||||||
padding-top: $lte-sidebar-padding-y;
|
|
||||||
padding-right: $lte-sidebar-padding-x;
|
|
||||||
padding-bottom: $lte-sidebar-padding-y;
|
|
||||||
padding-left: $lte-sidebar-padding-x;
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
@include scrollbar-color-gray();
|
|
||||||
@include scrollbar-width-thin();
|
|
||||||
|
|
||||||
.nav-item {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-start;
|
|
||||||
|
|
||||||
p {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.nav-icon {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-width: 1.5rem;
|
|
||||||
max-width: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sidebar Menu. First level links
|
|
||||||
.sidebar-menu > .nav-item {
|
|
||||||
// links
|
|
||||||
> .nav-link {
|
|
||||||
color: var(--#{$lte-prefix}sidebar-color);
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:focus {
|
|
||||||
color: var(--#{$lte-prefix}sidebar-hover-color);
|
|
||||||
background-color: var(--#{$lte-prefix}sidebar-hover-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
color: var(--#{$lte-prefix}sidebar-menu-active-color);
|
|
||||||
background-color: var(--#{$lte-prefix}sidebar-menu-active-bg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open state
|
|
||||||
&.menu-open > .nav-link {
|
|
||||||
color: var(--#{$lte-prefix}sidebar-hover-color);
|
|
||||||
background-color: var(--#{$lte-prefix}sidebar-hover-bg);
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
color: var(--#{$lte-prefix}sidebar-menu-active-color);
|
|
||||||
background-color: var(--#{$lte-prefix}sidebar-menu-active-bg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// First Level Submenu
|
|
||||||
> .nav-treeview {
|
|
||||||
background-color: var(--#{$lte-prefix}sidebar-submenu-bg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Section Heading
|
|
||||||
.nav-header {
|
|
||||||
display: inline;
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--#{$lte-prefix}sidebar-header-color);
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
background-color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// All links within the sidebar menu
|
|
||||||
a {
|
|
||||||
color: var(--#{$lte-prefix}sidebar-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
// All submenus
|
|
||||||
.nav-treeview {
|
|
||||||
> .nav-item {
|
|
||||||
> .nav-link {
|
|
||||||
color: var(--#{$lte-prefix}sidebar-submenu-color);
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:focus {
|
|
||||||
color: var(--#{$lte-prefix}sidebar-submenu-hover-color);
|
|
||||||
background-color: var(--#{$lte-prefix}sidebar-submenu-hover-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
color: var(--#{$lte-prefix}sidebar-submenu-active-color);
|
|
||||||
background-color: var(--#{$lte-prefix}sidebar-submenu-active-bg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sidebar navigation menu
|
|
||||||
.sidebar-menu {
|
|
||||||
// All levels
|
|
||||||
.nav-item {
|
|
||||||
> .nav-link {
|
|
||||||
margin-bottom: .2rem;
|
|
||||||
|
|
||||||
.nav-arrow {
|
|
||||||
@include transition(transform $lte-transition-fn $lte-transition-speed);
|
|
||||||
transform: translateY(-50%) #{"/*rtl:append:rotate(180deg)*/"};
|
|
||||||
animation-name: fadeIn;
|
|
||||||
animation-duration: $lte-transition-speed;
|
|
||||||
animation-fill-mode: both;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// All levels
|
|
||||||
.nav-link > .nav-badge,
|
|
||||||
.nav-link > p > .nav-badge {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
right: 1rem;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link > .nav-arrow,
|
|
||||||
.nav-link > p > .nav-arrow {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
right: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
@include transition(width $lte-transition-fn $lte-transition-speed);
|
|
||||||
@include border-radius($border-radius);
|
|
||||||
|
|
||||||
p {
|
|
||||||
display: inline;
|
|
||||||
padding-left: .5rem;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-header {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
padding: $nav-link-padding-y ($nav-link-padding-y * 1.5);
|
|
||||||
font-size: .9rem;
|
|
||||||
@include transition(width $lte-transition-fn $lte-transition-speed);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tree view menu
|
|
||||||
.nav-treeview {
|
|
||||||
display: none;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-open {
|
|
||||||
> .nav-treeview {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
> .nav-link {
|
|
||||||
.nav-arrow {
|
|
||||||
transform: translateY(-50%) rotate(90deg) #{"/*rtl:ignore*/"};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Override: ensure badges/arrows are aligned to the end (right in LTR, left in RTL)
|
|
||||||
.nav-link > .nav-badge,
|
|
||||||
.nav-link > p > .nav-badge,
|
|
||||||
.nav-link > .nav-arrow,
|
|
||||||
.nav-link > p > .nav-arrow {
|
|
||||||
right: 1rem !important; // place at end in LTR
|
|
||||||
left: auto !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-indent {
|
|
||||||
.nav-treeview {
|
|
||||||
padding-left: $lte-sidebar-padding-x;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-compact.nav-indent {
|
|
||||||
.nav-treeview {
|
|
||||||
padding-left: 0;
|
|
||||||
|
|
||||||
.nav-item {
|
|
||||||
padding-left: $lte-sidebar-padding-x;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-mini.sidebar-collapse.nav-indent {
|
|
||||||
.app-sidebar:hover {
|
|
||||||
.nav-treeview {
|
|
||||||
padding-left: 0;
|
|
||||||
|
|
||||||
.nav-item {
|
|
||||||
padding-left: $lte-sidebar-padding-x;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-collapse {
|
|
||||||
&.nav-compact.nav-indent {
|
|
||||||
.nav-treeview {
|
|
||||||
.nav-item {
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-compact .nav-link {
|
|
||||||
@include border-radius(0);
|
|
||||||
margin-bottom: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A fix for text overflow while transitioning from sidebar mini to full sidebar
|
|
||||||
.sidebar-menu,
|
|
||||||
.sidebar-menu > .nav-header,
|
|
||||||
.sidebar-menu .nav-link {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logo style
|
|
||||||
.logo-xs,
|
|
||||||
.logo-xl {
|
|
||||||
position: absolute;
|
|
||||||
visibility: visible;
|
|
||||||
opacity: 1;
|
|
||||||
|
|
||||||
&.brand-image-xs {
|
|
||||||
top: 12px;
|
|
||||||
left: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.brand-image-xl {
|
|
||||||
top: 6px;
|
|
||||||
left: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-xs {
|
|
||||||
visibility: hidden;
|
|
||||||
opacity: 0;
|
|
||||||
|
|
||||||
&.brand-image-xl {
|
|
||||||
top: 8px;
|
|
||||||
left: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-link {
|
|
||||||
&.logo-switch {
|
|
||||||
&::before {
|
|
||||||
content: "\00a0";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-mini.sidebar-collapse {
|
|
||||||
.app-sidebar {
|
|
||||||
min-width: $lte-sidebar-mini-width;
|
|
||||||
max-width: $lte-sidebar-mini-width;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make the sidebar headers
|
|
||||||
.sidebar-menu .nav-header {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-menu {
|
|
||||||
.nav-link {
|
|
||||||
width: $lte-sidebar-mini-width - $lte-sidebar-padding-x * 2;
|
|
||||||
|
|
||||||
p {
|
|
||||||
display: inline-block;
|
|
||||||
width: 0;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.nav-badge,
|
|
||||||
.nav-arrow {
|
|
||||||
display: none;
|
|
||||||
animation-name: fadeOut;
|
|
||||||
animation-duration: $lte-transition-speed;
|
|
||||||
animation-fill-mode: both;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-text {
|
|
||||||
display: inline-block;
|
|
||||||
max-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-menu .nav-link p,
|
|
||||||
.brand-text,
|
|
||||||
.logo-xl,
|
|
||||||
.nav-arrow {
|
|
||||||
visibility: hidden;
|
|
||||||
animation-name: fadeOut;
|
|
||||||
animation-duration: $lte-transition-speed;
|
|
||||||
animation-fill-mode: both;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.logo-xs {
|
|
||||||
display: inline-block;
|
|
||||||
visibility: visible;
|
|
||||||
animation-name: fadeIn;
|
|
||||||
animation-duration: $lte-transition-speed;
|
|
||||||
animation-fill-mode: both;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:not(.sidebar-without-hover) {
|
|
||||||
.app-sidebar:hover {
|
|
||||||
min-width: var(--#{$lte-prefix}sidebar-width);
|
|
||||||
max-width: var(--#{$lte-prefix}sidebar-width);
|
|
||||||
|
|
||||||
.sidebar-menu .nav-header {
|
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-menu .nav-link {
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-menu .nav-link p,
|
|
||||||
.brand-text,
|
|
||||||
.logo-xl {
|
|
||||||
width: auto;
|
|
||||||
margin-left: 0;
|
|
||||||
visibility: visible;
|
|
||||||
animation-name: fadeIn;
|
|
||||||
animation-duration: $lte-transition-speed;
|
|
||||||
animation-fill-mode: both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-text {
|
|
||||||
display: inline;
|
|
||||||
max-width: inherit;
|
|
||||||
margin-left: .5rem;
|
|
||||||
animation-name: fadeIn;
|
|
||||||
animation-duration: $lte-transition-speed;
|
|
||||||
animation-fill-mode: both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-badge,
|
|
||||||
.nav-arrow {
|
|
||||||
display: inline-block;
|
|
||||||
visibility: visible;
|
|
||||||
animation-name: fadeIn;
|
|
||||||
animation-duration: $lte-transition-speed;
|
|
||||||
animation-fill-mode: both;
|
|
||||||
animation-delay: $lte-transition-speed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link p {
|
|
||||||
padding-left: .5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-xs {
|
|
||||||
visibility: hidden;
|
|
||||||
animation-name: fadeOut;
|
|
||||||
animation-duration: $lte-transition-speed;
|
|
||||||
animation-fill-mode: both;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-collapse:not(.sidebar-mini) {
|
|
||||||
.app-sidebar {
|
|
||||||
margin-left: calc(var(--#{$lte-prefix}sidebar-width) * -1); // stylelint-disable-line function-disallowed-list
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-expand {
|
|
||||||
@each $breakpoint in map-keys($grid-breakpoints) {
|
|
||||||
$next: breakpoint-next($breakpoint, $grid-breakpoints);
|
|
||||||
$infix: breakpoint-infix($next, $grid-breakpoints);
|
|
||||||
|
|
||||||
/* stylelint-disable-next-line scss/selector-no-union-class-name */
|
|
||||||
&#{$infix} {
|
|
||||||
@include media-breakpoint-up($next) {
|
|
||||||
&.layout-fixed {
|
|
||||||
.app-main-wrapper {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
.app-sidebar-wrapper {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.app-main {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
.app-sidebar {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
max-height: 100vh;
|
|
||||||
|
|
||||||
.sidebar-wrapper {
|
|
||||||
height: subtract(100vh, add($lte-app-header-height, 1px));
|
|
||||||
overflow-x: hidden;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.sidebar-open {
|
|
||||||
.nav-link > .nav-badge,
|
|
||||||
.nav-link > p > .nav-badge {
|
|
||||||
animation-name: fadeIn;
|
|
||||||
animation-duration: $lte-transition-speed;
|
|
||||||
animation-fill-mode: both;
|
|
||||||
animation-delay: $lte-transition-speed;
|
|
||||||
}
|
|
||||||
.nav-link > .nav-arrow,
|
|
||||||
.nav-link > p > .nav-arrow {
|
|
||||||
animation-name: fadeIn;
|
|
||||||
animation-duration: $lte-transition-speed;
|
|
||||||
animation-fill-mode: both;
|
|
||||||
animation-delay: $lte-transition-speed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@include media-breakpoint-down($next) {
|
|
||||||
$max: breakpoint-max($next);
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
display: none;
|
|
||||||
content: "#{$max}";
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
max-height: 100vh;
|
|
||||||
margin-left: calc(var(--#{$lte-prefix}sidebar-width) * -1); // stylelint-disable-line function-disallowed-list
|
|
||||||
|
|
||||||
.sidebar-wrapper {
|
|
||||||
height: subtract(100vh, add($lte-app-header-height, 1px));
|
|
||||||
overflow-x: hidden;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.sidebar-open {
|
|
||||||
.app-sidebar {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-overlay {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
z-index: $lte-zindex-sidebar-overlay;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
cursor: pointer;
|
|
||||||
visibility: visible;
|
|
||||||
background-color: rgba(0, 0, 0, .2);
|
|
||||||
animation-name: fadeIn;
|
|
||||||
animation-fill-mode: both;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-menu .nav-link p,
|
|
||||||
.app-sidebar .brand-text,
|
|
||||||
.app-sidebar .logo-xs,
|
|
||||||
.app-sidebar .logo-xl {
|
|
||||||
@include transition(margin-left $lte-transition-speed linear, opacity $lte-transition-speed ease, visibility $lte-transition-speed ease);
|
|
||||||
}
|
|
||||||
|
|
||||||
// To prevent onload transition and animation
|
|
||||||
.app-loaded {
|
|
||||||
&.sidebar-mini.sidebar-collapse {
|
|
||||||
.sidebar-menu .nav-link p,
|
|
||||||
.brand-text {
|
|
||||||
animation-duration: $lte-transition-speed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
body:not(.app-loaded) {
|
|
||||||
.app-header,
|
|
||||||
.app-sidebar,
|
|
||||||
.app-main,
|
|
||||||
.app-footer {
|
|
||||||
@include transition(none !important);
|
|
||||||
animation-duration: 0s !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.hold-transition {
|
|
||||||
.app-header,
|
|
||||||
.app-sidebar,
|
|
||||||
.app-main,
|
|
||||||
.app-footer,
|
|
||||||
.nav-arrow,
|
|
||||||
.nav-badge {
|
|
||||||
@include transition(none !important);
|
|
||||||
animation-duration: 0s !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@if $enable-dark-mode {
|
|
||||||
@include color-mode(dark) {
|
|
||||||
&.app-sidebar,
|
|
||||||
.app-sidebar {
|
|
||||||
--#{$lte-prefix}sidebar-hover-bg: #{$lte-sidebar-hover-bg-dark};
|
|
||||||
--#{$lte-prefix}sidebar-color: #{$lte-sidebar-color-dark};
|
|
||||||
--#{$lte-prefix}sidebar-hover-color: #{$lte-sidebar-hover-color-dark};
|
|
||||||
--#{$lte-prefix}sidebar-active-color: #{$lte-sidebar-active-color-dark};
|
|
||||||
--#{$lte-prefix}sidebar-menu-active-bg: #{$lte-sidebar-menu-active-bg-dark};
|
|
||||||
--#{$lte-prefix}sidebar-menu-active-color: #{$lte-sidebar-menu-active-color-dark};
|
|
||||||
--#{$lte-prefix}sidebar-submenu-bg: #{$lte-sidebar-submenu-bg-dark};
|
|
||||||
--#{$lte-prefix}sidebar-submenu-color: #{$lte-sidebar-submenu-color-dark};
|
|
||||||
--#{$lte-prefix}sidebar-submenu-hover-color: #{$lte-sidebar-submenu-hover-color-dark};
|
|
||||||
--#{$lte-prefix}sidebar-submenu-hover-bg: #{$lte-sidebar-submenu-hover-bg-dark};
|
|
||||||
--#{$lte-prefix}sidebar-submenu-active-color: #{$lte-sidebar-submenu-active-color-dark};
|
|
||||||
--#{$lte-prefix}sidebar-submenu-active-bg: #{$lte-sidebar-submenu-active-bg-dark};
|
|
||||||
--#{$lte-prefix}sidebar-header-color: #{$lte-sidebar-header-color-dark};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
//
|
|
||||||
// Core: Layout
|
|
||||||
//
|
|
||||||
|
|
||||||
.app-wrapper {
|
|
||||||
position: relative;
|
|
||||||
display: grid;
|
|
||||||
grid-template-areas:
|
|
||||||
"#{$lte-prefix}app-sidebar #{$lte-prefix}app-header"
|
|
||||||
"#{$lte-prefix}app-sidebar #{$lte-prefix}app-main"
|
|
||||||
"#{$lte-prefix}app-sidebar #{$lte-prefix}app-footer";
|
|
||||||
grid-template-rows: min-content 1fr min-content;
|
|
||||||
grid-template-columns: auto 1fr;
|
|
||||||
grid-gap: 0;
|
|
||||||
align-content: stretch;
|
|
||||||
align-items: stretch;
|
|
||||||
max-width: 100vw;
|
|
||||||
min-height: 100vh;
|
|
||||||
|
|
||||||
> * {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,40 +0,0 @@
|
||||||
//
|
|
||||||
// Callouts
|
|
||||||
//
|
|
||||||
|
|
||||||
.callout {
|
|
||||||
--#{$prefix}link-color-rgb: var(--#{$lte-prefix}callout-link);
|
|
||||||
--#{$prefix}code-color: var(--#{$lte-prefix}callout-code-color);
|
|
||||||
|
|
||||||
padding: 1.25rem;
|
|
||||||
color: var(--#{$lte-prefix}callout-color, inherit);
|
|
||||||
background-color: var(--#{$lte-prefix}callout-bg, var(--bs-gray-100));
|
|
||||||
border-left: .25rem solid var(--#{$lte-prefix}callout-border, var(--bs-gray-300));
|
|
||||||
|
|
||||||
.callout-link {
|
|
||||||
font-weight: $lte-callout-link-font-weight;
|
|
||||||
color: var(--#{$prefix}callout-link-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
h4 {
|
|
||||||
margin-bottom: .25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
> :last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
+ .callout {
|
|
||||||
margin-top: -.25rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Variations
|
|
||||||
@each $name, $color in $theme-colors {
|
|
||||||
.callout-#{$name} {
|
|
||||||
--#{$lte-prefix}callout-color: var(--#{$prefix}#{$name}-text-emphasis);
|
|
||||||
--#{$lte-prefix}callout-bg: var(--#{$prefix}#{$name}-bg-subtle);
|
|
||||||
--#{$lte-prefix}callout-border: var(--#{$prefix}#{$name}-border-subtle);
|
|
||||||
--#{$prefix}callout-link-color: var(--#{$prefix}#{$name}-text-emphasis);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,311 +0,0 @@
|
||||||
//
|
|
||||||
// Component: Cards
|
|
||||||
//
|
|
||||||
|
|
||||||
// Color variants
|
|
||||||
.card {
|
|
||||||
@include box-shadow($lte-card-shadow);
|
|
||||||
|
|
||||||
&[class*="card-"]:not(.card-outline),
|
|
||||||
&[class*="text-bg-"]:not(.card-outline) {
|
|
||||||
> .card-header {
|
|
||||||
color: var(--#{$lte-prefix}card-variant-color);
|
|
||||||
background-color: var(--#{$lte-prefix}card-variant-bg);
|
|
||||||
|
|
||||||
.btn-tool {
|
|
||||||
--#{$prefix}btn-color: rgba(var(--#{$lte-prefix}card-variant-color-rgb), .8);
|
|
||||||
--#{$prefix}btn-hover-color: var(--#{$lte-prefix}card-variant-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.card-outline {
|
|
||||||
border-top: 3px solid var(--#{$lte-prefix}card-variant-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.maximized-card {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
z-index: $zindex-modal-backdrop;
|
|
||||||
width: 100% !important;
|
|
||||||
max-width: 100% !important;
|
|
||||||
height: 100% !important;
|
|
||||||
max-height: 100% !important;
|
|
||||||
|
|
||||||
&.was-collapsed .card-body {
|
|
||||||
display: block !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-body {
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-lte-toggle="card-collapse"] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-lte-icon="maximize"] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header,
|
|
||||||
.card-footer {
|
|
||||||
@include border-radius(0 !important);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:not(.maximized-card) {
|
|
||||||
[data-lte-icon="minimize"] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// collapsed mode
|
|
||||||
&.collapsed-card {
|
|
||||||
// Use > .card-header to scope to direct card only, not nested cards
|
|
||||||
> .card-header [data-lte-icon="collapse"] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
> .card-body,
|
|
||||||
> .card-footer {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:not(.collapsed-card) {
|
|
||||||
// Use > .card-header to scope to direct card only, not nested cards
|
|
||||||
> .card-header [data-lte-icon="expand"] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.nav.flex-column {
|
|
||||||
> li {
|
|
||||||
margin: 0;
|
|
||||||
border-bottom: 1px solid $card-border-color;
|
|
||||||
|
|
||||||
&:last-of-type {
|
|
||||||
border-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// fixed height to 300px
|
|
||||||
&.height-control {
|
|
||||||
.card-body {
|
|
||||||
max-height: 300px;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.border-end {
|
|
||||||
border-right: 1px solid $card-border-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
.border-start {
|
|
||||||
border-left: 1px solid $card-border-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.card-tabs {
|
|
||||||
&:not(.card-outline) {
|
|
||||||
> .card-header {
|
|
||||||
border-bottom: 0;
|
|
||||||
|
|
||||||
.nav-item {
|
|
||||||
&:first-child .nav-link {
|
|
||||||
border-left-color: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.card-outline {
|
|
||||||
.nav-item {
|
|
||||||
border-bottom: 0;
|
|
||||||
|
|
||||||
&:first-child .nav-link {
|
|
||||||
margin-left: 0;
|
|
||||||
border-left: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-tools {
|
|
||||||
margin: .3rem .5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:not(.expanding-card).collapsed-card {
|
|
||||||
.card-header {
|
|
||||||
border-bottom: 0;
|
|
||||||
|
|
||||||
.nav-tabs {
|
|
||||||
border-bottom: 0;
|
|
||||||
|
|
||||||
.nav-item {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.expanding-card {
|
|
||||||
.card-header {
|
|
||||||
.nav-tabs {
|
|
||||||
.nav-item {
|
|
||||||
margin-bottom: -1px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.card-outline-tabs {
|
|
||||||
border-top: 0;
|
|
||||||
|
|
||||||
.card-header {
|
|
||||||
.nav-item {
|
|
||||||
&:first-child .nav-link {
|
|
||||||
margin-left: 0;
|
|
||||||
border-left: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
text-decoration: none;
|
|
||||||
border-top: 3px solid transparent;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-top: 3px solid $nav-tabs-border-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
&:hover {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-tools {
|
|
||||||
margin: .5rem .5rem .3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:not(.expanding-card).collapsed-card .card-header {
|
|
||||||
border-bottom: 0;
|
|
||||||
|
|
||||||
.nav-tabs {
|
|
||||||
border-bottom: 0;
|
|
||||||
|
|
||||||
.nav-item {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.expanding-card {
|
|
||||||
.card-header {
|
|
||||||
.nav-tabs {
|
|
||||||
.nav-item {
|
|
||||||
margin-bottom: -1px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// Maximized Card Body Scroll fix
|
|
||||||
html.maximized-card {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add clearfix to header, body and footer
|
|
||||||
.card-header,
|
|
||||||
.card-body,
|
|
||||||
.card-footer {
|
|
||||||
@include clearfix();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Box header
|
|
||||||
.card-header {
|
|
||||||
position: relative;
|
|
||||||
padding: (($card-spacer-y * .5) * 2) $card-spacer-x;
|
|
||||||
background-color: transparent;
|
|
||||||
border-bottom: 1px solid $card-border-color;
|
|
||||||
|
|
||||||
@if $enable-rounded {
|
|
||||||
@include border-top-radius($border-radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
.collapsed-card & {
|
|
||||||
border-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
> .card-tools {
|
|
||||||
float: right;
|
|
||||||
margin-right: -$card-spacer-x * .5;
|
|
||||||
|
|
||||||
.input-group,
|
|
||||||
.nav,
|
|
||||||
.pagination {
|
|
||||||
margin-top: -$card-spacer-y * .4;
|
|
||||||
margin-bottom: -$card-spacer-y * .4;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-bs-toggle="tooltip"] {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-title {
|
|
||||||
float: left;
|
|
||||||
margin: 0;
|
|
||||||
font-size: $lte-card-title-font-size;
|
|
||||||
font-weight: $lte-card-title-font-weight;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Box Tools Buttons
|
|
||||||
.btn-tool {
|
|
||||||
--#{$prefix}btn-padding-x: .5rem;
|
|
||||||
--#{$prefix}btn-padding-y: .25rem;
|
|
||||||
|
|
||||||
&:not(.btn-tool-custom) {
|
|
||||||
--#{$prefix}btn-color: var(--#{$prefix}tertiary-color);
|
|
||||||
--#{$prefix}btn-bg: transparent;
|
|
||||||
--#{$prefix}btn-box-shadow: none;
|
|
||||||
--#{$prefix}btn-hover-color: var(--#{$prefix}secondary-color);
|
|
||||||
--#{$prefix}btn-active-border-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
margin: -$card-spacer-y 0;
|
|
||||||
font-size: $font-size-sm;
|
|
||||||
}
|
|
||||||
|
|
||||||
@each $name, $color in $theme-colors {
|
|
||||||
.card-#{$name},
|
|
||||||
.bg-#{$name},
|
|
||||||
.text-bg-#{$name} {
|
|
||||||
--#{$lte-prefix}card-variant-bg: #{$color};
|
|
||||||
--#{$lte-prefix}card-variant-bg-rgb: #{to-rgb($color)};
|
|
||||||
--#{$lte-prefix}card-variant-color: #{color-contrast($color)};
|
|
||||||
--#{$lte-prefix}card-variant-color-rgb: #{to-rgb(color-contrast($color))};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Box Body
|
|
||||||
.card-body {
|
|
||||||
// Tables within the box body
|
|
||||||
> .table {
|
|
||||||
margin-bottom: 0;
|
|
||||||
|
|
||||||
> thead > tr > th,
|
|
||||||
> thead > tr > td {
|
|
||||||
border-top-width: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
.compact-mode {
|
|
||||||
.app-header {
|
|
||||||
max-height: $lte-app-header-height-compact;
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
max-height: $nav-link-height-compact;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
--bs-nav-link-padding-y: .25rem;
|
|
||||||
--bs-nav-link-padding-x: .5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.sidebar-mini.sidebar-collapse {
|
|
||||||
.app-sidebar:not(:hover) {
|
|
||||||
min-width: $lte-sidebar-mini-width-compact;
|
|
||||||
max-width: $lte-sidebar-mini-width-compact;
|
|
||||||
|
|
||||||
.sidebar-menu {
|
|
||||||
.nav-link {
|
|
||||||
width: $lte-sidebar-mini-width-compact - $lte-sidebar-padding-x * 2 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-xs,
|
|
||||||
.logo-xl {
|
|
||||||
max-height: $lte-app-header-height-compact;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-image {
|
|
||||||
width: $nav-link-height-compact;
|
|
||||||
height: $nav-link-height-compact;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-brand {
|
|
||||||
height: $lte-app-header-height-compact;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-footer {
|
|
||||||
padding: $lte-app-footer-padding-compact;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-wrapper {
|
|
||||||
.nav-icon {
|
|
||||||
min-width: 1.1rem;
|
|
||||||
max-width: 1.1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,235 +0,0 @@
|
||||||
//
|
|
||||||
// Component: Direct Chat
|
|
||||||
//
|
|
||||||
|
|
||||||
.direct-chat {
|
|
||||||
.card-body {
|
|
||||||
position: relative;
|
|
||||||
padding: 0;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.chat-pane-open {
|
|
||||||
.direct-chat-contacts {
|
|
||||||
transform: translate(0, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
&.timestamp-light {
|
|
||||||
.direct-chat-timestamp {
|
|
||||||
color: rgba(var(--#{$prefix}body-color-rgb), .65);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.timestamp-dark {
|
|
||||||
.direct-chat-timestamp {
|
|
||||||
color: rgba(var(--#{$prefix}body-color-rgb), .9);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.direct-chat-messages {
|
|
||||||
height: 250px;
|
|
||||||
padding: 10px;
|
|
||||||
overflow: auto;
|
|
||||||
transform: translate(0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.direct-chat-msg,
|
|
||||||
.direct-chat-text {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.direct-chat-msg {
|
|
||||||
@include clearfix();
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.direct-chat-messages,
|
|
||||||
.direct-chat-contacts {
|
|
||||||
@include transition(transform .5s ease-in-out);
|
|
||||||
}
|
|
||||||
|
|
||||||
.direct-chat-text {
|
|
||||||
@if $enable-rounded {
|
|
||||||
@include border-radius($border-radius-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
position: relative;
|
|
||||||
padding: 5px 10px;
|
|
||||||
margin: 5px 0 0 50px;
|
|
||||||
color: $lte-direct-chat-default-font-color;
|
|
||||||
background-color: $lte-direct-chat-default-msg-bg;
|
|
||||||
border: 1px solid $lte-direct-chat-default-msg-border-color;
|
|
||||||
|
|
||||||
//Create the arrow
|
|
||||||
&::after,
|
|
||||||
&::before {
|
|
||||||
position: absolute;
|
|
||||||
top: 15px;
|
|
||||||
right: 100%;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
content: " ";
|
|
||||||
border: solid transparent;
|
|
||||||
border-right-color: $lte-direct-chat-default-msg-border-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
margin-top: -5px;
|
|
||||||
border-width: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
margin-top: -6px;
|
|
||||||
border-width: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.end & {
|
|
||||||
margin-right: 50px;
|
|
||||||
margin-left: 0;
|
|
||||||
|
|
||||||
&::after,
|
|
||||||
&::before {
|
|
||||||
right: auto;
|
|
||||||
left: 100%;
|
|
||||||
border-right-color: transparent;
|
|
||||||
border-left-color: $lte-direct-chat-default-msg-border-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.direct-chat-img {
|
|
||||||
@include border-radius(50%);
|
|
||||||
float: left;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
|
|
||||||
.end & {
|
|
||||||
float: right;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.direct-chat-infos {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
font-size: $font-size-sm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.direct-chat-name {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.direct-chat-timestamp {
|
|
||||||
color: rgba(var(--#{$prefix}body-color-rgb), .75);
|
|
||||||
}
|
|
||||||
|
|
||||||
//Direct chat contacts pane
|
|
||||||
.direct-chat-contacts-open {
|
|
||||||
.direct-chat-contacts {
|
|
||||||
transform: translate(0, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.direct-chat-contacts {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 250px;
|
|
||||||
overflow: auto;
|
|
||||||
color: var(--#{$prefix}body-bg);
|
|
||||||
background-color: var(--#{$prefix}body-color);
|
|
||||||
transform: translate(101%, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.direct-chat-contacts-light {
|
|
||||||
background-color: var(--#{$prefix}light-bg-subtle);
|
|
||||||
|
|
||||||
.contacts-list-name {
|
|
||||||
color: var(--#{$prefix}body-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.contacts-list-date {
|
|
||||||
color: var(--#{$prefix}secondary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.contacts-list-msg {
|
|
||||||
color: var(--#{$prefix}secondary-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//Contacts list -- for displaying contacts in direct chat contacts pane
|
|
||||||
.contacts-list {
|
|
||||||
@include list-unstyled();
|
|
||||||
|
|
||||||
> li {
|
|
||||||
@include clearfix();
|
|
||||||
padding: 10px;
|
|
||||||
margin: 0;
|
|
||||||
text-decoration: none;
|
|
||||||
border-bottom: 1px solid rgba($black, .2);
|
|
||||||
|
|
||||||
&:last-of-type {
|
|
||||||
border-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.contacts-list-img {
|
|
||||||
@include border-radius(50%);
|
|
||||||
float: left;
|
|
||||||
width: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contacts-list-info {
|
|
||||||
margin-left: 45px;
|
|
||||||
color: var(--#{$prefix}body-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.contacts-list-name,
|
|
||||||
.contacts-list-status {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contacts-list-name {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contacts-list-status {
|
|
||||||
font-size: $font-size-sm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contacts-list-date {
|
|
||||||
font-weight: 400;
|
|
||||||
color: var(--#{$prefix}secondary-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.contacts-list-msg {
|
|
||||||
color: var(--#{$prefix}secondary-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.end > .direct-chat-text {
|
|
||||||
color: var(--#{$lte-prefix}direct-chat-color);
|
|
||||||
background-color: var(--#{$lte-prefix}direct-chat-bg);
|
|
||||||
border-color: var(--#{$lte-prefix}direct-chat-bg);
|
|
||||||
|
|
||||||
&::after,
|
|
||||||
&::before {
|
|
||||||
border-left-color: var(--#{$lte-prefix}direct-chat-bg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Color variants
|
|
||||||
@each $name, $color in $theme-colors {
|
|
||||||
.direct-chat-#{$name} {
|
|
||||||
--#{$lte-prefix}direct-chat-color: #{color-contrast($color)};
|
|
||||||
--#{$lte-prefix}direct-chat-bg: #{$color};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,820 +0,0 @@
|
||||||
//
|
|
||||||
// Component: Docs
|
|
||||||
//
|
|
||||||
// Visual treatment for documentation pages. Body class `.docs-page` opts in.
|
|
||||||
// Demo pages are not affected.
|
|
||||||
//
|
|
||||||
|
|
||||||
// Inline code blocks rendered by MDX (Astro highlight default class)
|
|
||||||
.astro-code {
|
|
||||||
padding: .75rem;
|
|
||||||
@include border-radius($border-radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
.docs-page {
|
|
||||||
// Constrain the docs card to a comfortable reading width.
|
|
||||||
// Tables and code blocks inside can still scroll horizontally if needed.
|
|
||||||
.app-content > .container-fluid {
|
|
||||||
max-width: 60rem; // ~960px
|
|
||||||
|
|
||||||
@include media-breakpoint-up(xxl) {
|
|
||||||
max-width: 64rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Card body — breathing room + prose typography.
|
|
||||||
// Only applied inside docs content cards so demo-page card content
|
|
||||||
// stays unchanged.
|
|
||||||
//
|
|
||||||
.card > .card-body {
|
|
||||||
padding: 1.5rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1.7;
|
|
||||||
color: var(--#{$prefix}body-color);
|
|
||||||
|
|
||||||
@include media-breakpoint-up(md) {
|
|
||||||
padding: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Heading hierarchy. The MDX content in src/html/components/docs/ uses
|
|
||||||
// ##### (h5) as the dominant section heading and ###### (h6) as a
|
|
||||||
// sub-heading (most visible in FAQ.mdx, where h5 is a topic and h6 is
|
|
||||||
// a question). Style h5 as a major section and h6 as a sub-section so
|
|
||||||
// the visual hierarchy matches the semantic one.
|
|
||||||
//
|
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
h3,
|
|
||||||
h4,
|
|
||||||
h5,
|
|
||||||
h6,
|
|
||||||
.h1,
|
|
||||||
.h2,
|
|
||||||
.h3,
|
|
||||||
.h4,
|
|
||||||
.h5,
|
|
||||||
.h6 {
|
|
||||||
margin-top: 2em;
|
|
||||||
margin-bottom: .5em;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.3;
|
|
||||||
color: var(--#{$prefix}emphasis-color);
|
|
||||||
scroll-margin-top: 4rem; // anchor-link friendly
|
|
||||||
|
|
||||||
&:first-child {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h2,
|
|
||||||
.h2 {
|
|
||||||
padding-bottom: .35em;
|
|
||||||
font-size: 1.75rem;
|
|
||||||
border-bottom: 1px solid var(--#{$prefix}border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
h3,
|
|
||||||
.h3 {
|
|
||||||
font-size: 1.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h4,
|
|
||||||
.h4 {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
// h5 is the dominant section heading across docs MDX files. Give it
|
|
||||||
// a visible separator above so sections are easy to scan.
|
|
||||||
h5,
|
|
||||||
.h5 {
|
|
||||||
padding-top: 1.5em;
|
|
||||||
margin-top: 2.5em;
|
|
||||||
font-size: 1.3rem;
|
|
||||||
border-top: 1px solid var(--#{$prefix}border-color);
|
|
||||||
|
|
||||||
&:first-child {
|
|
||||||
padding-top: 0;
|
|
||||||
border-top: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// h6 is the sub-section / FAQ question style. Smaller than h5 but
|
|
||||||
// still distinctly heading-like, with a touch of accent colour to
|
|
||||||
// separate from body text.
|
|
||||||
h6,
|
|
||||||
.h6 {
|
|
||||||
margin-top: 1.75em;
|
|
||||||
font-size: 1.08rem;
|
|
||||||
color: var(--#{$prefix}emphasis-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Paragraphs and lists.
|
|
||||||
//
|
|
||||||
p,
|
|
||||||
ul,
|
|
||||||
ol,
|
|
||||||
dl,
|
|
||||||
blockquote,
|
|
||||||
table {
|
|
||||||
margin-bottom: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul,
|
|
||||||
ol {
|
|
||||||
padding-left: 1.5rem;
|
|
||||||
|
|
||||||
li {
|
|
||||||
margin-bottom: .35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tighter nested lists
|
|
||||||
ul,
|
|
||||||
ol {
|
|
||||||
margin-top: .35rem;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lead-style first paragraph (just after a heading)
|
|
||||||
h2 + p,
|
|
||||||
h3 + p,
|
|
||||||
h5 + p {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Inline code — pill style that complements MDX's syntax-highlighted blocks.
|
|
||||||
//
|
|
||||||
p code,
|
|
||||||
li code,
|
|
||||||
td code,
|
|
||||||
th code,
|
|
||||||
h2 code,
|
|
||||||
h3 code,
|
|
||||||
h4 code,
|
|
||||||
h5 code,
|
|
||||||
h6 code {
|
|
||||||
padding: .15em .4em;
|
|
||||||
font-size: .875em;
|
|
||||||
color: var(--#{$prefix}emphasis-color);
|
|
||||||
background: var(--#{$prefix}tertiary-bg);
|
|
||||||
border: 1px solid var(--#{$prefix}border-color);
|
|
||||||
@include border-radius($border-radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Code blocks.
|
|
||||||
//
|
|
||||||
pre.astro-code,
|
|
||||||
pre {
|
|
||||||
padding: 1rem 1.25rem;
|
|
||||||
margin-top: 1.25rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
overflow-x: auto;
|
|
||||||
font-size: .875rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
@include border-radius($border-radius);
|
|
||||||
|
|
||||||
code {
|
|
||||||
padding: 0;
|
|
||||||
background: transparent;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Tables — reference-doc styling with hairline borders.
|
|
||||||
//
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 1.25rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
overflow: hidden;
|
|
||||||
font-size: .95rem;
|
|
||||||
border-collapse: collapse;
|
|
||||||
border: 1px solid var(--#{$prefix}border-color);
|
|
||||||
@include border-radius($border-radius-sm);
|
|
||||||
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
padding: .65rem .9rem;
|
|
||||||
text-align: left;
|
|
||||||
vertical-align: top;
|
|
||||||
border-bottom: 1px solid var(--#{$prefix}border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--#{$prefix}emphasis-color);
|
|
||||||
background: var(--#{$prefix}tertiary-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
tr:last-child td {
|
|
||||||
border-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Blockquotes used as info callouts.
|
|
||||||
//
|
|
||||||
blockquote {
|
|
||||||
padding: .85rem 1.1rem;
|
|
||||||
margin-top: 1.25rem;
|
|
||||||
color: var(--#{$prefix}emphasis-color);
|
|
||||||
background: var(--#{$prefix}info-bg-subtle);
|
|
||||||
border-left: 4px solid var(--#{$prefix}info-border-subtle);
|
|
||||||
@include border-radius($border-radius-sm);
|
|
||||||
|
|
||||||
p:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
background: rgba(0, 0, 0, .05);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Prose links — stand out more than Bootstrap defaults.
|
|
||||||
//
|
|
||||||
a:not(.btn):not(.nav-link) {
|
|
||||||
color: var(--#{$prefix}primary);
|
|
||||||
text-decoration: underline;
|
|
||||||
text-decoration-thickness: 1px;
|
|
||||||
text-underline-offset: 3px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
text-decoration-thickness: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Horizontal rule — used as additional section separator.
|
|
||||||
//
|
|
||||||
hr {
|
|
||||||
margin: 2.5rem 0;
|
|
||||||
border: 0;
|
|
||||||
border-top: 1px solid var(--#{$prefix}border-color);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Lead paragraph — the first paragraph in any doc is a summary.
|
|
||||||
// Visually heavier so it functions as a deck.
|
|
||||||
//
|
|
||||||
> p:first-child,
|
|
||||||
> p:first-of-type {
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
font-size: 1.125rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: var(--#{$prefix}body-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// FAQ / disclosure accordion — uses native <details>/<summary>.
|
|
||||||
// Add `.faq-item` to <details> to opt in.
|
|
||||||
//
|
|
||||||
details.faq-item {
|
|
||||||
margin-bottom: .5rem;
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--#{$prefix}body-bg);
|
|
||||||
border: 1px solid var(--#{$prefix}border-color);
|
|
||||||
@include border-radius($border-radius);
|
|
||||||
@include transition(border-color .15s ease, box-shadow .15s ease);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: var(--#{$prefix}primary-border-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
&[open] {
|
|
||||||
background: var(--#{$prefix}body-bg);
|
|
||||||
border-color: var(--#{$prefix}primary-border-subtle);
|
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, .04);
|
|
||||||
}
|
|
||||||
|
|
||||||
summary {
|
|
||||||
position: relative;
|
|
||||||
padding: .9rem 3rem .9rem 1.1rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--#{$prefix}emphasis-color);
|
|
||||||
list-style: none;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
@include transition(color .15s ease);
|
|
||||||
|
|
||||||
// Hide the default disclosure triangle in Webkit/Blink
|
|
||||||
&::-webkit-details-marker {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide the marker in modern Firefox too
|
|
||||||
&::marker {
|
|
||||||
content: "";
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--#{$prefix}primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Right-side chevron, rotates on open
|
|
||||||
&::after {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
right: 1.1rem;
|
|
||||||
display: inline-block;
|
|
||||||
width: .55rem;
|
|
||||||
height: .55rem;
|
|
||||||
margin-top: -.4rem;
|
|
||||||
content: "";
|
|
||||||
border-right: 2px solid currentcolor;
|
|
||||||
border-bottom: 2px solid currentcolor;
|
|
||||||
opacity: .55;
|
|
||||||
transform: rotate(45deg);
|
|
||||||
@include transition(transform .2s ease);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&[open] > summary {
|
|
||||||
color: var(--#{$prefix}primary);
|
|
||||||
border-bottom: 1px solid var(--#{$prefix}border-color);
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
margin-top: -.15rem;
|
|
||||||
opacity: 1;
|
|
||||||
transform: rotate(-135deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Content (everything after <summary>) gets the inner padding
|
|
||||||
> *:not(summary) {
|
|
||||||
padding-right: 1.1rem;
|
|
||||||
padding-left: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
> *:not(summary):first-of-type {
|
|
||||||
padding-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
> *:not(summary):last-child {
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tighten paragraphs/lists inside an FAQ answer
|
|
||||||
p,
|
|
||||||
ul,
|
|
||||||
ol {
|
|
||||||
margin-bottom: .65rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
> *:last-child p:last-child,
|
|
||||||
> p:last-child,
|
|
||||||
> ul:last-child,
|
|
||||||
> ol:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Code blocks inside an FAQ answer get a less-distracting bg
|
|
||||||
pre.astro-code,
|
|
||||||
pre {
|
|
||||||
margin-right: 0;
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dark-mode tweak: blockquote info bg needs more contrast on dark
|
|
||||||
&[data-bs-theme="dark"],
|
|
||||||
[data-bs-theme="dark"] & {
|
|
||||||
.card > .card-body blockquote code {
|
|
||||||
background: rgba(255, 255, 255, .08);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// FAQ page — custom layout that breaks out of the standard docs card
|
|
||||||
// to give the FAQ a more distinctive visual identity. Opt in via
|
|
||||||
// body.faq-page (set in pages/docs/faq.astro).
|
|
||||||
//
|
|
||||||
|
|
||||||
.faq-page {
|
|
||||||
// The FAQ doesn't use the standard card wrapper used by other docs,
|
|
||||||
// so the container can stretch wider for the chip strip and section
|
|
||||||
// grid.
|
|
||||||
.app-content > .container-fluid {
|
|
||||||
max-width: 64rem;
|
|
||||||
|
|
||||||
@include media-breakpoint-up(xxl) {
|
|
||||||
max-width: 72rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Hero -------------------------------------------------------------
|
|
||||||
.faq-hero {
|
|
||||||
position: relative;
|
|
||||||
padding: 3rem 1.5rem 2.5rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
overflow: hidden;
|
|
||||||
background: radial-gradient(ellipse at top left, rgba(var(--#{$prefix}primary-rgb), .12), transparent 60%), radial-gradient(ellipse at bottom right, rgba(var(--#{$prefix}info-rgb), .1), transparent 65%), var(--#{$prefix}body-bg);
|
|
||||||
border: 1px solid var(--#{$prefix}border-color);
|
|
||||||
@include border-radius($border-radius-xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
.faq-hero-eyebrow {
|
|
||||||
display: inline-flex;
|
|
||||||
gap: .4rem;
|
|
||||||
align-items: center;
|
|
||||||
padding: .35rem .85rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
font-size: .75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--#{$prefix}primary);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: .08em;
|
|
||||||
background: var(--#{$prefix}primary-bg-subtle);
|
|
||||||
border: 1px solid var(--#{$prefix}primary-border-subtle);
|
|
||||||
@include border-radius(50rem);
|
|
||||||
|
|
||||||
.bi {
|
|
||||||
font-size: .9rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.faq-hero-title {
|
|
||||||
margin: 0 0 .5rem;
|
|
||||||
font-size: 2.25rem;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.15;
|
|
||||||
color: var(--#{$prefix}emphasis-color);
|
|
||||||
|
|
||||||
@include media-breakpoint-up(md) {
|
|
||||||
font-size: 2.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.faq-hero-lead {
|
|
||||||
max-width: 36rem;
|
|
||||||
margin: 0 auto 1.75rem;
|
|
||||||
font-size: 1.05rem;
|
|
||||||
line-height: 1.55;
|
|
||||||
color: var(--#{$prefix}secondary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.faq-search {
|
|
||||||
position: relative;
|
|
||||||
max-width: 32rem;
|
|
||||||
margin: 0 auto;
|
|
||||||
|
|
||||||
.form-control {
|
|
||||||
height: 3rem;
|
|
||||||
padding-right: 3rem;
|
|
||||||
padding-left: 3rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
background: var(--#{$prefix}body-bg);
|
|
||||||
@include border-radius(50rem);
|
|
||||||
@include transition(box-shadow .15s ease, border-color .15s ease);
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
box-shadow: 0 0 0 .25rem rgba(var(--#{$prefix}primary-rgb), .15);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.faq-search-icon {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 1.2rem;
|
|
||||||
color: var(--#{$prefix}secondary-color);
|
|
||||||
pointer-events: none;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.faq-search-clear {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
right: .65rem;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
padding: 0;
|
|
||||||
color: var(--#{$prefix}secondary-color);
|
|
||||||
background: var(--#{$prefix}tertiary-bg);
|
|
||||||
border: 0;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
@include border-radius(50%);
|
|
||||||
@include transition(background .15s ease, color .15s ease);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--#{$prefix}body-color);
|
|
||||||
background: var(--#{$prefix}secondary-bg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.faq-empty-state {
|
|
||||||
max-width: 32rem;
|
|
||||||
padding: 1rem;
|
|
||||||
margin: 1.5rem auto 0;
|
|
||||||
font-size: .95rem;
|
|
||||||
color: var(--#{$prefix}secondary-color);
|
|
||||||
background: var(--#{$prefix}tertiary-bg);
|
|
||||||
@include border-radius($border-radius);
|
|
||||||
|
|
||||||
.bi {
|
|
||||||
margin-right: .35rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Section nav chips ------------------------------------------------
|
|
||||||
.faq-chips {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: .5rem;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.faq-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
gap: .45rem;
|
|
||||||
align-items: center;
|
|
||||||
padding: .45rem .85rem;
|
|
||||||
font-size: .875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--#{$prefix}body-color);
|
|
||||||
text-decoration: none;
|
|
||||||
background: var(--#{$prefix}body-bg);
|
|
||||||
border: 1px solid var(--#{$prefix}border-color);
|
|
||||||
@include border-radius(50rem);
|
|
||||||
@include transition(transform .15s ease, border-color .15s ease, background-color .15s ease, color .15s ease);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--#{$prefix}emphasis-color);
|
|
||||||
background: var(--#{$prefix}tertiary-bg);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bi {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.faq-chip-count {
|
|
||||||
padding: 0 .45rem;
|
|
||||||
font-size: .7rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--#{$prefix}secondary-color);
|
|
||||||
background: var(--#{$prefix}tertiary-bg);
|
|
||||||
@include border-radius(50rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-section chip accent on hover/active
|
|
||||||
@each $tone in (primary, info, warning, success, danger, secondary) {
|
|
||||||
.faq-chip-#{$tone}:hover {
|
|
||||||
color: var(--#{$prefix}#{$tone}-text-emphasis);
|
|
||||||
background: var(--#{$prefix}#{$tone}-bg-subtle);
|
|
||||||
border-color: var(--#{$prefix}#{$tone}-border-subtle);
|
|
||||||
|
|
||||||
.faq-chip-count {
|
|
||||||
color: var(--#{$prefix}#{$tone}-text-emphasis);
|
|
||||||
background: rgba(0, 0, 0, .06);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Sections ---------------------------------------------------------
|
|
||||||
.faq-section {
|
|
||||||
margin-bottom: 2.5rem;
|
|
||||||
scroll-margin-top: 5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.faq-section-header {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
align-items: center;
|
|
||||||
padding: 1rem 1.25rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
background: var(--#{$prefix}body-bg);
|
|
||||||
border: 1px solid var(--#{$prefix}border-color);
|
|
||||||
border-left: 4px solid var(--#{$prefix}primary);
|
|
||||||
@include border-radius($border-radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
.faq-section-icon {
|
|
||||||
display: inline-flex;
|
|
||||||
flex-shrink: 0;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 2.75rem;
|
|
||||||
height: 2.75rem;
|
|
||||||
font-size: 1.35rem;
|
|
||||||
color: var(--#{$prefix}primary);
|
|
||||||
background: var(--#{$prefix}primary-bg-subtle);
|
|
||||||
@include border-radius($border-radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
.faq-section-title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.4rem;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.2;
|
|
||||||
color: var(--#{$prefix}emphasis-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.faq-section-count {
|
|
||||||
margin: .1rem 0 0;
|
|
||||||
font-size: .8rem;
|
|
||||||
color: var(--#{$prefix}secondary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-section tone accents
|
|
||||||
@each $tone in (primary, info, warning, success, danger, secondary) {
|
|
||||||
.faq-section-#{$tone} {
|
|
||||||
border-left-color: var(--#{$prefix}#{$tone});
|
|
||||||
|
|
||||||
.faq-section-icon {
|
|
||||||
color: var(--#{$prefix}#{$tone}-text-emphasis);
|
|
||||||
background: var(--#{$prefix}#{$tone}-bg-subtle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- FAQ items (override the generic .docs-page details styling) ------
|
|
||||||
.faq-section-items .faq-item {
|
|
||||||
margin-bottom: .5rem;
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--#{$prefix}body-bg);
|
|
||||||
border: 1px solid var(--#{$prefix}border-color);
|
|
||||||
@include border-radius($border-radius);
|
|
||||||
@include transition(border-color .15s ease, box-shadow .15s ease, transform .15s ease);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: var(--#{$prefix}primary-border-subtle);
|
|
||||||
transform: translateX(2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
&[open] {
|
|
||||||
border-color: var(--#{$prefix}primary-border-subtle);
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, .04);
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
summary {
|
|
||||||
display: flex;
|
|
||||||
gap: .85rem;
|
|
||||||
align-items: center;
|
|
||||||
padding: 1rem 1.25rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--#{$prefix}emphasis-color);
|
|
||||||
list-style: none;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
|
|
||||||
&::-webkit-details-marker {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::marker {
|
|
||||||
content: "";
|
|
||||||
}
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
content: none; // disable the generic chevron, we use our own
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.faq-q-icon {
|
|
||||||
display: inline-flex;
|
|
||||||
flex-shrink: 0;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 1.75rem;
|
|
||||||
height: 1.75rem;
|
|
||||||
font-size: .85rem;
|
|
||||||
color: var(--#{$prefix}primary);
|
|
||||||
background: var(--#{$prefix}primary-bg-subtle);
|
|
||||||
@include border-radius(50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.faq-q-text {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.faq-q-chevron {
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: var(--#{$prefix}secondary-color);
|
|
||||||
@include transition(transform .2s ease, color .2s ease);
|
|
||||||
}
|
|
||||||
|
|
||||||
&[open] .faq-q-chevron {
|
|
||||||
color: var(--#{$prefix}primary);
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
&[open] summary {
|
|
||||||
color: var(--#{$prefix}primary);
|
|
||||||
border-bottom: 1px solid var(--#{$prefix}border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.faq-answer {
|
|
||||||
padding: 1.1rem 1.25rem 1.1rem 3.85rem; // align with the question text (icon + gap)
|
|
||||||
font-size: .95rem;
|
|
||||||
line-height: 1.65;
|
|
||||||
color: var(--#{$prefix}body-color);
|
|
||||||
|
|
||||||
p:last-child,
|
|
||||||
ul:last-child,
|
|
||||||
ol:last-child,
|
|
||||||
pre:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: var(--#{$prefix}primary);
|
|
||||||
text-decoration: underline;
|
|
||||||
text-underline-offset: 2px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
text-decoration-thickness: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
padding: .12em .35em;
|
|
||||||
font-size: .875em;
|
|
||||||
color: var(--#{$prefix}emphasis-color);
|
|
||||||
background: var(--#{$prefix}tertiary-bg);
|
|
||||||
border: 1px solid var(--#{$prefix}border-color);
|
|
||||||
@include border-radius($border-radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
pre.astro-code,
|
|
||||||
pre {
|
|
||||||
padding: .85rem 1rem;
|
|
||||||
margin-top: .75rem;
|
|
||||||
margin-bottom: .75rem;
|
|
||||||
overflow-x: auto;
|
|
||||||
font-size: .85rem;
|
|
||||||
line-height: 1.55;
|
|
||||||
@include border-radius($border-radius-sm);
|
|
||||||
|
|
||||||
code {
|
|
||||||
padding: 0;
|
|
||||||
background: transparent;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- CTA footer -------------------------------------------------------
|
|
||||||
.faq-cta {
|
|
||||||
padding: 2.5rem 1.5rem;
|
|
||||||
text-align: center;
|
|
||||||
background: radial-gradient(ellipse at top, rgba(var(--#{$prefix}primary-rgb), .08), transparent 60%), var(--#{$prefix}body-bg);
|
|
||||||
border: 1px solid var(--#{$prefix}border-color);
|
|
||||||
@include border-radius($border-radius-xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
.faq-cta-icon {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 3.5rem;
|
|
||||||
height: 3.5rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
font-size: 1.6rem;
|
|
||||||
color: var(--#{$prefix}primary);
|
|
||||||
background: var(--#{$prefix}primary-bg-subtle);
|
|
||||||
@include border-radius(50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.faq-cta h2 {
|
|
||||||
margin: 0 0 .5rem;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--#{$prefix}emphasis-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.faq-cta p {
|
|
||||||
max-width: 32rem;
|
|
||||||
margin: 0 auto 1.5rem;
|
|
||||||
color: var(--#{$prefix}secondary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.faq-cta-actions {
|
|
||||||
display: inline-flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: .5rem;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,225 +0,0 @@
|
||||||
//
|
|
||||||
// Component: Dropdown
|
|
||||||
//
|
|
||||||
|
|
||||||
// General Dropdown Rules
|
|
||||||
|
|
||||||
// Ensure children cannot overflow and break the dropdown border radius
|
|
||||||
.dropdown-menu {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fs-7 {
|
|
||||||
.dropdown-menu {
|
|
||||||
font-size: $font-size-sm !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-toggle::after {
|
|
||||||
vertical-align: .2rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item-title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: $font-size-base;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-icon {
|
|
||||||
&::after {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dropdown Sizes
|
|
||||||
.dropdown-menu-lg {
|
|
||||||
min-width: 280px;
|
|
||||||
max-width: 300px;
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
.dropdown-divider {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item {
|
|
||||||
padding: $dropdown-padding-y $dropdown-item-padding-x;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
word-wrap: break-word;
|
|
||||||
white-space: normal;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dropdown Submenu
|
|
||||||
.dropdown-submenu {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
> a::after {
|
|
||||||
@include caret-end();
|
|
||||||
float: right;
|
|
||||||
margin-top: .5rem;
|
|
||||||
margin-left: .5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
> .dropdown-menu {
|
|
||||||
top: 0;
|
|
||||||
left: 100%;
|
|
||||||
margin-top: 0;
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dropdown Hover
|
|
||||||
.dropdown-hover {
|
|
||||||
&:hover,
|
|
||||||
&.nav-item.dropdown:hover,
|
|
||||||
.dropdown-submenu:hover,
|
|
||||||
&.dropdown-submenu:hover {
|
|
||||||
> .dropdown-menu {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Dropdown Sizes
|
|
||||||
.dropdown-menu-xl {
|
|
||||||
min-width: 360px;
|
|
||||||
max-width: 420px;
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
.dropdown-divider {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item {
|
|
||||||
padding: $dropdown-padding-y $dropdown-item-padding-x;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
word-wrap: break-word;
|
|
||||||
white-space: normal;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dropdown header and footer
|
|
||||||
.dropdown-footer,
|
|
||||||
.dropdown-header {
|
|
||||||
display: block;
|
|
||||||
padding: .5rem $dropdown-item-padding-x;
|
|
||||||
font-size: $font-size-sm;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add fade animation to dropdown menus by appending
|
|
||||||
// the class .animated-dropdown-menu to the .dropdown-menu ul (or ol)
|
|
||||||
.open:not(.dropup) > .animated-dropdown-menu {
|
|
||||||
animation: flipInX .7s both;
|
|
||||||
backface-visibility: visible !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fix dropdown menu in navbars
|
|
||||||
.navbar-custom-menu > .navbar-nav {
|
|
||||||
> li {
|
|
||||||
position: relative;
|
|
||||||
> .dropdown-menu {
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
left: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@include media-breakpoint-down(sm) {
|
|
||||||
.navbar-custom-menu > .navbar-nav {
|
|
||||||
float: right;
|
|
||||||
> li {
|
|
||||||
position: static;
|
|
||||||
> .dropdown-menu {
|
|
||||||
position: absolute;
|
|
||||||
right: 5%;
|
|
||||||
left: auto;
|
|
||||||
background-color: var(--#{$prefix}body-bg);
|
|
||||||
border: 1px solid var(--#{$prefix}border-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// User Menu
|
|
||||||
.navbar-nav > .user-menu {
|
|
||||||
> .nav-link::after {
|
|
||||||
content: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
> .dropdown-menu {
|
|
||||||
width: 280px;
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
// Header menu
|
|
||||||
> li.user-header {
|
|
||||||
min-height: 175px;
|
|
||||||
padding: 10px;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
// User image
|
|
||||||
> img {
|
|
||||||
z-index: 5;
|
|
||||||
width: 90px;
|
|
||||||
height: 90px;
|
|
||||||
border: 3px solid;
|
|
||||||
border-color: transparent;
|
|
||||||
border-color: var(--#{$prefix}border-color-translucent);
|
|
||||||
}
|
|
||||||
|
|
||||||
> p {
|
|
||||||
z-index: 5;
|
|
||||||
margin-top: 10px;
|
|
||||||
font-size: 17px;
|
|
||||||
word-wrap: break-word;
|
|
||||||
|
|
||||||
> small {
|
|
||||||
display: block;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Menu Body
|
|
||||||
> .user-body {
|
|
||||||
@include clearfix();
|
|
||||||
padding: 15px;
|
|
||||||
border-top: 1px solid var(--#{$prefix}border-color);
|
|
||||||
border-bottom: 1px solid var(--#{$prefix}border-color-translucent);
|
|
||||||
|
|
||||||
a {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Menu Footer
|
|
||||||
> .user-footer {
|
|
||||||
@include clearfix();
|
|
||||||
padding: 10px;
|
|
||||||
background-color: var(--#{$prefix}light-bg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-image {
|
|
||||||
@include media-breakpoint-up(sm) {
|
|
||||||
float: none;
|
|
||||||
margin-top: -8px;
|
|
||||||
margin-right: .4rem;
|
|
||||||
line-height: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
float: left;
|
|
||||||
width: $lte-sidebar-user-image-width;
|
|
||||||
height: $lte-sidebar-user-image-width;
|
|
||||||
margin-top: -2px;
|
|
||||||
// margin-right: 10px;
|
|
||||||
@include border-radius(50%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,131 +0,0 @@
|
||||||
//
|
|
||||||
// Component: Info Box
|
|
||||||
//
|
|
||||||
|
|
||||||
.info-box {
|
|
||||||
@include box-shadow($lte-card-shadow);
|
|
||||||
@include border-radius($border-radius);
|
|
||||||
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 80px;
|
|
||||||
padding: .5rem;
|
|
||||||
margin-bottom: map-get($spacers, 3);
|
|
||||||
color: var(--#{$prefix}body-color);
|
|
||||||
background-color: var(--#{$prefix}body-bg);
|
|
||||||
|
|
||||||
.progress {
|
|
||||||
height: 2px;
|
|
||||||
margin: 5px 0;
|
|
||||||
background-color: rgba(var(--#{$lte-prefix}card-variant-color-rgb), .125);
|
|
||||||
|
|
||||||
.progress-bar {
|
|
||||||
background-color: var(--#{$lte-prefix}card-variant-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-box-icon {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 70px;
|
|
||||||
font-size: 1.875rem;
|
|
||||||
text-align: center;
|
|
||||||
@include border-radius($border-radius);
|
|
||||||
|
|
||||||
> img {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-box-content {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0 10px;
|
|
||||||
line-height: 1.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-box-number {
|
|
||||||
display: block;
|
|
||||||
margin-top: .25rem;
|
|
||||||
font-weight: $font-weight-bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-description,
|
|
||||||
.info-box-text {
|
|
||||||
display: block;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-box-more {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-description {
|
|
||||||
margin: 0;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@include media-breakpoint-up(md) {
|
|
||||||
.col-xl-2 &,
|
|
||||||
.col-lg-2 &,
|
|
||||||
.col-md-2 & {
|
|
||||||
.progress-description {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-xl-3 &,
|
|
||||||
.col-lg-3 &,
|
|
||||||
.col-md-3 & {
|
|
||||||
.progress-description {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@include media-breakpoint-up(lg) {
|
|
||||||
.col-xl-2 &,
|
|
||||||
.col-lg-2 &,
|
|
||||||
.col-md-2 & {
|
|
||||||
.progress-description {
|
|
||||||
@include font-size(.75rem);
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-xl-3 &,
|
|
||||||
.col-lg-3 &,
|
|
||||||
.col-md-3 & {
|
|
||||||
.progress-description {
|
|
||||||
@include font-size(.75rem);
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@include media-breakpoint-up(xl) {
|
|
||||||
.col-xl-2 &,
|
|
||||||
.col-lg-2 &,
|
|
||||||
.col-md-2 & {
|
|
||||||
.progress-description {
|
|
||||||
@include font-size(1rem);
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-xl-3 &,
|
|
||||||
.col-lg-3 &,
|
|
||||||
.col-md-3 & {
|
|
||||||
.progress-description {
|
|
||||||
@include font-size(1rem);
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
//
|
|
||||||
// Misc: Miscellaneous
|
|
||||||
//
|
|
||||||
|
|
||||||
// Image sizes
|
|
||||||
.img-size-64,
|
|
||||||
.img-size-50,
|
|
||||||
.img-size-32 {
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.img-size-64 {
|
|
||||||
width: 64px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.img-size-50 {
|
|
||||||
width: 50px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.img-size-32 {
|
|
||||||
width: 32px;
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
//
|
|
||||||
// General: Mixins
|
|
||||||
//
|
|
||||||
|
|
||||||
@import "mixins/animations";
|
|
||||||
@import "mixins/scrollbar";
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
//
|
|
||||||
// Component: Progress Bar
|
|
||||||
//
|
|
||||||
|
|
||||||
//General CSS
|
|
||||||
.progress {
|
|
||||||
@include box-shadow(null);
|
|
||||||
@include border-radius($lte-progress-bar-border-radius);
|
|
||||||
|
|
||||||
// Vertical bars
|
|
||||||
&.vertical {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
width: 30px;
|
|
||||||
height: 200px;
|
|
||||||
margin-right: 10px;
|
|
||||||
|
|
||||||
> .progress-bar {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
//Sizes
|
|
||||||
&.sm,
|
|
||||||
&.progress-sm {
|
|
||||||
width: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.xs,
|
|
||||||
&.progress-xs {
|
|
||||||
width: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.xxs,
|
|
||||||
&.progress-xxs {
|
|
||||||
width: 3px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-group {
|
|
||||||
margin-bottom: map-get($spacers, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// size variation
|
|
||||||
.progress-sm {
|
|
||||||
height: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-xs {
|
|
||||||
height: 7px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-xxs {
|
|
||||||
height: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove margins from progress bars when put in a table
|
|
||||||
.table {
|
|
||||||
tr > td {
|
|
||||||
.progress {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
:root,
|
|
||||||
[data-bs-theme="light"] {
|
|
||||||
// Sidebar
|
|
||||||
--#{$lte-prefix}sidebar-width: #{$lte-sidebar-width};
|
|
||||||
}
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
//
|
|
||||||
// Component: Small Box
|
|
||||||
//
|
|
||||||
|
|
||||||
.small-box {
|
|
||||||
@include border-radius($border-radius);
|
|
||||||
@include box-shadow($lte-card-shadow);
|
|
||||||
position: relative;
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 1.25rem;
|
|
||||||
--bs-link-color-rgb: none;
|
|
||||||
--bs-link-hover-color-rgb: none;
|
|
||||||
--bs-heading-color: none;
|
|
||||||
|
|
||||||
// content wrapper
|
|
||||||
> .inner {
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
> .small-box-footer {
|
|
||||||
position: relative;
|
|
||||||
z-index: 10;
|
|
||||||
display: block;
|
|
||||||
padding: 3px 0;
|
|
||||||
text-align: center;
|
|
||||||
background-color: rgba($black, .07);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: rgba($black, .1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
@include font-size(2.2rem);
|
|
||||||
padding: 0;
|
|
||||||
margin: 0 0 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include media-breakpoint-up(lg) {
|
|
||||||
.col-xl-2 &,
|
|
||||||
.col-lg-2 &,
|
|
||||||
.col-md-2 & {
|
|
||||||
h3 {
|
|
||||||
@include font-size(1.6rem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-xl-3 &,
|
|
||||||
.col-lg-3 &,
|
|
||||||
.col-md-3 & {
|
|
||||||
h3 {
|
|
||||||
@include font-size(1.6rem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@include media-breakpoint-up(xl) {
|
|
||||||
.col-xl-2 &,
|
|
||||||
.col-lg-2 &,
|
|
||||||
.col-md-2 & {
|
|
||||||
h3 {
|
|
||||||
@include font-size(2.2rem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-xl-3 &,
|
|
||||||
.col-lg-3 &,
|
|
||||||
.col-md-3 & {
|
|
||||||
h3 {
|
|
||||||
@include font-size(2.2rem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
font-size: 1rem;
|
|
||||||
|
|
||||||
> small {
|
|
||||||
display: block;
|
|
||||||
margin-top: 5px;
|
|
||||||
font-size: .9rem;
|
|
||||||
color: $gray-100;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h3,
|
|
||||||
p {
|
|
||||||
z-index: 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
// the icon
|
|
||||||
.small-box-icon {
|
|
||||||
position: absolute;
|
|
||||||
top: 15px;
|
|
||||||
right: 15px;
|
|
||||||
z-index: 0;
|
|
||||||
height: 70px;
|
|
||||||
font-size: 70px;
|
|
||||||
color: rgba($black, .15);
|
|
||||||
@include transition(transform $lte-transition-speed linear);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Small box hover state
|
|
||||||
&:hover {
|
|
||||||
// Animate icons on small box hover
|
|
||||||
.small-box-icon {
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@include media-breakpoint-down(sm) {
|
|
||||||
// No need for icons on very small devices
|
|
||||||
.small-box {
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
.small-box-icon {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
//
|
|
||||||
// Component: Table
|
|
||||||
//
|
|
||||||
|
|
||||||
.table {
|
|
||||||
&:not(.table-dark) {
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// fixed table head
|
|
||||||
// Uses Bootstrap CSS variables so the sticky header follows light/dark mode
|
|
||||||
// automatically. Fixes #6026.
|
|
||||||
&.table-head-fixed {
|
|
||||||
thead tr:nth-child(1) th {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 10;
|
|
||||||
background-color: var(--bs-body-bg, #{$white});
|
|
||||||
border-bottom: 0;
|
|
||||||
box-shadow: inset 0 1px 0 var(--bs-border-color, #{$table-border-color}), inset 0 -1px 0 var(--bs-border-color, #{$table-border-color});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// no border
|
|
||||||
&.no-border {
|
|
||||||
&,
|
|
||||||
td,
|
|
||||||
th {
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// .text-center in tables
|
|
||||||
&.text-center {
|
|
||||||
&,
|
|
||||||
td,
|
|
||||||
th {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.table-valign-middle {
|
|
||||||
thead > tr > th,
|
|
||||||
thead > tr > td,
|
|
||||||
tbody > tr > th,
|
|
||||||
tbody > tr > td {
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-body.p-0 & {
|
|
||||||
thead > tr > th,
|
|
||||||
thead > tr > td,
|
|
||||||
tfoot > tr > th,
|
|
||||||
tfoot > tr > td,
|
|
||||||
tbody > tr > th,
|
|
||||||
tbody > tr > td {
|
|
||||||
&:first-of-type {
|
|
||||||
padding-left: map-get($spacers, 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:last-of-type {
|
|
||||||
padding-right: map-get($spacers, 4);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
//
|
|
||||||
// Component: Timeline
|
|
||||||
//
|
|
||||||
|
|
||||||
.timeline {
|
|
||||||
position: relative;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0 0 45px;
|
|
||||||
// The line
|
|
||||||
&::before {
|
|
||||||
@include border-radius($border-radius);
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 31px;
|
|
||||||
width: 4px;
|
|
||||||
margin: 0;
|
|
||||||
content: "";
|
|
||||||
background-color: var(--#{$prefix}border-color);
|
|
||||||
}
|
|
||||||
// Element
|
|
||||||
> div {
|
|
||||||
&::before,
|
|
||||||
&::after {
|
|
||||||
display: table;
|
|
||||||
content: "";
|
|
||||||
}
|
|
||||||
|
|
||||||
position: relative;
|
|
||||||
margin-right: 10px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
// The content
|
|
||||||
> .timeline-item {
|
|
||||||
@include box-shadow($lte-card-shadow);
|
|
||||||
@include border-radius($border-radius);
|
|
||||||
position: relative;
|
|
||||||
padding: 0;
|
|
||||||
margin-top: 0;
|
|
||||||
margin-right: 15px;
|
|
||||||
margin-left: 60px;
|
|
||||||
color: var(--#{$prefix}body-color);
|
|
||||||
background-color: var(--#{$prefix}body-bg);
|
|
||||||
// The time and header
|
|
||||||
> .time {
|
|
||||||
float: right;
|
|
||||||
padding: 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--#{$prefix}secondary-color);
|
|
||||||
}
|
|
||||||
// Header
|
|
||||||
> .timeline-header {
|
|
||||||
padding: 10px;
|
|
||||||
margin: 0;
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 1.1;
|
|
||||||
color: var(--#{$prefix}secondary-color);
|
|
||||||
border-bottom: 1px solid var(--#{$prefix}border-color);
|
|
||||||
// Link in header
|
|
||||||
> a {
|
|
||||||
font-weight: 600;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Item body and footer
|
|
||||||
> .timeline-body,
|
|
||||||
> .timeline-footer {
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
> .timeline-body {
|
|
||||||
> img {
|
|
||||||
margin: 10px;
|
|
||||||
}
|
|
||||||
> dl,
|
|
||||||
ol,
|
|
||||||
ul {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-icon {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 18px;
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 30px;
|
|
||||||
text-align: center;
|
|
||||||
background-color: var(--#{$prefix}secondary-bg);
|
|
||||||
border-radius: 50%; // stylelint-disable-line property-disallowed-list
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Time label
|
|
||||||
> .time-label {
|
|
||||||
> span {
|
|
||||||
@include border-radius(4px);
|
|
||||||
display: inline-block;
|
|
||||||
padding: 5px;
|
|
||||||
font-weight: 600;
|
|
||||||
background-color: var(--#{$prefix}body-bg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-inverse {
|
|
||||||
> div {
|
|
||||||
> .timeline-item {
|
|
||||||
@include box-shadow(none);
|
|
||||||
background-color: var(--#{$prefix}tertiary-bg);
|
|
||||||
border: 1px solid var(--#{$prefix}border-color);
|
|
||||||
|
|
||||||
> .timeline-header {
|
|
||||||
border-bottom-color: var(--#{$prefix}border-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
//
|
|
||||||
// Toast
|
|
||||||
//
|
|
||||||
|
|
||||||
@each $name, $color in $theme-colors {
|
|
||||||
.toast-#{$name} {
|
|
||||||
--#{$prefix}toast-header-color: #{color-contrast($color)};
|
|
||||||
--#{$prefix}toast-header-bg: #{$color};
|
|
||||||
--#{$prefix}toast-header-border-color: #{$color};
|
|
||||||
--#{$prefix}toast-border-color: #{$color};
|
|
||||||
--#{$prefix}toast-bg: var(--#{$prefix}#{$name}-bg-subtle);
|
|
||||||
|
|
||||||
@if color-contrast($color) == $color-contrast-light {
|
|
||||||
.btn-close {
|
|
||||||
@include btn-close-white();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@if $enable-dark-mode {
|
|
||||||
@include color-mode(dark) {
|
|
||||||
@each $name, $color in $theme-colors {
|
|
||||||
.toast-#{$name} {
|
|
||||||
@if color-contrast($color) == $color-contrast-dark {
|
|
||||||
.btn-close {
|
|
||||||
--#{$prefix}btn-close-white-filter: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
// SIDEBAR SKINS
|
|
||||||
// --------------------------------------------------------
|
|
||||||
|
|
||||||
$lte-sidebar-hover-bg-dark: rgba(255, 255, 255, .1) !default;
|
|
||||||
$lte-sidebar-color-dark: #c2c7d0 !default;
|
|
||||||
$lte-sidebar-hover-color-dark: $white !default;
|
|
||||||
$lte-sidebar-active-color-dark: $white !default;
|
|
||||||
$lte-sidebar-menu-active-bg-dark: rgba(255, 255, 255, .1) !default;
|
|
||||||
$lte-sidebar-menu-active-color-dark: $white !default;
|
|
||||||
$lte-sidebar-submenu-bg-dark: transparent !default;
|
|
||||||
$lte-sidebar-submenu-color-dark: #c2c7d0 !default;
|
|
||||||
$lte-sidebar-submenu-hover-color-dark: $white !default;
|
|
||||||
$lte-sidebar-submenu-hover-bg-dark: rgba(255, 255, 255, .1) !default;
|
|
||||||
$lte-sidebar-submenu-active-color-dark: $white !default;
|
|
||||||
$lte-sidebar-submenu-active-bg-dark: rgba(255, 255, 255, .1) !default;
|
|
||||||
$lte-sidebar-header-color-dark: tint-color(#c2c7d0, 5%) !default;
|
|
||||||
|
|
@ -1,131 +0,0 @@
|
||||||
//
|
|
||||||
// Custom AdminLTE Variables
|
|
||||||
//
|
|
||||||
|
|
||||||
// Prefix for :root CSS variables and others.
|
|
||||||
$lte-prefix: lte- !default;
|
|
||||||
|
|
||||||
// TRANSITIONS SETTINGS
|
|
||||||
// --------------------------------------------------------
|
|
||||||
// Transition global options
|
|
||||||
$lte-transition-speed: .3s !default;
|
|
||||||
$lte-transition-fn: ease-in-out !default;
|
|
||||||
|
|
||||||
// SIDEBAR
|
|
||||||
// --------------------------------------------------------
|
|
||||||
$lte-sidebar-width: 250px !default;
|
|
||||||
$lte-sidebar-breakpoint: lg !default;
|
|
||||||
$lte-sidebar-padding-x: .5rem !default;
|
|
||||||
$lte-sidebar-padding-x-compact: .5rem !default;
|
|
||||||
$lte-sidebar-padding-y: .5rem !default;
|
|
||||||
$lte-sidebar-padding-y-compact: .25rem !default;
|
|
||||||
$lte-sidebar-transition: min-width $lte-transition-speed $lte-transition-fn,
|
|
||||||
max-width $lte-transition-speed $lte-transition-fn,
|
|
||||||
margin-left $lte-transition-speed $lte-transition-fn,
|
|
||||||
margin-right $lte-transition-speed $lte-transition-fn !default;
|
|
||||||
|
|
||||||
// SIDEBAR SKINS
|
|
||||||
// --------------------------------------------------------
|
|
||||||
|
|
||||||
$lte-sidebar-hover-bg: rgba($black, .1) !default;
|
|
||||||
$lte-sidebar-color: $gray-800 !default;
|
|
||||||
$lte-sidebar-hover-color: $gray-900 !default;
|
|
||||||
$lte-sidebar-active-color: $black !default;
|
|
||||||
$lte-sidebar-menu-active-bg: rgba($black, .1) !default;
|
|
||||||
$lte-sidebar-menu-active-color: $black !default;
|
|
||||||
$lte-sidebar-submenu-bg: transparent !default;
|
|
||||||
$lte-sidebar-submenu-color: #777 !default;
|
|
||||||
$lte-sidebar-submenu-hover-color: $black !default;
|
|
||||||
$lte-sidebar-submenu-hover-bg: rgba($black, .1) !default;
|
|
||||||
$lte-sidebar-submenu-active-color: $gray-900 !default;
|
|
||||||
$lte-sidebar-submenu-active-bg: rgba($black, .1) !default;
|
|
||||||
$lte-sidebar-header-color: shade-color($gray-800, 5%) !default;
|
|
||||||
|
|
||||||
// SIDEBAR MINI
|
|
||||||
// --------------------------------------------------------
|
|
||||||
$nav-link-padding-x-compact: .25rem !default;
|
|
||||||
$lte-sidebar-mini-width: ($nav-link-padding-x + $lte-sidebar-padding-x + .8rem) * 2 !default;
|
|
||||||
$lte-sidebar-mini-width-compact: ($nav-link-padding-x-compact + $lte-sidebar-padding-x-compact + .8rem) * 2 !default;
|
|
||||||
$lte-sidebar-nav-icon-width: $lte-sidebar-mini-width - (($lte-sidebar-padding-x + $nav-link-padding-x) * 2) !default;
|
|
||||||
$lte-sidebar-user-image-width: $lte-sidebar-nav-icon-width + ($nav-link-padding-x * .4) !default;
|
|
||||||
|
|
||||||
// MAIN HEADER
|
|
||||||
// --------------------------------------------------------
|
|
||||||
$nav-link-height-compact: 1.75rem !default;
|
|
||||||
$lte-app-header-bottom-border-width: $border-width !default;
|
|
||||||
$lte-app-header-bottom-border-color: var(--#{$prefix}border-color) !default;
|
|
||||||
$lte-app-header-bottom-border: $lte-app-header-bottom-border-width solid $lte-app-header-bottom-border-color !default;
|
|
||||||
$lte-app-header-link-padding-y: $navbar-padding-y !default;
|
|
||||||
$lte-app-header-height: ($nav-link-height + ($lte-app-header-link-padding-y * 2)) !default;
|
|
||||||
$lte-app-header-height-compact: ($nav-link-height-compact + ($lte-app-header-link-padding-y * 2)) !default;
|
|
||||||
$lte-zindex-fixed-header: $zindex-fixed !default;
|
|
||||||
|
|
||||||
// APP MAIN
|
|
||||||
// --------------------------------------------------------
|
|
||||||
$lte-app-main-padding-bottom: $grid-gutter-width * .5 !default;
|
|
||||||
|
|
||||||
// CONTENT PADDING
|
|
||||||
// --------------------------------------------------------
|
|
||||||
$lte-content-padding-y: 0 !default;
|
|
||||||
$lte-content-padding-x: .5rem !default;
|
|
||||||
|
|
||||||
// MAIN FOOTER
|
|
||||||
// --------------------------------------------------------
|
|
||||||
$lte-app-footer-padding: 1rem !default;
|
|
||||||
$lte-app-footer-padding-compact: .5rem !default;
|
|
||||||
$lte-app-footer-border-top-width: 1px !default;
|
|
||||||
$lte-app-footer-border-top-color: var(--#{$prefix}border-color) !default;
|
|
||||||
$lte-app-footer-border-top: $lte-app-footer-border-top-width solid $lte-app-footer-border-top-color !default;
|
|
||||||
$lte-app-footer-bg: var(--#{$prefix}body-bg) !default;
|
|
||||||
$lte-app-footer-color: var(--#{$prefix}secondary-color) !default;
|
|
||||||
$lte-zindex-fixed-footer: $zindex-fixed !default;
|
|
||||||
// CONTENT BOTTOM AREA
|
|
||||||
// --------------------------------------------------------
|
|
||||||
$lte-app-content-bottom-area-margin-bottom: -$lte-app-main-padding-bottom !default;
|
|
||||||
$lte-app-content-bottom-area-color: $lte-app-footer-color !default;
|
|
||||||
$lte-app-content-bottom-area-bg: $lte-app-footer-bg !default;
|
|
||||||
$lte-app-content-bottom-area-top-border: $lte-app-footer-border-top !default;
|
|
||||||
$lte-app-content-bottom-area-padding-y: $lte-app-footer-padding !default;
|
|
||||||
$lte-app-content-bottom-area-padding-x: 0 !default;
|
|
||||||
|
|
||||||
// CONTENT TOP AREA
|
|
||||||
// --------------------------------------------------------
|
|
||||||
$lte-app-content-top-area-top-border: $lte-app-footer-border-top !default;
|
|
||||||
$lte-app-content-top-area-padding-y: $lte-app-footer-padding !default;
|
|
||||||
$lte-app-content-top-area-padding-x: 0 !default;
|
|
||||||
|
|
||||||
// BRAND LINK
|
|
||||||
// --------------------------------------------------------
|
|
||||||
$navbar-brand-padding-y-compact: $navbar-brand-padding-y * .75 !default;
|
|
||||||
$navbar-padding-y-compact: $navbar-padding-y * .5 !default;
|
|
||||||
$lte-brand-link-padding-y: $navbar-brand-padding-y + $navbar-padding-y !default;
|
|
||||||
$lte-brand-link-padding-y-compact: $navbar-brand-padding-y-compact + $navbar-padding-y-compact !default;
|
|
||||||
$lte-brand-link-padding-x: $lte-sidebar-padding-x !default;
|
|
||||||
$lte-brand-link-padding-x-compact: $lte-sidebar-padding-x-compact !default;
|
|
||||||
$lte-brand-link-border-buttom: 1px !default;
|
|
||||||
|
|
||||||
// CARDS
|
|
||||||
// --------------------------------------------------------
|
|
||||||
$lte-card-shadow: 0 0 1px rgba(var(--#{$prefix}body-color-rgb), .125), 0 1px 3px rgba(var(--#{$prefix}body-color-rgb), .2) !default;
|
|
||||||
$lte-card-title-font-size: 1.1rem !default;
|
|
||||||
$lte-card-title-font-weight: $font-weight-normal !default;
|
|
||||||
|
|
||||||
// PROGRESS BARS
|
|
||||||
// --------------------------------------------------------
|
|
||||||
$lte-progress-bar-border-radius: 1px !default;
|
|
||||||
|
|
||||||
// CALLOUTS
|
|
||||||
// --------------------------------------------------------
|
|
||||||
$lte-callout-link-font-weight: $alert-link-font-weight !default;
|
|
||||||
|
|
||||||
// DIRECT CHAT
|
|
||||||
// --------------------------------------------------------
|
|
||||||
$lte-direct-chat-default-msg-bg: var(--#{$prefix}secondary-bg) !default;
|
|
||||||
$lte-direct-chat-default-font-color: var(--#{$prefix}emphasis-color) !default;
|
|
||||||
$lte-direct-chat-default-msg-border-color: var(--#{$prefix}border-color) !default;
|
|
||||||
|
|
||||||
// Z-INDEX
|
|
||||||
// --------------------------------------------------------
|
|
||||||
$lte-zindex-app-header: $zindex-fixed + 4 !default;
|
|
||||||
$lte-zindex-sidebar: $zindex-fixed + 8 !default;
|
|
||||||
$lte-zindex-sidebar-overlay: $lte-zindex-sidebar - 1 !default;
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
/*!
|
|
||||||
* AdminLTE v4.0.0
|
|
||||||
* Author: Colorlib
|
|
||||||
* Website: AdminLTE.io <https://adminlte.io>
|
|
||||||
* License: Open source - MIT <https://opensource.org/licenses/MIT>
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Bootstrap Configuration
|
|
||||||
// ---------------------------------------------------
|
|
||||||
@import "bootstrap/scss/functions";
|
|
||||||
|
|
||||||
// AdminLTE Configuration
|
|
||||||
// ---------------------------------------------------
|
|
||||||
@import "bootstrap-variables"; // little modified are here
|
|
||||||
|
|
||||||
// Bootstrap Configuration
|
|
||||||
// ---------------------------------------------------
|
|
||||||
@import "bootstrap/scss/variables";
|
|
||||||
@import "bootstrap/scss/variables-dark";
|
|
||||||
@import "bootstrap/scss/maps";
|
|
||||||
@import "bootstrap/scss/mixins";
|
|
||||||
@import "bootstrap/scss/utilities";
|
|
||||||
|
|
||||||
// Bootstrap Layout & components
|
|
||||||
@import "bootstrap/scss/root";
|
|
||||||
@import "bootstrap/scss/reboot";
|
|
||||||
@import "bootstrap/scss/type";
|
|
||||||
@import "bootstrap/scss/images";
|
|
||||||
@import "bootstrap/scss/containers";
|
|
||||||
@import "bootstrap/scss/grid";
|
|
||||||
@import "bootstrap/scss/tables";
|
|
||||||
@import "bootstrap/scss/forms";
|
|
||||||
@import "bootstrap/scss/buttons";
|
|
||||||
@import "bootstrap/scss/transitions";
|
|
||||||
@import "bootstrap/scss/dropdown";
|
|
||||||
@import "bootstrap/scss/button-group";
|
|
||||||
@import "bootstrap/scss/nav";
|
|
||||||
@import "bootstrap/scss/navbar";
|
|
||||||
@import "bootstrap/scss/card";
|
|
||||||
@import "bootstrap/scss/accordion";
|
|
||||||
@import "bootstrap/scss/breadcrumb";
|
|
||||||
@import "bootstrap/scss/pagination";
|
|
||||||
@import "bootstrap/scss/badge";
|
|
||||||
@import "bootstrap/scss/alert";
|
|
||||||
@import "bootstrap/scss/progress";
|
|
||||||
@import "bootstrap/scss/list-group";
|
|
||||||
@import "bootstrap/scss/close";
|
|
||||||
@import "bootstrap/scss/toasts";
|
|
||||||
@import "bootstrap/scss/modal";
|
|
||||||
@import "bootstrap/scss/tooltip";
|
|
||||||
@import "bootstrap/scss/popover";
|
|
||||||
@import "bootstrap/scss/carousel";
|
|
||||||
@import "bootstrap/scss/spinners";
|
|
||||||
@import "bootstrap/scss/offcanvas";
|
|
||||||
@import "bootstrap/scss/placeholders";
|
|
||||||
|
|
||||||
// Bootstrap Helpers
|
|
||||||
@import "bootstrap/scss/helpers";
|
|
||||||
|
|
||||||
// Bootstrap Utilities
|
|
||||||
@import "bootstrap/scss/utilities/api";
|
|
||||||
|
|
||||||
// AdminLTE Configuration
|
|
||||||
// ---------------------------------------------------
|
|
||||||
@import "variables";
|
|
||||||
@import "variables-dark";
|
|
||||||
@import "mixins";
|
|
||||||
|
|
||||||
// AdiminLTE Parts
|
|
||||||
// ---------------------------------------------------
|
|
||||||
@import "parts/core";
|
|
||||||
@import "parts/components";
|
|
||||||
@import "parts/extra-components";
|
|
||||||
@import "parts/pages";
|
|
||||||
@import "parts/miscellaneous";
|
|
||||||
|
|
||||||
// AdminLTE Accessibility Styles - WCAG 2.1 AA Compliance
|
|
||||||
@import "accessibility";
|
|
||||||
|
|
||||||
// AdminLTE Documentation Styles (only applies to pages with body.docs-page)
|
|
||||||
@import "docs";
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
//
|
|
||||||
// Mixins: Animation
|
|
||||||
//
|
|
||||||
|
|
||||||
|
|
||||||
@keyframes flipInX {
|
|
||||||
0% {
|
|
||||||
opacity: 0;
|
|
||||||
transition-timing-function: ease-in;
|
|
||||||
transform: perspective(400px) rotate3d(1, 0, 0, 90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
40% {
|
|
||||||
transition-timing-function: ease-in;
|
|
||||||
transform: perspective(400px) rotate3d(1, 0, 0, -20deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
60% {
|
|
||||||
opacity: 1;
|
|
||||||
transform: perspective(400px) rotate3d(1, 0, 0, 10deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
80% {
|
|
||||||
transform: perspective(400px) rotate3d(1, 0, 0, -5deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
transform: perspective(400px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeOut {
|
|
||||||
from {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shake {
|
|
||||||
0% {
|
|
||||||
transform: translate(2px, 1px) rotate(0deg);
|
|
||||||
}
|
|
||||||
10% {
|
|
||||||
transform: translate(-1px, -2px) rotate(-2deg);
|
|
||||||
}
|
|
||||||
20% {
|
|
||||||
transform: translate(-3px, 0) rotate(3deg);
|
|
||||||
}
|
|
||||||
30% {
|
|
||||||
transform: translate(0, 2px) rotate(0deg);
|
|
||||||
}
|
|
||||||
40% {
|
|
||||||
transform: translate(1px, -1px) rotate(1deg);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: translate(-1px, 2px) rotate(-1deg);
|
|
||||||
}
|
|
||||||
60% {
|
|
||||||
transform: translate(-3px, 1px) rotate(0deg);
|
|
||||||
}
|
|
||||||
70% {
|
|
||||||
transform: translate(2px, 1px) rotate(-2deg);
|
|
||||||
}
|
|
||||||
80% {
|
|
||||||
transform: translate(-1px, -1px) rotate(4deg);
|
|
||||||
}
|
|
||||||
90% {
|
|
||||||
transform: translate(2px, 2px) rotate(0deg);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: translate(1px, -2px) rotate(-1deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes wobble {
|
|
||||||
0% {
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
15% {
|
|
||||||
transform: translate3d(-25%, 0, 0) rotate3d(0, 0, 1, -5deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
30% {
|
|
||||||
transform: translate3d(20%, 0, 0) rotate3d(0, 0, 1, 3deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
45% {
|
|
||||||
transform: translate3d(-15%, 0, 0) rotate3d(0, 0, 1, -3deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
60% {
|
|
||||||
transform: translate3d(10%, 0, 0) rotate3d(0, 0, 1, 2deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
75% {
|
|
||||||
transform: translate3d(-5%, 0, 0) rotate3d(0, 0, 1, -1deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
//
|
|
||||||
// Mixins: Scrollbar
|
|
||||||
//
|
|
||||||
|
|
||||||
@mixin scrollbar-color-gray() {
|
|
||||||
scrollbar-color: var(--#{$prefix}secondary-bg) transparent;
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb {
|
|
||||||
background-color: var(--#{$prefix}secondary-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-track {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-corner {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin scrollbar-width-thin() {
|
|
||||||
scrollbar-width: thin;
|
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
width: .5rem;
|
|
||||||
height: .5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin scrollbar-width-none() {
|
|
||||||
scrollbar-width: none;
|
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
width: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
//
|
|
||||||
// Pages: Lock Screen
|
|
||||||
//
|
|
||||||
|
|
||||||
// ADD THIS CLASS TO THE <BODY> TAG
|
|
||||||
.lockscreen {
|
|
||||||
// User name [optional]
|
|
||||||
.lockscreen-name {
|
|
||||||
font-weight: 600;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lockscreen-logo {
|
|
||||||
margin-bottom: 25px;
|
|
||||||
font-size: 35px;
|
|
||||||
font-weight: 300;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: var(--#{$prefix}emphasis-color);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.lockscreen-wrapper {
|
|
||||||
max-width: 400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
margin-top: 10%;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Will contain the image and the sign in form
|
|
||||||
.lockscreen-item {
|
|
||||||
position: relative;
|
|
||||||
width: 290px;
|
|
||||||
padding: 0;
|
|
||||||
margin: 10px auto 30px;
|
|
||||||
background-color: var(--#{$prefix}body-bg);
|
|
||||||
@include border-radius(4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
// User image
|
|
||||||
.lockscreen-image {
|
|
||||||
position: absolute;
|
|
||||||
top: -25px;
|
|
||||||
left: -10px;
|
|
||||||
z-index: 10;
|
|
||||||
padding: 5px;
|
|
||||||
background-color: var(--#{$prefix}body-bg);
|
|
||||||
@include border-radius(50%);
|
|
||||||
|
|
||||||
> img {
|
|
||||||
@include border-radius(50%);
|
|
||||||
width: 70px;
|
|
||||||
height: 70px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Contains the password input and the login button
|
|
||||||
.lockscreen-credentials {
|
|
||||||
margin-left: 70px;
|
|
||||||
|
|
||||||
.form-control {
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
padding: 0 10px;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.lockscreen-footer {
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
//
|
|
||||||
// Pages: Login & Register
|
|
||||||
//
|
|
||||||
|
|
||||||
.login-logo,
|
|
||||||
.register-logo {
|
|
||||||
margin-bottom: .9rem;
|
|
||||||
font-size: 2.1rem;
|
|
||||||
font-weight: 300;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: var(--#{$prefix}secondary-color);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-page,
|
|
||||||
.register-page {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-box,
|
|
||||||
.register-box {
|
|
||||||
width: 400px;
|
|
||||||
|
|
||||||
@media (max-width: map-get($grid-breakpoints, sm)) {
|
|
||||||
width: 90%;
|
|
||||||
margin-top: .5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-card-body,
|
|
||||||
.register-card-body {
|
|
||||||
padding: 20px;
|
|
||||||
color: var(--#{$prefix}secondary-color);
|
|
||||||
background-color: var(--#{$prefix}body-bg);
|
|
||||||
border-top: 0;
|
|
||||||
|
|
||||||
.input-group {
|
|
||||||
.form-control {
|
|
||||||
&:focus {
|
|
||||||
box-shadow: none;
|
|
||||||
|
|
||||||
~ .input-group-prepend .input-group-text,
|
|
||||||
~ .input-group-append .input-group-text {
|
|
||||||
border-color: $input-focus-border-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.is-valid {
|
|
||||||
&:focus {
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
~ .input-group-prepend .input-group-text,
|
|
||||||
~ .input-group-append .input-group-text {
|
|
||||||
border-color: $success;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.is-invalid {
|
|
||||||
&:focus {
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
~ .input-group-append .input-group-text {
|
|
||||||
border-color: $danger;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-group-text {
|
|
||||||
color: var(--#{$prefix}secondary-color);
|
|
||||||
background-color: transparent;
|
|
||||||
@include border-top-end-radius($border-radius);
|
|
||||||
@include border-bottom-end-radius($border-radius);
|
|
||||||
@include transition($input-transition);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-box-msg,
|
|
||||||
.register-box-msg {
|
|
||||||
padding: 0 20px 20px;
|
|
||||||
margin: 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-auth-links {
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
//
|
|
||||||
// Part: Components
|
|
||||||
//
|
|
||||||
|
|
||||||
@import "../progress-bars";
|
|
||||||
@import "../cards";
|
|
||||||
@import "../table";
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
//
|
|
||||||
// Part: Core
|
|
||||||
//
|
|
||||||
|
|
||||||
@import "../root";
|
|
||||||
@import "../app-wrapper";
|
|
||||||
@import "../app-content";
|
|
||||||
@import "../app-header";
|
|
||||||
@import "../app-sidebar";
|
|
||||||
@import "../app-main";
|
|
||||||
@import "../app-footer";
|
|
||||||
@import "../dropdown";
|
|
||||||
@import "../callouts";
|
|
||||||
@import "../compact-mode";
|
|
||||||
@import "../docs";
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
//
|
|
||||||
// Part: Extra Components
|
|
||||||
//
|
|
||||||
|
|
||||||
@import "../small-box";
|
|
||||||
@import "../info-box";
|
|
||||||
@import "../timeline";
|
|
||||||
@import "../direct-chat";
|
|
||||||
@import "../toasts";
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
//
|
|
||||||
// Part: Miscellaneous
|
|
||||||
//
|
|
||||||
|
|
||||||
@import "../miscellaneous";
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
//
|
|
||||||
// Part: Pages
|
|
||||||
//
|
|
||||||
|
|
||||||
@import "../pages/login_and_register";
|
|
||||||
@import "../pages/lockscreen";
|
|
||||||
|
|
@ -1,13 +1,9 @@
|
||||||
// AdminLTE v4.0.0 + Bootstrap 5.3.8 - PageTop Bootsier Styles.
|
// Bootstrap 5.3.8 - PageTop Bootsier Styles.
|
||||||
|
|
||||||
// Bootstrap Configuration
|
// Bootstrap Configuration
|
||||||
// ---------------------------------------------------
|
// ---------------------------------------------------
|
||||||
@import "bootstrap-5.3.8/scss/functions";
|
@import "bootstrap-5.3.8/scss/functions";
|
||||||
|
|
||||||
// AdminLTE Configuration (little modified are here)
|
|
||||||
// ---------------------------------------------------
|
|
||||||
@import "adminlte-4.0.0/scss/bootstrap-variables";
|
|
||||||
|
|
||||||
// Bootsier Bootstrap variable overrides
|
// Bootsier Bootstrap variable overrides
|
||||||
// ---------------------------------------------------
|
// ---------------------------------------------------
|
||||||
@import "bootsier-variables";
|
@import "bootsier-variables";
|
||||||
|
|
@ -62,26 +58,6 @@
|
||||||
// Bootstrap Utilities
|
// Bootstrap Utilities
|
||||||
@import "bootstrap-5.3.8/scss/utilities/api";
|
@import "bootstrap-5.3.8/scss/utilities/api";
|
||||||
|
|
||||||
// AdminLTE Configuration
|
|
||||||
// ---------------------------------------------------
|
|
||||||
@import "adminlte-4.0.0/scss/variables";
|
|
||||||
@import "adminlte-4.0.0/scss/variables-dark";
|
|
||||||
@import "adminlte-4.0.0/scss/mixins";
|
|
||||||
|
|
||||||
// AdminLTE Parts
|
|
||||||
// ---------------------------------------------------
|
|
||||||
@import "adminlte-4.0.0/scss/parts/core";
|
|
||||||
@import "adminlte-4.0.0/scss/parts/components";
|
|
||||||
@import "adminlte-4.0.0/scss/parts/extra-components";
|
|
||||||
@import "adminlte-4.0.0/scss/parts/pages";
|
|
||||||
@import "adminlte-4.0.0/scss/parts/miscellaneous";
|
|
||||||
|
|
||||||
// AdminLTE Accessibility Styles - WCAG 2.1 AA Compliance
|
|
||||||
@import "adminlte-4.0.0/scss/accessibility";
|
|
||||||
|
|
||||||
// AdminLTE Documentation Styles (only applies to pages with body.docs-page)
|
|
||||||
@import "adminlte-4.0.0/scss/docs";
|
|
||||||
|
|
||||||
// Bootstrap Icons 1.13.1
|
// Bootstrap Icons 1.13.1
|
||||||
// ---------------------------------------------------
|
// ---------------------------------------------------
|
||||||
@import "bootsier-icons";
|
@import "bootsier-icons";
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,8 @@
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// Fullscreen: keeps maximize/minimize icons in sync with the actual fullscreen state.
|
|
||||||
document.addEventListener('fullscreenchange', function () {
|
|
||||||
var isFs = !!document.fullscreenElement;
|
|
||||||
document.querySelectorAll('[data-lte-icon="maximize"]').forEach(function (el) {
|
|
||||||
el.classList.toggle('d-none', isFs);
|
|
||||||
});
|
|
||||||
document.querySelectorAll('[data-lte-icon="minimize"]').forEach(function (el) {
|
|
||||||
el.classList.toggle('d-none', !isFs);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Color mode selector (light / dark / auto).
|
// Color mode selector (light / dark / auto).
|
||||||
var STORAGE_KEY = 'lte-theme';
|
var STORAGE_KEY = 'bootsier-theme';
|
||||||
var getStored = function () { return localStorage.getItem(STORAGE_KEY); };
|
var getStored = function () { return localStorage.getItem(STORAGE_KEY); };
|
||||||
var setStored = function (theme) { localStorage.setItem(STORAGE_KEY, theme); };
|
var setStored = function (theme) { localStorage.setItem(STORAGE_KEY, theme); };
|
||||||
var prefersDark = function () {
|
var prefersDark = function () {
|
||||||
|
|
@ -39,8 +28,8 @@
|
||||||
var check = active.querySelector('.bi-check-lg');
|
var check = active.querySelector('.bi-check-lg');
|
||||||
if (check) { check.classList.remove('d-none'); }
|
if (check) { check.classList.remove('d-none'); }
|
||||||
}
|
}
|
||||||
document.querySelectorAll('[data-lte-theme-icon]').forEach(function (icon) {
|
document.querySelectorAll('[data-bs-theme-icon]').forEach(function (icon) {
|
||||||
icon.classList.toggle('d-none', icon.dataset.lteThemeIcon !== theme);
|
icon.classList.toggle('d-none', icon.dataset.bsThemeIcon !== theme);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -3,13 +3,13 @@
|
||||||
//! Genera el directorio `static/` a partir de `assets/` y embebe su contenido en el binario:
|
//! Genera el directorio `static/` a partir de `assets/` y embebe su contenido en el binario:
|
||||||
//!
|
//!
|
||||||
//! - `static/css/` - CSS compilado a partir de los archivos SCSS de `assets/`.
|
//! - `static/css/` - CSS compilado a partir de los archivos SCSS de `assets/`.
|
||||||
//! - `static/js/` - JS copiado desde `assets/`, renombrando AdminLTE a `bootsier.min.js`.
|
//! - `static/js/` - JS copiado o minificado desde `assets/`.
|
||||||
//! - `static/fonts/` - Fuentes copiadas desde `assets/`.
|
//! - `static/fonts/` - Fuentes copiadas desde `assets/`.
|
||||||
//!
|
//!
|
||||||
//! Los archivos `.map` se copian a `static/js/` para uso en desarrollo pero no se incluyen en el
|
//! Los archivos `.map` se copian a `static/js/` para uso en desarrollo pero no se incluyen en el
|
||||||
//! binario embebido.
|
//! binario embebido.
|
||||||
|
|
||||||
use pagetop_build::{StaticFilesBundle, compile_scss, copy_file, copy_file_replacing, minify_js};
|
use pagetop_build::{StaticFilesBundle, compile_scss, copy_file, minify_js};
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
|
@ -18,7 +18,7 @@ fn main() -> std::io::Result<()> {
|
||||||
println!("cargo:rerun-if-changed=assets");
|
println!("cargo:rerun-if-changed=assets");
|
||||||
let _ = std::fs::remove_dir_all("static");
|
let _ = std::fs::remove_dir_all("static");
|
||||||
|
|
||||||
// CSS: Bootstrap 5.3.8 + AdminLTE 4.0.0 + Bootstrap Icons 1.13.1.
|
// CSS: Bootstrap 5.3.8 + Bootstrap Icons 1.13.1.
|
||||||
compile_scss("assets/bootsier.scss", "static/css/bootsier.min.css")?;
|
compile_scss("assets/bootsier.scss", "static/css/bootsier.min.css")?;
|
||||||
|
|
||||||
// JS: Bootstrap bundle.
|
// JS: Bootstrap bundle.
|
||||||
|
|
@ -30,20 +30,10 @@ fn main() -> std::io::Result<()> {
|
||||||
"assets/bootstrap-5.3.8/js/bootstrap.bundle.min.js.map",
|
"assets/bootstrap-5.3.8/js/bootstrap.bundle.min.js.map",
|
||||||
"static/js/bootsier.bundle.min.js.map",
|
"static/js/bootsier.bundle.min.js.map",
|
||||||
)?;
|
)?;
|
||||||
// JS: AdminLTE renombrado a bootsier.extended.min.js.
|
// JS: selector de modo de color (claro / oscuro / automático).
|
||||||
copy_file_replacing(
|
|
||||||
"assets/adminlte-4.0.0/js/adminlte.min.js",
|
|
||||||
"static/js/bootsier.extended.min.js",
|
|
||||||
&[("adminlte.min.js.map", "bootsier.extended.min.js.map")],
|
|
||||||
)?;
|
|
||||||
copy_file(
|
|
||||||
"assets/adminlte-4.0.0/js/adminlte.min.js.map",
|
|
||||||
"static/js/bootsier.extended.min.js.map",
|
|
||||||
)?;
|
|
||||||
// JS: shell de Bootsier.
|
|
||||||
minify_js(
|
minify_js(
|
||||||
"assets/bootsier.shell.js",
|
"assets/bootsier.theme.js",
|
||||||
"static/js/bootsier.shell.min.js",
|
"static/js/bootsier.theme.min.js",
|
||||||
)?;
|
)?;
|
||||||
// JS: fix de apilamiento para Dialog.
|
// JS: fix de apilamiento para Dialog.
|
||||||
minify_js(
|
minify_js(
|
||||||
|
|
@ -67,11 +57,11 @@ fn main() -> std::io::Result<()> {
|
||||||
)?;
|
)?;
|
||||||
// Fuentes: Source Sans 3 (SIL OFL 1.1).
|
// Fuentes: Source Sans 3 (SIL OFL 1.1).
|
||||||
copy_file(
|
copy_file(
|
||||||
"assets/adminlte-4.0.0/fonts/SourceSans3VF-Upright.otf.woff2",
|
"assets/fonts/SourceSans3VF-Upright.otf.woff2",
|
||||||
"static/fonts/bootsier.font.woff2",
|
"static/fonts/bootsier.font.woff2",
|
||||||
)?;
|
)?;
|
||||||
copy_file(
|
copy_file(
|
||||||
"assets/adminlte-4.0.0/fonts/SourceSans3VF-Italic.otf.woff2",
|
"assets/fonts/SourceSans3VF-Italic.otf.woff2",
|
||||||
"static/fonts/bootsier.font.italic.woff2",
|
"static/fonts/bootsier.font.italic.woff2",
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,6 @@ use pagetop::prelude::*;
|
||||||
|
|
||||||
include_locales!(LOCALES_BOOTSIER);
|
include_locales!(LOCALES_BOOTSIER);
|
||||||
|
|
||||||
pub(crate) const ADMINLTE_VERSION: &str = "4.0.0";
|
|
||||||
const BOOTSTRAP_VERSION: &str = "5.3.8";
|
const BOOTSTRAP_VERSION: &str = "5.3.8";
|
||||||
|
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
|
@ -169,7 +168,6 @@ impl Theme for Bootsier {
|
||||||
cx: &mut Context,
|
cx: &mut Context,
|
||||||
) -> Option<Result<Markup, ComponentError>> {
|
) -> Option<Result<Markup, ComponentError>> {
|
||||||
render_component!(component, {
|
render_component!(component, {
|
||||||
layout::Region => |c| theme::bs::layout::region::render(c, cx).await?,
|
|
||||||
layout::Template => |c| theme::bs::layout::template::render(c, cx).await?,
|
layout::Template => |c| theme::bs::layout::template::render(c, cx).await?,
|
||||||
Dialog => |c| theme::bs::dialog::render(c, cx).await,
|
Dialog => |c| theme::bs::dialog::render(c, cx).await,
|
||||||
Dropdown => |c| theme::bs::dropdown::render(c, cx).await,
|
Dropdown => |c| theme::bs::dropdown::render(c, cx).await,
|
||||||
|
|
@ -201,7 +199,7 @@ impl Theme for Bootsier {
|
||||||
)
|
)
|
||||||
.alter_assets(
|
.alter_assets(
|
||||||
StyleSheet::from("/bootsier/css/bootsier.min.css")
|
StyleSheet::from("/bootsier/css/bootsier.min.css")
|
||||||
.with_version(ADMINLTE_VERSION)
|
.with_version(BOOTSTRAP_VERSION)
|
||||||
.with_weight(-99),
|
.with_weight(-99),
|
||||||
)
|
)
|
||||||
.alter_assets(
|
.alter_assets(
|
||||||
|
|
@ -210,8 +208,8 @@ impl Theme for Bootsier {
|
||||||
.with_weight(-99),
|
.with_weight(-99),
|
||||||
)
|
)
|
||||||
.alter_assets(
|
.alter_assets(
|
||||||
JavaScript::defer("/bootsier/js/bootsier.extended.min.js")
|
JavaScript::defer("/bootsier/js/bootsier.theme.min.js")
|
||||||
.with_version(ADMINLTE_VERSION)
|
.with_version(BOOTSTRAP_VERSION)
|
||||||
.with_weight(-99),
|
.with_weight(-99),
|
||||||
)
|
)
|
||||||
.alter_assets(
|
.alter_assets(
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,7 @@ toggle = Toggle navigation
|
||||||
# Offcanvas
|
# Offcanvas
|
||||||
offcanvas_close = Close
|
offcanvas_close = Close
|
||||||
|
|
||||||
# Shell AdminLTE (BootsierTemplates::Admin)
|
# Theme toggle
|
||||||
shell_fullscreen = Full screen
|
|
||||||
shell_theme_toggle = Color mode selector
|
shell_theme_toggle = Color mode selector
|
||||||
shell_theme_light = Light
|
shell_theme_light = Light
|
||||||
shell_theme_dark = Dark
|
shell_theme_dark = Dark
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
# BootsierRegions
|
|
||||||
region_sidebar = Sidebar
|
|
||||||
region_navbar = Navigation bar
|
|
||||||
|
|
@ -7,8 +7,7 @@ toggle = Mostrar/ocultar navegación
|
||||||
# Offcanvas
|
# Offcanvas
|
||||||
offcanvas_close = Cerrar
|
offcanvas_close = Cerrar
|
||||||
|
|
||||||
# Shell AdminLTE (BootsierTemplates::Admin)
|
# Theme toggle
|
||||||
shell_fullscreen = Pantalla completa
|
|
||||||
shell_theme_toggle = Selector de modo de color
|
shell_theme_toggle = Selector de modo de color
|
||||||
shell_theme_light = Claro
|
shell_theme_light = Claro
|
||||||
shell_theme_dark = Oscuro
|
shell_theme_dark = Oscuro
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
# BootsierRegions
|
|
||||||
region_sidebar = Barra lateral
|
|
||||||
region_navbar = Barra de navegación
|
|
||||||
|
|
@ -11,10 +11,12 @@
|
||||||
//!
|
//!
|
||||||
//! # Plantillas
|
//! # Plantillas
|
||||||
//!
|
//!
|
||||||
//! Bootsier maqueta las dos plantillas de PageTop ([`CoreTemplates`]): `Standard`, con cabecera,
|
//! Bootsier maqueta las dos plantillas de PageTop ([`CoreTemplates`]) con la misma cabecera,
|
||||||
//! contenido y pie, que es la plantilla por defecto de cualquier página; y `Admin`, con la shell
|
//! contenido y pie: `Standard`, la plantilla por defecto de cualquier página; y `Admin`, que se
|
||||||
//! completa de administración (barra superior + barra lateral + área de contenido), que se activa
|
//! activa creando la página con [`Page::admin()`] en lugar de [`Page::new()`]. Ambas se maquetan
|
||||||
//! creando la página con [`Page::admin()`] en lugar de [`Page::new()`].
|
//! 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 (ver
|
||||||
|
//! más abajo `pagetop-admin`).
|
||||||
//!
|
//!
|
||||||
//! ```rust,no_run
|
//! ```rust,no_run
|
||||||
//! use pagetop::prelude::*;
|
//! use pagetop::prelude::*;
|
||||||
|
|
@ -33,60 +35,29 @@
|
||||||
//! [`Page::admin()`]: pagetop::response::Page::admin
|
//! [`Page::admin()`]: pagetop::response::Page::admin
|
||||||
//! [`Page::new()`]: pagetop::response::Page::new
|
//! [`Page::new()`]: pagetop::response::Page::new
|
||||||
//!
|
//!
|
||||||
//! # Barra lateral
|
//! # Menú de administración
|
||||||
//!
|
//!
|
||||||
//! Registra elementos en [`BootsierRegions::Sidebar`](bs::BootsierRegions::Sidebar) para poblar la
|
//! `pagetop-admin` (si está presente en la aplicación) registra su propio menú de secciones y
|
||||||
//! barra lateral de la shell. Los elementos esperados son [`bs::sidebar::Item`] y
|
//! páginas en [`CoreRegions::Aside`], autolimitado a
|
||||||
//! [`bs::sidebar::Section`].
|
//! 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.
|
||||||
//!
|
//!
|
||||||
//! De forma **global** (visibles en todas las páginas de administració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
|
//! ```rust,no_run
|
||||||
//! use pagetop::prelude::*;
|
//! use pagetop::prelude::*;
|
||||||
//! use pagetop_bootsier::theme::bs::{BootsierRegions, sidebar};
|
//! use pagetop_bootsier::theme::*;
|
||||||
//!
|
//!
|
||||||
//! fn register_navigation() {
|
//! let navbar = bs::Navbar::simple().with_item(bs::navbar::Item::nav(
|
||||||
//! InRegion::Global(&BootsierRegions::Sidebar)
|
//! bs::Nav::new()
|
||||||
//! .add(sidebar::Section::titled(Lc::n("Administración")))
|
//! .with_item(bs::nav::Item::link(Lc::n("Home"), "/"))
|
||||||
//! .add(sidebar::Item::link(Lc::n("Usuarios"), "/users", "people"))
|
//! .with_item(bs::theme_toggle()),
|
||||||
//! .add(sidebar::Item::link(Lc::n("Roles"), "/roles", "shield-check"));
|
//! ));
|
||||||
//! }
|
|
||||||
//! ```
|
|
||||||
//!
|
|
||||||
//! O de forma **por página**:
|
|
||||||
//!
|
|
||||||
//! ```rust,no_run
|
|
||||||
//! use pagetop::prelude::*;
|
|
||||||
//! use pagetop_bootsier::theme::bs::{BootsierRegions, sidebar};
|
|
||||||
//!
|
|
||||||
//! async fn settings(request: HttpRequest) -> Result<Markup, ErrorPage> {
|
|
||||||
//! Page::admin(request)
|
|
||||||
//! .with_child_in(
|
|
||||||
//! &BootsierRegions::Sidebar,
|
|
||||||
//! sidebar::Item::link(Lc::n("Ajustes"), "/settings", "gear"),
|
|
||||||
//! )
|
|
||||||
//! .with_child(Html::with(|_| html! { h3 { "Ajustes" } }))
|
|
||||||
//! .render().await
|
|
||||||
//! }
|
|
||||||
//! ```
|
|
||||||
//!
|
|
||||||
//! # Barra de navegación superior
|
|
||||||
//!
|
|
||||||
//! La barra superior incluye por defecto los controles de pantalla completa y selector de tema.
|
|
||||||
//! Para añadir elementos adicionales en el lado derecho (por ejemplo, el dropdown de usuario de
|
|
||||||
//! `pagetop-user`), registra componentes en
|
|
||||||
//! [`BootsierRegions::Navbar`](bs::BootsierRegions::Navbar):
|
|
||||||
//!
|
|
||||||
//! ```rust,no_run
|
|
||||||
//! use pagetop::prelude::*;
|
|
||||||
//! use pagetop_bootsier::theme::bs::BootsierRegions;
|
|
||||||
//!
|
|
||||||
//! InRegion::Global(&BootsierRegions::Navbar)
|
|
||||||
//! .add(Html::with(|_| html! {
|
|
||||||
//! li class="nav-item" {
|
|
||||||
//! a class="nav-link" href="/logout" { "Cerrar sesión" }
|
|
||||||
//! }
|
|
||||||
//! }));
|
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use pagetop::prelude::*;
|
use pagetop::prelude::*;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
//! Componentes proporcionados por el tema.
|
//! Componentes proporcionados por el tema.
|
||||||
|
|
||||||
pub(crate) mod layout;
|
pub(crate) mod layout;
|
||||||
pub use layout::BootsierRegions;
|
|
||||||
|
|
||||||
// Badge.
|
// Badge.
|
||||||
pub(crate) mod badge;
|
pub(crate) mod badge;
|
||||||
|
|
@ -81,10 +80,11 @@ pub use offcanvas::Offcanvas;
|
||||||
// Pager.
|
// Pager.
|
||||||
pub use pagetop::base::component::{Pager, PagerAlign, PagerVisibility};
|
pub use pagetop::base::component::{Pager, PagerAlign, PagerVisibility};
|
||||||
|
|
||||||
// Sidebar (componentes de navegación de AdminLTE).
|
|
||||||
pub mod sidebar;
|
|
||||||
|
|
||||||
// Table.
|
// Table.
|
||||||
pub use pagetop::base::component::table;
|
pub use pagetop::base::component::table;
|
||||||
#[doc(inline)]
|
#[doc(inline)]
|
||||||
pub use table::Table;
|
pub use table::Table;
|
||||||
|
|
||||||
|
// Theme toggle.
|
||||||
|
mod theme_toggle;
|
||||||
|
pub use theme_toggle::theme_toggle;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1 @@
|
||||||
pub(crate) mod region;
|
|
||||||
pub use region::BootsierRegions;
|
|
||||||
|
|
||||||
pub(crate) mod template;
|
pub(crate) mod template;
|
||||||
|
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
use pagetop::prelude::*;
|
|
||||||
|
|
||||||
use crate::LOCALES_BOOTSIER;
|
|
||||||
|
|
||||||
/// Regiones específicas de la shell de Bootsier.
|
|
||||||
pub enum BootsierRegions {
|
|
||||||
/// Barra lateral de navegación (`app-sidebar` de AdminLTE).
|
|
||||||
///
|
|
||||||
/// Los componentes registrados aquí se renderizan directamente dentro del
|
|
||||||
/// `<ul class="sidebar-menu">`, sin el `<div>` envolvente que añade
|
|
||||||
/// [`Region`](pagetop::base::component::layout::Region) por defecto --
|
|
||||||
/// [`Bootsier`](crate::Bootsier) intercepta este componente en `render_component()` para
|
|
||||||
/// renderizarlo así. Los elementos esperados son
|
|
||||||
/// [`bs::sidebar::Item`](crate::theme::bs::sidebar::Item) y
|
|
||||||
/// [`bs::sidebar::Section`](crate::theme::bs::sidebar::Section).
|
|
||||||
///
|
|
||||||
/// Sólo se renderiza en la plantilla de administración (`CoreTemplates::Admin`), que se
|
|
||||||
/// activa creando la página con [`Page::admin()`](pagetop::response::Page::admin). Registrar
|
|
||||||
/// elementos aquí no tiene efecto en páginas creadas con `Page::new()`.
|
|
||||||
///
|
|
||||||
/// # Registro global
|
|
||||||
///
|
|
||||||
/// Para que los ítems aparezcan en todas las páginas con shell, regístralos durante
|
|
||||||
/// el arranque de la aplicación con [`InRegion::Global`]:
|
|
||||||
///
|
|
||||||
/// ```rust,no_run
|
|
||||||
/// use pagetop::prelude::*;
|
|
||||||
/// use pagetop_bootsier::theme::bs::{BootsierRegions, sidebar};
|
|
||||||
///
|
|
||||||
/// InRegion::Global(&BootsierRegions::Sidebar)
|
|
||||||
/// .add(sidebar::Section::titled(Lc::n("Administración")))
|
|
||||||
/// .add(sidebar::Item::link(Lc::n("Usuarios"), "/users", "people"))
|
|
||||||
/// .add(sidebar::Item::link(Lc::n("Roles"), "/roles", "shield-check"));
|
|
||||||
/// ```
|
|
||||||
///
|
|
||||||
/// # Registro por página
|
|
||||||
///
|
|
||||||
/// Para añadir ítems sólo en una página concreta, usa [`Contextual::with_child_in`]:
|
|
||||||
///
|
|
||||||
/// ```rust,no_run
|
|
||||||
/// use pagetop::prelude::*;
|
|
||||||
/// use pagetop_bootsier::theme::bs::{BootsierRegions, sidebar};
|
|
||||||
///
|
|
||||||
/// async fn dashboard(request: HttpRequest) -> Result<Markup, ErrorPage> {
|
|
||||||
/// Page::admin(request)
|
|
||||||
/// .with_child_in(
|
|
||||||
/// &BootsierRegions::Sidebar,
|
|
||||||
/// sidebar::Item::link(Lc::n("Panel"), "/dashboard", "grid"),
|
|
||||||
/// )
|
|
||||||
/// .render().await
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
Sidebar,
|
|
||||||
|
|
||||||
/// Elementos adicionales en la barra de navegación superior (`app-header`).
|
|
||||||
///
|
|
||||||
/// Los componentes registrados aquí se renderizan en el lado derecho de la barra superior,
|
|
||||||
/// a continuación de los controles fijos (pantalla completa y selector de tema). Los elementos
|
|
||||||
/// esperados son típicamente ítems de navegación (`<li class="nav-item">`).
|
|
||||||
///
|
|
||||||
/// Esta región es opcional: si no tiene contenido, no añade ningún marcado al navbar.
|
|
||||||
///
|
|
||||||
/// # Registro global
|
|
||||||
///
|
|
||||||
/// ```rust,no_run
|
|
||||||
/// use pagetop::prelude::*;
|
|
||||||
/// use pagetop_bootsier::theme::bs::BootsierRegions;
|
|
||||||
///
|
|
||||||
/// InRegion::Global(&BootsierRegions::Navbar)
|
|
||||||
/// .add(Html::with(|_| html! {
|
|
||||||
/// li class="nav-item" {
|
|
||||||
/// a class="nav-link" href="/logout" { "Cerrar sesión" }
|
|
||||||
/// }
|
|
||||||
/// }));
|
|
||||||
/// ```
|
|
||||||
Navbar,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RegionName for BootsierRegions {
|
|
||||||
#[inline]
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Sidebar => "bootsier-sidebar",
|
|
||||||
Self::Navbar => "bootsier-navbar",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn label(&self) -> Lc {
|
|
||||||
match self {
|
|
||||||
Self::Sidebar => Lc::t("region_sidebar", &LOCALES_BOOTSIER),
|
|
||||||
Self::Navbar => Lc::t("region_navbar", &LOCALES_BOOTSIER),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **< Region RENDER >******************************************************************************
|
|
||||||
|
|
||||||
// Regiones de Bootsier: se renderizan sin el `<div role="region">` envolvente que aplica
|
|
||||||
// `layout::Region::prepare()` por defecto. Sus elementos van directamente dentro del contenedor que
|
|
||||||
// los gestiona (sidebar-menu o navbar-nav). Devuelve `None` si `component` no envuelve una
|
|
||||||
// `BootsierRegions`, dejando que el resto de la cadena de temas (o el propio componente) resuelva
|
|
||||||
// el renderizado por defecto.
|
|
||||||
pub(crate) async fn render(
|
|
||||||
component: &layout::Region,
|
|
||||||
cx: &mut Context,
|
|
||||||
) -> Option<Result<Markup, ComponentError>> {
|
|
||||||
match component.region().downcast_ref::<BootsierRegions>()? {
|
|
||||||
BootsierRegions::Sidebar | BootsierRegions::Navbar => {
|
|
||||||
Some(Ok(cx.render_region(component.region()).await))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,25 +2,30 @@ use pagetop::prelude::*;
|
||||||
|
|
||||||
use crate::config;
|
use crate::config;
|
||||||
use crate::theme::bs;
|
use crate::theme::bs;
|
||||||
use crate::{ADMINLTE_VERSION, LOCALES_BOOTSIER};
|
|
||||||
|
|
||||||
// Regiones de Bootsier: se renderiza sin el `<div role="region">` envolvente que aplica
|
// Regiones de Bootsier: se renderiza sin el `<div role="region">` envolvente que aplica
|
||||||
// `layout::Template::prepare()` por defecto; delega en `render_standard()`/`render_admin()` según
|
// `layout::Template::prepare()` por defecto. Devuelve `None` si `component` no envuelve una
|
||||||
// la variante de `CoreTemplates` que envuelva el componente. Devuelve `None` si `component` no
|
// `CoreTemplates`, dejando que el resto de la cadena de temas (o el propio componente) resuelva el
|
||||||
// envuelve una `CoreTemplates`, dejando que el resto de la cadena de temas (o el propio componente)
|
// renderizado por defecto.
|
||||||
// resuelva el renderizado por defecto.
|
//
|
||||||
|
// `CoreTemplates::Admin` delega hoy en el mismo maquetado que `Standard`: la diferencia entre
|
||||||
|
// página "normal" y de administración no vive en la plantilla, sino en qué se registra en cada
|
||||||
|
// región -- p. ej. el menú de `pagetop-admin` en `CoreRegions::Aside`, que se autolimita a
|
||||||
|
// `CoreTemplates::Admin` (ver `pagetop_admin::component::AdminMenu`). Mantener la rama separada
|
||||||
|
// deja abierta la posibilidad de que un maquetado distinto lo necesite en el futuro sin tocar el
|
||||||
|
// núcleo.
|
||||||
pub(crate) async fn render(
|
pub(crate) async fn render(
|
||||||
component: &layout::Template,
|
component: &layout::Template,
|
||||||
cx: &mut Context,
|
cx: &mut Context,
|
||||||
) -> Option<Result<Markup, ComponentError>> {
|
) -> Option<Result<Markup, ComponentError>> {
|
||||||
match component.template().downcast_ref::<CoreTemplates>()? {
|
match component.template().downcast_ref::<CoreTemplates>()? {
|
||||||
CoreTemplates::Standard => Some(Ok(render_standard(cx).await)),
|
CoreTemplates::Standard => Some(Ok(render_standard(cx).await)),
|
||||||
CoreTemplates::Admin => Some(Ok(render_admin(cx).await)),
|
CoreTemplates::Admin => Some(Ok(render_standard(cx).await)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Layout estándar: `CoreRegions::Header`, `CoreRegions::Aside`, `CoreRegions::Content` y
|
// `CoreRegions::Header`, `CoreRegions::Aside`, `CoreRegions::Content` y `CoreRegions::Footer`
|
||||||
// `CoreRegions::Footer` envueltos en un contenedor de ancho configurable.
|
// envueltos en un contenedor de ancho configurable.
|
||||||
async fn render_standard(cx: &mut Context) -> Markup {
|
async fn render_standard(cx: &mut Context) -> Markup {
|
||||||
bs::Container::new()
|
bs::Container::new()
|
||||||
.with_prop(PropsOp::add_classes("container-wrapper"))
|
.with_prop(PropsOp::add_classes("container-wrapper"))
|
||||||
|
|
@ -34,137 +39,3 @@ async fn render_standard(cx: &mut Context) -> Markup {
|
||||||
.render(cx)
|
.render(cx)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
// Layout de administración: shell de AdminLTE 4 (barra superior, barra lateral con el contenido
|
|
||||||
// de BootsierRegions::Sidebar, área de contenido y pie).
|
|
||||||
async fn render_admin(cx: &mut Context) -> Markup {
|
|
||||||
cx.alter_body_props(PropsOp::add_classes(
|
|
||||||
"layout-fixed sidebar-expand-lg bg-body-tertiary",
|
|
||||||
));
|
|
||||||
cx.alter_assets(
|
|
||||||
JavaScript::defer("/bootsier/js/bootsier.shell.min.js")
|
|
||||||
.with_version(ADMINLTE_VERSION)
|
|
||||||
.with_weight(-88),
|
|
||||||
);
|
|
||||||
// `CoreRegions::Aside` es una región neutra del core: la usa `pagetop-admin` para su menú de
|
|
||||||
// secciones sin que este tema tenga que depender de él. `BootsierRegions::Sidebar` sigue
|
|
||||||
// disponible para que cualquier extensión añada elementos propios a mano.
|
|
||||||
let aside = layout::Region::of(&CoreRegions::Aside).render(cx).await;
|
|
||||||
let sidebar = layout::Region::of(&bs::BootsierRegions::Sidebar)
|
|
||||||
.render(cx)
|
|
||||||
.await;
|
|
||||||
render_shell(cx, html! { (aside) (sidebar) }).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn render_shell(cx: &mut Context, sidebar: Markup) -> Markup {
|
|
||||||
let navbar = layout::Region::of(&bs::BootsierRegions::Navbar)
|
|
||||||
.render(cx)
|
|
||||||
.await;
|
|
||||||
let content = layout::Region::default().render(cx).await;
|
|
||||||
let footer = layout::Region::footer().render(cx).await;
|
|
||||||
html! {
|
|
||||||
div class="app-wrapper" {
|
|
||||||
// Barra de navegación superior (app-header)
|
|
||||||
nav class="app-header navbar navbar-expand bg-body" {
|
|
||||||
div class="container-fluid" {
|
|
||||||
ul class="navbar-nav" {
|
|
||||||
li class="nav-item" {
|
|
||||||
a class="nav-link" data-lte-toggle="sidebar" href="#" role="button" {
|
|
||||||
i class="bi bi-list" {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ul class="navbar-nav ms-auto" {
|
|
||||||
// Botón de pantalla completa
|
|
||||||
li class="nav-item" {
|
|
||||||
a class="nav-link" href="#" data-lte-toggle="fullscreen"
|
|
||||||
aria-label=[Lc::t("shell_fullscreen", &LOCALES_BOOTSIER).lookup(cx)]
|
|
||||||
{
|
|
||||||
i data-lte-icon="maximize" class="bi bi-fullscreen" {}
|
|
||||||
i data-lte-icon="minimize" class="bi bi-fullscreen-exit d-none" {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Selector de modo de color (claro / oscuro / automático)
|
|
||||||
li class="nav-item dropdown" {
|
|
||||||
a class="nav-link" href="#" id="bd-theme"
|
|
||||||
data-bs-toggle="dropdown" aria-expanded="false"
|
|
||||||
aria-label=[Lc::t("shell_theme_toggle", &LOCALES_BOOTSIER).lookup(cx)]
|
|
||||||
{
|
|
||||||
i class="bi bi-sun-fill" data-lte-theme-icon="light" {}
|
|
||||||
i class="bi bi-moon-fill d-none" data-lte-theme-icon="dark" {}
|
|
||||||
i class="bi bi-circle-half d-none" data-lte-theme-icon="auto" {}
|
|
||||||
}
|
|
||||||
ul class="dropdown-menu dropdown-menu-end" aria-labelledby="bd-theme"
|
|
||||||
style="--bs-dropdown-min-width: 8rem"
|
|
||||||
{
|
|
||||||
li {
|
|
||||||
button type="button"
|
|
||||||
class="dropdown-item d-flex align-items-center"
|
|
||||||
data-bs-theme-value="light"
|
|
||||||
aria-pressed="false"
|
|
||||||
{
|
|
||||||
i class="bi bi-sun-fill me-2" {}
|
|
||||||
(Lc::t("shell_theme_light", &LOCALES_BOOTSIER).using(cx))
|
|
||||||
i class="bi bi-check-lg ms-auto d-none" {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
li {
|
|
||||||
button type="button"
|
|
||||||
class="dropdown-item d-flex align-items-center"
|
|
||||||
data-bs-theme-value="dark"
|
|
||||||
aria-pressed="false"
|
|
||||||
{
|
|
||||||
i class="bi bi-moon-fill me-2" {}
|
|
||||||
(Lc::t("shell_theme_dark", &LOCALES_BOOTSIER).using(cx))
|
|
||||||
i class="bi bi-check-lg ms-auto d-none" {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
li {
|
|
||||||
button type="button"
|
|
||||||
class="dropdown-item d-flex align-items-center"
|
|
||||||
data-bs-theme-value="auto"
|
|
||||||
aria-pressed="false"
|
|
||||||
{
|
|
||||||
i class="bi bi-circle-half me-2" {}
|
|
||||||
(Lc::t("shell_theme_auto", &LOCALES_BOOTSIER).using(cx))
|
|
||||||
i class="bi bi-check-lg ms-auto d-none" {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(navbar)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Barra lateral (app-sidebar)
|
|
||||||
aside class="app-sidebar bg-body-secondary shadow" data-bs-theme="dark" {
|
|
||||||
div class="sidebar-brand" {
|
|
||||||
a href="/" class="brand-link" {
|
|
||||||
span class="brand-text fw-light" { (global::SETTINGS.app.name) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
div class="sidebar-wrapper" {
|
|
||||||
nav class="mt-2" {
|
|
||||||
ul class="nav sidebar-menu flex-column"
|
|
||||||
data-lte-toggle="treeview" role="menu"
|
|
||||||
{
|
|
||||||
(sidebar)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Área de contenido principal (app-main)
|
|
||||||
main class="app-main" {
|
|
||||||
div class="app-content" {
|
|
||||||
div class="container-fluid" {
|
|
||||||
(content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Pie de página (app-footer)
|
|
||||||
footer class="app-footer" {
|
|
||||||
(footer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
//! Componentes para la barra lateral de la shell de Bootsier.
|
|
||||||
//!
|
|
||||||
//! # Componentes disponibles
|
|
||||||
//!
|
|
||||||
//! - [`Section`] - encabezado de grupo (`<li class="nav-header">`).
|
|
||||||
//! - [`Item`] - enlace de navegación con icono de Bootstrap Icons. Detecta
|
|
||||||
//! automáticamente si la ruta activa coincide con la del *request* y añade la clase
|
|
||||||
//! `active` al enlace.
|
|
||||||
//!
|
|
||||||
//! # Flujo de uso
|
|
||||||
//!
|
|
||||||
//! Los componentes se añaden a la región
|
|
||||||
//! [`BootsierRegions::Sidebar`](crate::theme::bs::BootsierRegions::Sidebar).
|
|
||||||
//! El registro global se hace una sola vez en el arranque; el registro por página
|
|
||||||
//! se hace al construir la página. La región sólo se renderiza en páginas creadas con
|
|
||||||
//! [`Page::admin()`](pagetop::response::Page::admin); en páginas con
|
|
||||||
//! [`Page::new()`](pagetop::response::Page::new) no tiene efecto.
|
|
||||||
//!
|
|
||||||
//! ```rust,no_run
|
|
||||||
//! use pagetop::prelude::*;
|
|
||||||
//! use pagetop_bootsier::theme::bs::{BootsierRegions, sidebar};
|
|
||||||
//!
|
|
||||||
//! // Registro global: visible en todas las páginas de administración.
|
|
||||||
//! fn register_navigation() {
|
|
||||||
//! InRegion::Global(&BootsierRegions::Sidebar)
|
|
||||||
//! .add(sidebar::Section::titled(Lc::n("Administración")))
|
|
||||||
//! .add(sidebar::Item::link(Lc::n("Usuarios"), "/users", "people"))
|
|
||||||
//! .add(sidebar::Item::link(Lc::n("Roles"), "/roles", "shield-check"));
|
|
||||||
//! }
|
|
||||||
//!
|
|
||||||
//! // Uso en un handler: la página de administración muestra el sidebar registrado.
|
|
||||||
//! async fn users(request: HttpRequest) -> Result<Markup, ErrorPage> {
|
|
||||||
//! Page::admin(request)
|
|
||||||
//! .with_child(Html::with(|_| html! { h3 { "Usuarios" } }))
|
|
||||||
//! .render().await
|
|
||||||
//! }
|
|
||||||
//! ```
|
|
||||||
|
|
||||||
mod item;
|
|
||||||
pub use item::Item;
|
|
||||||
|
|
||||||
mod section;
|
|
||||||
pub use section::Section;
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
use pagetop::prelude::*;
|
|
||||||
|
|
||||||
// **< Item >***************************************************************************************
|
|
||||||
|
|
||||||
/// Elemento de navegación individual en la barra lateral de AdminLTE.
|
|
||||||
///
|
|
||||||
/// Renderiza un `<li class="nav-item">` con un enlace `<a class="nav-link">`, un icono de
|
|
||||||
/// Bootstrap Icons y una etiqueta localizable. Si la ruta del ítem coincide con la del *request*
|
|
||||||
/// actual, el enlace se marca como activo con la clase `active`.
|
|
||||||
///
|
|
||||||
/// # Ejemplo
|
|
||||||
///
|
|
||||||
/// ```rust,no_run
|
|
||||||
/// use pagetop::prelude::*;
|
|
||||||
/// use pagetop_bootsier::theme::bs::sidebar;
|
|
||||||
///
|
|
||||||
/// let item = sidebar::Item::link(
|
|
||||||
/// Lc::n("Usuarios"),
|
|
||||||
/// "/users",
|
|
||||||
/// "people",
|
|
||||||
/// );
|
|
||||||
/// ```
|
|
||||||
#[derive(AutoDefault, Clone, Getters)]
|
|
||||||
pub struct Item {
|
|
||||||
/// Devuelve el texto localizable del ítem.
|
|
||||||
label: Lc,
|
|
||||||
/// Devuelve la ruta de destino en función del contexto.
|
|
||||||
#[getters(skip)]
|
|
||||||
route: Option<Route>,
|
|
||||||
/// Devuelve el nombre del icono de Bootstrap Icons (sin el prefijo `bi-`).
|
|
||||||
icon: CowStr,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Component for Item {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
|
||||||
let Some(route) = self.route.as_ref() else {
|
|
||||||
return Ok(html! {});
|
|
||||||
};
|
|
||||||
|
|
||||||
let route_link = route.resolve(cx);
|
|
||||||
let current_path = cx.request().map(|r| r.path());
|
|
||||||
let is_active = current_path == Some(route_link.path());
|
|
||||||
|
|
||||||
let link_class = if is_active {
|
|
||||||
"nav-link active"
|
|
||||||
} else {
|
|
||||||
"nav-link"
|
|
||||||
};
|
|
||||||
let aria_current = is_active.then_some("page");
|
|
||||||
let icon_class = util::join!("nav-icon bi bi-", self.icon());
|
|
||||||
|
|
||||||
Ok(html! {
|
|
||||||
li class="nav-item" {
|
|
||||||
a href=(route_link) class=(link_class) aria-current=[aria_current] {
|
|
||||||
i class=(icon_class) {}
|
|
||||||
p { (self.label().using(cx)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[builder_impl]
|
|
||||||
impl Item {
|
|
||||||
/// Crea un ítem de navegación con etiqueta, ruta e icono.
|
|
||||||
///
|
|
||||||
/// * `label` - Texto localizable del ítem.
|
|
||||||
/// * `route` - Ruta de destino, resuelta según el contexto (ver [`Route`]).
|
|
||||||
/// * `icon` - Nombre del icono de Bootstrap Icons sin el prefijo `bi-` (p. ej. `"people"`).
|
|
||||||
pub fn link(label: Lc, route: impl Into<Route>, icon: impl Into<CowStr>) -> Self {
|
|
||||||
Self {
|
|
||||||
label,
|
|
||||||
route: Some(route.into()),
|
|
||||||
icon: icon.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **< Item BUILDER >***************************************************************************
|
|
||||||
|
|
||||||
/// Establece el texto localizable del ítem.
|
|
||||||
pub fn with_label(mut self, label: Lc) -> Self {
|
|
||||||
self.label = label;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Establece la ruta de destino del ítem.
|
|
||||||
pub fn with_route(mut self, route: impl Into<Option<Route>>) -> Self {
|
|
||||||
self.route = route.into();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Establece el nombre del icono de Bootstrap Icons (sin el prefijo `bi-`).
|
|
||||||
pub fn with_icon(mut self, icon: impl Into<CowStr>) -> Self {
|
|
||||||
self.icon = icon.into();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
use pagetop::prelude::*;
|
|
||||||
|
|
||||||
// **< Section >************************************************************************************
|
|
||||||
|
|
||||||
/// Encabezado de sección en la barra lateral de AdminLTE.
|
|
||||||
///
|
|
||||||
/// Renderiza un `<li class="nav-header">` con el texto localizable de la sección. Se usa para
|
|
||||||
/// agrupar visualmente los ítems de navegación ([`Item`](super::Item)) en la sidebar.
|
|
||||||
///
|
|
||||||
/// # Ejemplo
|
|
||||||
///
|
|
||||||
/// ```rust,no_run
|
|
||||||
/// use pagetop::prelude::*;
|
|
||||||
/// use pagetop_bootsier::theme::bs::sidebar;
|
|
||||||
///
|
|
||||||
/// // Sección con texto fijo.
|
|
||||||
/// let section = sidebar::Section::titled(Lc::n("Administración"));
|
|
||||||
///
|
|
||||||
/// // Sección con texto localizable.
|
|
||||||
/// let section_i18n = sidebar::Section::titled(Lc::l("nav-admin"));
|
|
||||||
/// ```
|
|
||||||
#[derive(AutoDefault, Clone, Getters)]
|
|
||||||
pub struct Section {
|
|
||||||
/// Devuelve el título localizable de la sección.
|
|
||||||
title: Lc,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Component for Section {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
|
||||||
Ok(html! {
|
|
||||||
li class="nav-header" {
|
|
||||||
(self.title().using(cx))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[builder_impl]
|
|
||||||
impl Section {
|
|
||||||
/// Crea un encabezado de sección con el título indicado.
|
|
||||||
pub fn titled(title: Lc) -> Self {
|
|
||||||
Self { title }
|
|
||||||
}
|
|
||||||
|
|
||||||
// **< Section BUILDER >************************************************************************
|
|
||||||
|
|
||||||
/// Establece el título localizable de la sección.
|
|
||||||
pub fn with_title(mut self, title: Lc) -> Self {
|
|
||||||
self.title = title;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
75
extensions/pagetop-bootsier/src/theme/bs/theme_toggle.rs
Normal file
75
extensions/pagetop-bootsier/src/theme/bs/theme_toggle.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
use pagetop::prelude::*;
|
||||||
|
|
||||||
|
use crate::LOCALES_BOOTSIER;
|
||||||
|
|
||||||
|
/// Crea un [`nav::Item`] con el selector de modo de color (claro / oscuro / automático), pensado
|
||||||
|
/// para añadirse a cualquier [`Nav`](super::Nav), suelto o dentro de una
|
||||||
|
/// [`Navbar`](super::Navbar).
|
||||||
|
///
|
||||||
|
/// El estado se recuerda en `localStorage` y se aplica con `/bootsier/js/bootsier.theme.min.js`,
|
||||||
|
/// cargado siempre por Bootsier (ver [`crate::Bootsier::before_render_page_body()`]).
|
||||||
|
///
|
||||||
|
/// # Ejemplo
|
||||||
|
///
|
||||||
|
/// ```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()),
|
||||||
|
/// ));
|
||||||
|
/// ```
|
||||||
|
pub fn theme_toggle() -> nav::Item {
|
||||||
|
nav::Item::html(Html::with(|cx| {
|
||||||
|
html! {
|
||||||
|
a class="nav-link" href="#" id="bd-theme"
|
||||||
|
data-bs-toggle="dropdown" aria-expanded="false"
|
||||||
|
aria-label=[Lc::t("shell_theme_toggle", &LOCALES_BOOTSIER).lookup(cx)]
|
||||||
|
{
|
||||||
|
i class="bi bi-sun-fill" data-bs-theme-icon="light" {}
|
||||||
|
i class="bi bi-moon-fill d-none" data-bs-theme-icon="dark" {}
|
||||||
|
i class="bi bi-circle-half d-none" data-bs-theme-icon="auto" {}
|
||||||
|
}
|
||||||
|
ul class="dropdown-menu dropdown-menu-end" aria-labelledby="bd-theme"
|
||||||
|
style="--bs-dropdown-min-width: 8rem"
|
||||||
|
{
|
||||||
|
li {
|
||||||
|
button type="button"
|
||||||
|
class="dropdown-item d-flex align-items-center"
|
||||||
|
data-bs-theme-value="light"
|
||||||
|
aria-pressed="false"
|
||||||
|
{
|
||||||
|
i class="bi bi-sun-fill me-2" {}
|
||||||
|
(Lc::t("shell_theme_light", &LOCALES_BOOTSIER).using(cx))
|
||||||
|
i class="bi bi-check-lg ms-auto d-none" {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
button type="button"
|
||||||
|
class="dropdown-item d-flex align-items-center"
|
||||||
|
data-bs-theme-value="dark"
|
||||||
|
aria-pressed="false"
|
||||||
|
{
|
||||||
|
i class="bi bi-moon-fill me-2" {}
|
||||||
|
(Lc::t("shell_theme_dark", &LOCALES_BOOTSIER).using(cx))
|
||||||
|
i class="bi bi-check-lg ms-auto d-none" {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
button type="button"
|
||||||
|
class="dropdown-item d-flex align-items-center"
|
||||||
|
data-bs-theme-value="auto"
|
||||||
|
aria-pressed="false"
|
||||||
|
{
|
||||||
|
i class="bi bi-circle-half me-2" {}
|
||||||
|
(Lc::t("shell_theme_auto", &LOCALES_BOOTSIER).using(cx))
|
||||||
|
i class="bi bi-check-lg ms-auto d-none" {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.with_prop(PropsOp::add_classes("dropdown"))
|
||||||
|
}
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "pagetop-menu"
|
|
||||||
description = "Gestión centralizada y persistente de menús para PageTop."
|
|
||||||
version = "0.1.0"
|
|
||||||
categories = ["web-programming", "development-tools"]
|
|
||||||
keywords = ["pagetop", "menu", "navigation", "cms", "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
|
|
||||||
thiserror.workspace = true
|
|
||||||
|
|
@ -1,312 +0,0 @@
|
||||||
//! Tipos de acción que `pagetop-menu` expone para que otras extensiones extiendan el sistema.
|
|
||||||
//!
|
|
||||||
//! El flujo de `build_tree()` despacha estas acciones en este orden:
|
|
||||||
//!
|
|
||||||
//! 1. `AlterMenuTree` - filtros, reordenamientos, inyección de nodos en caliente.
|
|
||||||
//! 2. Cálculo del active trail por URL.
|
|
||||||
//! 3. `ResolveActiveTrail` - matching personalizado para rutas paramétricas.
|
|
||||||
//! 4. `DecorateMenuItem` - añadir atributos HTML a nodos individuales.
|
|
||||||
//!
|
|
||||||
//! El sembrado inicial usa `DeclareDefaultMenus` y `DeclareDefaultMenuItems`.
|
|
||||||
|
|
||||||
use pagetop::prelude::*;
|
|
||||||
|
|
||||||
use crate::repo::NewMenuItem;
|
|
||||||
use crate::tree::{MenuNode, MenuTree};
|
|
||||||
|
|
||||||
// **< Tipo de callbacks >**************************************************************************
|
|
||||||
|
|
||||||
pub type FnMenuDefs = fn(&mut MenuDefs);
|
|
||||||
pub type FnItemBag = fn(&mut ItemBag);
|
|
||||||
pub type FnAlterTree = fn(&mut MenuTree, &Context);
|
|
||||||
pub type FnDecorate = fn(&mut MenuNode, &Context);
|
|
||||||
|
|
||||||
// **< MenuDefs >***********************************************************************************
|
|
||||||
|
|
||||||
/// Bolsa de declaraciones de menú para `DeclareDefaultMenus`.
|
|
||||||
pub struct MenuDefs {
|
|
||||||
pub(crate) entries: Vec<(String, String)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MenuDefs {
|
|
||||||
/// Declara que el menú `machine_name` debe existir con el título dado.
|
|
||||||
/// Si ya existe en la lista, no se añade de nuevo.
|
|
||||||
pub fn ensure(&mut self, machine_name: impl Into<String>, title: impl Into<String>) {
|
|
||||||
let name = machine_name.into();
|
|
||||||
if !self.entries.iter().any(|(n, _)| n == &name) {
|
|
||||||
self.entries.push((name, title.into()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **< ItemBag >************************************************************************************
|
|
||||||
|
|
||||||
/// Bolsa de declaraciones de ítems para `DeclareDefaultMenuItems`.
|
|
||||||
pub struct ItemBag {
|
|
||||||
pub(crate) items: Vec<NewMenuItem>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ItemBag {
|
|
||||||
/// Añade un ítem declarado por código a la bolsa.
|
|
||||||
pub fn add(&mut self, item: NewMenuItem) {
|
|
||||||
self.items.push(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **< DeclareDefaultMenus >************************************************************************
|
|
||||||
|
|
||||||
/// Acción para declarar los menús que una extensión necesita.
|
|
||||||
///
|
|
||||||
/// Se despacha durante el sembrado inicial (`seed::run()`). El callback recibe un
|
|
||||||
/// [`MenuDefs`] y puede llamar a `ensure()` para declarar los menús necesarios.
|
|
||||||
///
|
|
||||||
/// # Ejemplo
|
|
||||||
///
|
|
||||||
/// ```rust,no_run
|
|
||||||
/// use pagetop_menu::action::{DeclareDefaultMenus, MenuDefs};
|
|
||||||
///
|
|
||||||
/// fn my_menus(defs: &mut MenuDefs) {
|
|
||||||
/// defs.ensure("main", "Main navigation");
|
|
||||||
/// defs.ensure("footer", "Footer links");
|
|
||||||
/// }
|
|
||||||
/// // En Extension::actions():
|
|
||||||
/// // DeclareDefaultMenus::new(my_menus)
|
|
||||||
/// ```
|
|
||||||
pub struct DeclareDefaultMenus {
|
|
||||||
f: FnMenuDefs,
|
|
||||||
weight: Weight,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ActionDispatcher for DeclareDefaultMenus {
|
|
||||||
fn weight(&self) -> Weight {
|
|
||||||
self.weight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DeclareDefaultMenus {
|
|
||||||
pub fn new(f: FnMenuDefs) -> Self {
|
|
||||||
DeclareDefaultMenus { f, weight: 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_weight(mut self, w: Weight) -> Self {
|
|
||||||
self.weight = w;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn dispatch(defs: &mut MenuDefs) {
|
|
||||||
dispatch_actions(
|
|
||||||
&ActionKey::new(UniqueId::of::<Self>(), None, None),
|
|
||||||
|action: &Self| (action.f)(defs),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **< DeclareDefaultMenuItems >********************************************************************
|
|
||||||
|
|
||||||
/// Acción para declarar los ítems por defecto de un menú concreto.
|
|
||||||
///
|
|
||||||
/// El `referer_id` es el `machine_name` del menú al que pertenecen los ítems.
|
|
||||||
/// Se despacha durante el sembrado inicial (`seed::run()`) para cada menú conocido.
|
|
||||||
///
|
|
||||||
/// # Ejemplo
|
|
||||||
///
|
|
||||||
/// ```rust,no_run
|
|
||||||
/// use pagetop_menu::action::{DeclareDefaultMenuItems, ItemBag};
|
|
||||||
/// use pagetop_menu::NewMenuItem;
|
|
||||||
///
|
|
||||||
/// fn blog_items(bag: &mut ItemBag) {
|
|
||||||
/// bag.add(NewMenuItem::new()
|
|
||||||
/// .with_provider("my-blog")
|
|
||||||
/// .with_external_key("blog.index")
|
|
||||||
/// .with_title("Blog")
|
|
||||||
/// .with_url("/blog")
|
|
||||||
/// .with_weight(10));
|
|
||||||
/// }
|
|
||||||
/// // En Extension::actions():
|
|
||||||
/// // DeclareDefaultMenuItems::new("main", blog_items)
|
|
||||||
/// ```
|
|
||||||
pub struct DeclareDefaultMenuItems {
|
|
||||||
menu_name: String,
|
|
||||||
f: FnItemBag,
|
|
||||||
weight: Weight,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ActionDispatcher for DeclareDefaultMenuItems {
|
|
||||||
fn referer_id(&self) -> Option<String> {
|
|
||||||
Some(self.menu_name.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn weight(&self) -> Weight {
|
|
||||||
self.weight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DeclareDefaultMenuItems {
|
|
||||||
pub fn new(menu_name: impl Into<String>, f: FnItemBag) -> Self {
|
|
||||||
DeclareDefaultMenuItems {
|
|
||||||
menu_name: menu_name.into(),
|
|
||||||
f,
|
|
||||||
weight: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_weight(mut self, w: Weight) -> Self {
|
|
||||||
self.weight = w;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn dispatch(menu_name: &str, bag: &mut ItemBag) {
|
|
||||||
dispatch_actions(
|
|
||||||
&ActionKey::new(UniqueId::of::<Self>(), None, Some(menu_name.to_owned())),
|
|
||||||
|action: &Self| (action.f)(bag),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **< AlterMenuTree >******************************************************************************
|
|
||||||
|
|
||||||
/// Acción para modificar el árbol de un menú antes de calcularse el active trail.
|
|
||||||
///
|
|
||||||
/// El `referer_id` es el `machine_name` del menú. Las acciones con `referer_id` `None`
|
|
||||||
/// no se registran aquí; usa un `machine_name` específico por menú.
|
|
||||||
///
|
|
||||||
/// Usos habituales: filtrar nodos por permisos, añadir nodos dinámicos, reordenar.
|
|
||||||
///
|
|
||||||
/// # Ejemplo
|
|
||||||
///
|
|
||||||
/// ```rust,no_run
|
|
||||||
/// use pagetop_menu::action::AlterMenuTree;
|
|
||||||
/// use pagetop_menu::tree::MenuTree;
|
|
||||||
/// use pagetop::prelude::*;
|
|
||||||
///
|
|
||||||
/// fn hide_disabled(tree: &mut MenuTree, _cx: &Context) {
|
|
||||||
/// tree.roots.retain(|n| n.enabled);
|
|
||||||
/// }
|
|
||||||
/// // En Extension::actions():
|
|
||||||
/// // AlterMenuTree::new("main", hide_disabled)
|
|
||||||
/// ```
|
|
||||||
pub struct AlterMenuTree {
|
|
||||||
menu_name: String,
|
|
||||||
f: FnAlterTree,
|
|
||||||
weight: Weight,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ActionDispatcher for AlterMenuTree {
|
|
||||||
fn referer_id(&self) -> Option<String> {
|
|
||||||
Some(self.menu_name.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn weight(&self) -> Weight {
|
|
||||||
self.weight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AlterMenuTree {
|
|
||||||
pub fn new(menu_name: impl Into<String>, f: FnAlterTree) -> Self {
|
|
||||||
AlterMenuTree {
|
|
||||||
menu_name: menu_name.into(),
|
|
||||||
f,
|
|
||||||
weight: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_weight(mut self, w: Weight) -> Self {
|
|
||||||
self.weight = w;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn dispatch(menu_name: &str, tree: &mut MenuTree, cx: &Context) {
|
|
||||||
dispatch_actions(
|
|
||||||
&ActionKey::new(UniqueId::of::<Self>(), None, Some(menu_name.to_owned())),
|
|
||||||
|action: &Self| (action.f)(tree, cx),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **< ResolveActiveTrail >*************************************************************************
|
|
||||||
|
|
||||||
/// Acción para resolver el active trail en casos que la coincidencia por URL no cubre.
|
|
||||||
///
|
|
||||||
/// Se despacha después del cálculo automático de active trail. Útil para rutas
|
|
||||||
/// paramétricas (p. ej., `/blog/{slug}` que debe marcar activo al ítem `/blog`).
|
|
||||||
pub struct ResolveActiveTrail {
|
|
||||||
menu_name: String,
|
|
||||||
f: FnAlterTree,
|
|
||||||
weight: Weight,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ActionDispatcher for ResolveActiveTrail {
|
|
||||||
fn referer_id(&self) -> Option<String> {
|
|
||||||
Some(self.menu_name.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn weight(&self) -> Weight {
|
|
||||||
self.weight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ResolveActiveTrail {
|
|
||||||
pub fn new(menu_name: impl Into<String>, f: FnAlterTree) -> Self {
|
|
||||||
ResolveActiveTrail {
|
|
||||||
menu_name: menu_name.into(),
|
|
||||||
f,
|
|
||||||
weight: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_weight(mut self, w: Weight) -> Self {
|
|
||||||
self.weight = w;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn dispatch(menu_name: &str, tree: &mut MenuTree, cx: &Context) {
|
|
||||||
dispatch_actions(
|
|
||||||
&ActionKey::new(UniqueId::of::<Self>(), None, Some(menu_name.to_owned())),
|
|
||||||
|action: &Self| (action.f)(tree, cx),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **< DecorateMenuItem >***************************************************************************
|
|
||||||
|
|
||||||
/// Acción para decorar nodos individuales antes del render (atributos HTML, iconos, badges).
|
|
||||||
///
|
|
||||||
/// Se despacha para cada nodo del árbol después del cálculo del active trail,
|
|
||||||
/// por lo que el callback puede leer `node.is_active` y `node.in_active_trail`.
|
|
||||||
pub struct DecorateMenuItem {
|
|
||||||
menu_name: String,
|
|
||||||
f: FnDecorate,
|
|
||||||
weight: Weight,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ActionDispatcher for DecorateMenuItem {
|
|
||||||
fn referer_id(&self) -> Option<String> {
|
|
||||||
Some(self.menu_name.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn weight(&self) -> Weight {
|
|
||||||
self.weight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DecorateMenuItem {
|
|
||||||
pub fn new(menu_name: impl Into<String>, f: FnDecorate) -> Self {
|
|
||||||
DecorateMenuItem {
|
|
||||||
menu_name: menu_name.into(),
|
|
||||||
f,
|
|
||||||
weight: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_weight(mut self, w: Weight) -> Self {
|
|
||||||
self.weight = w;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn dispatch(menu_name: &str, node: &mut MenuNode, cx: &Context) {
|
|
||||||
dispatch_actions(
|
|
||||||
&ActionKey::new(UniqueId::of::<Self>(), None, Some(menu_name.to_owned())),
|
|
||||||
|action: &Self| (action.f)(node, cx),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
//! Caché in-process de los ítems de menú por `machine_name`.
|
|
||||||
//!
|
|
||||||
//! Almacena la lista plana de modelos y sus traducciones para cada menú. El árbol se
|
|
||||||
//! reconstruye en memoria en cada petición a partir de esta estructura. Hoy no existe ningún
|
|
||||||
//! camino que escriba ítems tras el sembrado inicial (`seed::run()`, antes de la primera
|
|
||||||
//! petición), así que no hace falta invalidación: cuando exista una vía de escritura en caliente
|
|
||||||
//! (p. ej. una UI de administración), deberá invalidar la entrada correspondiente aquí.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::LazyLock;
|
|
||||||
use std::sync::{Arc, RwLock};
|
|
||||||
|
|
||||||
use crate::entity::{menu_item, menu_item_translation};
|
|
||||||
|
|
||||||
/// Contenido plano de un menú listo para construir el árbol.
|
|
||||||
pub struct FlatMenu {
|
|
||||||
pub items: Vec<menu_item::Model>,
|
|
||||||
/// Traducciones indexadas por `item_id`.
|
|
||||||
pub translations: HashMap<i32, Vec<menu_item_translation::Model>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
static CACHE: LazyLock<RwLock<HashMap<String, Arc<FlatMenu>>>> =
|
|
||||||
LazyLock::new(|| RwLock::new(HashMap::new()));
|
|
||||||
|
|
||||||
/// Devuelve el contenido cacheado del menú dado, o lo carga desde BD si no está.
|
|
||||||
pub async fn get_or_load(menu_id: i32, machine_name: &str) -> Arc<FlatMenu> {
|
|
||||||
{
|
|
||||||
let guard = CACHE.read().expect("cache read lock poisoned");
|
|
||||||
if let Some(flat) = guard.get(machine_name) {
|
|
||||||
return Arc::clone(flat);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let flat = load_from_db(menu_id).await;
|
|
||||||
let arc = Arc::new(flat);
|
|
||||||
CACHE
|
|
||||||
.write()
|
|
||||||
.expect("cache write lock poisoned")
|
|
||||||
.insert(machine_name.to_owned(), Arc::clone(&arc));
|
|
||||||
arc
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn load_from_db(menu_id: i32) -> FlatMenu {
|
|
||||||
use pagetop_seaorm::db::{ColumnTrait, EntityTrait, QueryFilter, dbconn};
|
|
||||||
|
|
||||||
let items = menu_item::Entity::find()
|
|
||||||
.filter(menu_item::Column::MenuId.eq(menu_id))
|
|
||||||
.all(dbconn())
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let item_ids: Vec<i32> = items.iter().map(|i| i.id).collect();
|
|
||||||
|
|
||||||
let all_translations = if item_ids.is_empty() {
|
|
||||||
vec![]
|
|
||||||
} else {
|
|
||||||
menu_item_translation::Entity::find()
|
|
||||||
.filter(menu_item_translation::Column::ItemId.is_in(item_ids))
|
|
||||||
.all(dbconn())
|
|
||||||
.await
|
|
||||||
.unwrap_or_default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut translations: HashMap<i32, Vec<menu_item_translation::Model>> = HashMap::new();
|
|
||||||
for t in all_translations {
|
|
||||||
translations.entry(t.item_id).or_default().push(t);
|
|
||||||
}
|
|
||||||
|
|
||||||
FlatMenu {
|
|
||||||
items,
|
|
||||||
translations,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
//! Componentes de renderizado de menús.
|
|
||||||
|
|
||||||
mod menu_block;
|
|
||||||
mod menu_breadcrumb;
|
|
||||||
|
|
||||||
pub use menu_block::MenuBlock;
|
|
||||||
pub use menu_breadcrumb::MenuBreadcrumb;
|
|
||||||
|
|
@ -1,195 +0,0 @@
|
||||||
use pagetop::prelude::*;
|
|
||||||
|
|
||||||
use crate::tree::{MenuKey, MenuNode, TreeOptions, build_tree, try_resolve_menu_url};
|
|
||||||
|
|
||||||
/// Renderiza un menú completo como bloque de navegación HTML.
|
|
||||||
///
|
|
||||||
/// Se construye componiendo [`Nav`] y [`nav::Item`] (con [`Dropdown`]/[`dropdown::Item`] para
|
|
||||||
/// los nodos con hijos) a partir del árbol del menú, así que produce el mismo marcado accesible
|
|
||||||
/// que cualquier `Nav`/`Dropdown` y se beneficia igual del CSS/JavaScript que aporta el tema
|
|
||||||
/// activo:
|
|
||||||
///
|
|
||||||
/// ```html
|
|
||||||
/// <nav aria-label="Main">
|
|
||||||
/// <ul class="nav">
|
|
||||||
/// <li class="nav-item"><a class="nav-link" aria-current="page" href="/">Home</a></li>
|
|
||||||
/// </ul>
|
|
||||||
/// </nav>
|
|
||||||
/// ```
|
|
||||||
///
|
|
||||||
/// Los temas pueden sobreescribir el render con `render_component()`, tanto de `MenuBlock` como,
|
|
||||||
/// más generalmente, de [`Nav`]/[`nav::Item`]/[`Dropdown`]/[`dropdown::Item`]. `pagetop-bootsier`
|
|
||||||
/// ya intercepta `Dropdown` así (ver `theme::bs::dropdown`), y por tanto también los que cuelguen
|
|
||||||
/// de un `nav::Item::dropdown()`; `MenuBlock`, `Nav` y `Navbar` siguen sin interceptarse: Bootsier
|
|
||||||
/// mantiene sus propios `bs::Navbar`/`bs::Nav`, sin relación con este componente.
|
|
||||||
///
|
|
||||||
/// # Limitaciones conocidas
|
|
||||||
///
|
|
||||||
/// - **Profundidad máxima de 2 niveles.** [`Dropdown`] no admite submenús anidados (como Bootstrap,
|
|
||||||
/// del que toma su marcado): un nodo de tercer nivel o más profundo nunca se construye.
|
|
||||||
/// [`TreeOptions::max_depth`] se acota internamente a `2` con independencia de lo que indique
|
|
||||||
/// [`with_max_depth()`](Self::with_max_depth), así que no hay pérdida silenciosa de datos,
|
|
||||||
/// sencillamente no se piden a la base de datos.
|
|
||||||
/// - **Sin colapso responsive propio.** A diferencia del antiguo `Menu::collapsible`, `Nav` es una
|
|
||||||
/// lista plana sin botón ni JavaScript de colapso; una aplicación que necesite ese comportamiento
|
|
||||||
/// debe envolver `MenuBlock` en su propia chrome hasta que exista un componente `Navbar` en el
|
|
||||||
/// core.
|
|
||||||
/// - `MenuNode::in_active_trail` y `MenuNode::expanded` (ver [`crate::tree::MenuNode`]) todavía no
|
|
||||||
/// se reflejan en el marcado. Ni [`nav::Item`] ni [`dropdown::Item`] tienen hoy una forma
|
|
||||||
/// verificada de pre-abrirse en el servidor sin que la mejora progresiva del tema
|
|
||||||
/// (`accessible-menu` en `Basic`) lo sobrescriba al inicializarse. Sólo se traduce el estado
|
|
||||||
/// `is_active`/`enabled` de cada nodo.
|
|
||||||
///
|
|
||||||
/// # Ejemplo
|
|
||||||
///
|
|
||||||
/// ```rust,no_run
|
|
||||||
/// use pagetop::prelude::*;
|
|
||||||
/// use pagetop_menu::component::MenuBlock;
|
|
||||||
///
|
|
||||||
/// async fn handler(request: HttpRequest) -> Result<Markup, ErrorPage> {
|
|
||||||
/// Page::new(request)
|
|
||||||
/// .with_child(MenuBlock::with("main"))
|
|
||||||
/// .render().await
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
#[derive(AutoDefault, Clone, Debug)]
|
|
||||||
pub struct MenuBlock {
|
|
||||||
menu_name: Option<String>,
|
|
||||||
show_title: bool,
|
|
||||||
max_depth: Option<u8>,
|
|
||||||
include_disabled: bool,
|
|
||||||
hide_when_empty: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Component for MenuBlock {
|
|
||||||
fn new() -> Self {
|
|
||||||
MenuBlock {
|
|
||||||
hide_when_empty: true,
|
|
||||||
..Self::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
|
||||||
let Some(name) = &self.menu_name else {
|
|
||||||
return Ok(html! {});
|
|
||||||
};
|
|
||||||
|
|
||||||
// `Dropdown` no admite submenús: nunca se piden más de 2 niveles al árbol.
|
|
||||||
let opts = TreeOptions {
|
|
||||||
max_depth: Some(self.max_depth.map(|d| d.min(2)).unwrap_or(2)),
|
|
||||||
include_disabled: self.include_disabled,
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(tree) = build_tree(MenuKey::Name(name.clone()), cx, &opts).await else {
|
|
||||||
return Ok(html! {});
|
|
||||||
};
|
|
||||||
|
|
||||||
if tree.roots.is_empty() && self.hide_when_empty {
|
|
||||||
return Ok(html! {});
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut nav = Nav::new();
|
|
||||||
for node in &tree.roots {
|
|
||||||
nav = nav.with_item(node_to_item(node, cx));
|
|
||||||
}
|
|
||||||
|
|
||||||
let aria_label = (!tree.title.is_empty()).then_some(&tree.title);
|
|
||||||
|
|
||||||
Ok(html! {
|
|
||||||
@if self.show_title {
|
|
||||||
h2.menu-title { (&tree.title) }
|
|
||||||
}
|
|
||||||
nav aria-label=[aria_label] {
|
|
||||||
(nav.render(cx).await)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[builder_impl]
|
|
||||||
impl MenuBlock {
|
|
||||||
/// Crea un `MenuBlock` para el menú con el `machine_name` dado.
|
|
||||||
pub fn with(menu_name: impl Into<String>) -> Self {
|
|
||||||
let mut block = Self::new();
|
|
||||||
block.menu_name = Some(menu_name.into());
|
|
||||||
block
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_show_title(mut self, v: impl Into<Option<bool>>) -> Self {
|
|
||||||
if let Some(v) = v.into() {
|
|
||||||
self.show_title = v;
|
|
||||||
}
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Establece la profundidad máxima de nodos a incluir. El efectivo nunca supera `2`, por muy
|
|
||||||
/// alto que sea el valor indicado (ver "Limitaciones conocidas" en [`MenuBlock`]).
|
|
||||||
pub fn with_max_depth(mut self, v: impl Into<Option<u8>>) -> Self {
|
|
||||||
self.max_depth = v.into();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_include_disabled(mut self, v: impl Into<Option<bool>>) -> Self {
|
|
||||||
if let Some(v) = v.into() {
|
|
||||||
self.include_disabled = v;
|
|
||||||
}
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_hide_when_empty(mut self, v: impl Into<Option<bool>>) -> Self {
|
|
||||||
if let Some(v) = v.into() {
|
|
||||||
self.hide_when_empty = v;
|
|
||||||
}
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **< Traducción de MenuNode a nav::Item / dropdown::Item >****************************************
|
|
||||||
|
|
||||||
// Convierte un `MenuNode` de nivel 1 (raíz) en un `nav::Item`: sin hijos, enlace (o etiqueta sin
|
|
||||||
// ruta propia); con hijos, activador de un `Dropdown` con sus hijos como `dropdown::Item`. Los
|
|
||||||
// hijos de un `MenuNode` de nivel 1 nunca tienen a su vez hijos propios. `TreeOptions::max_depth`
|
|
||||||
// se acota a 2 en `MenuBlock::prepare()`, así que no hay un tercer nivel que representar.
|
|
||||||
fn node_to_item(node: &MenuNode, cx: &Context) -> nav::Item {
|
|
||||||
if !node.children.is_empty() {
|
|
||||||
let mut dropdown = Dropdown::new().with_title(Lc::n(node.title.clone()));
|
|
||||||
for child in &node.children {
|
|
||||||
dropdown = dropdown.with_item(child_to_item(child, cx));
|
|
||||||
}
|
|
||||||
return nav::Item::dropdown(dropdown);
|
|
||||||
}
|
|
||||||
|
|
||||||
let label = Lc::n(node.title.clone());
|
|
||||||
let is_external = node.url.as_ref().is_some_and(RoutePath::is_external);
|
|
||||||
let disabled = !node.enabled;
|
|
||||||
|
|
||||||
let Some(route) = try_resolve_menu_url(node.url.as_ref(), cx).map(Route::from) else {
|
|
||||||
return nav::Item::label(label);
|
|
||||||
};
|
|
||||||
match (is_external, disabled) {
|
|
||||||
(true, true) => nav::Item::link_blank_disabled(label, route),
|
|
||||||
(true, false) => nav::Item::link_blank(label, route),
|
|
||||||
(false, true) => nav::Item::link_disabled(label, route),
|
|
||||||
(false, false) => nav::Item::link(label, route),
|
|
||||||
}
|
|
||||||
.with_active(node.is_active)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convierte un `MenuNode` de nivel 2 en un `dropdown::Item`: sin ruta, etiqueta no interactiva; con
|
|
||||||
// ruta, enlace. `Dropdown` no admite submenús, así que no hay recursión posible aquí.
|
|
||||||
fn child_to_item(node: &MenuNode, cx: &Context) -> dropdown::Item {
|
|
||||||
let label = Lc::n(node.title.clone());
|
|
||||||
let is_external = node.url.as_ref().is_some_and(RoutePath::is_external);
|
|
||||||
let disabled = !node.enabled;
|
|
||||||
|
|
||||||
let Some(route) = try_resolve_menu_url(node.url.as_ref(), cx).map(Route::from) else {
|
|
||||||
return dropdown::Item::label(label);
|
|
||||||
};
|
|
||||||
match (is_external, disabled) {
|
|
||||||
(true, true) => dropdown::Item::link_blank_disabled(label, route),
|
|
||||||
(true, false) => dropdown::Item::link_blank(label, route),
|
|
||||||
(false, true) => dropdown::Item::link_disabled(label, route),
|
|
||||||
(false, false) => dropdown::Item::link(label, route),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
use pagetop::prelude::*;
|
|
||||||
|
|
||||||
use crate::tree::{MenuKey, MenuNode, TreeOptions, build_tree, try_resolve_menu_url};
|
|
||||||
|
|
||||||
/// Migas de pan del menú dado, basándose en el active trail.
|
|
||||||
///
|
|
||||||
/// Resuelve el árbol del menú y su active trail (de forma asíncrona, en su propio
|
|
||||||
/// [`prepare()`](Component::prepare)) y delega el renderizado en
|
|
||||||
/// [`Breadcrumb`](pagetop::base::component::Breadcrumb): esta extensión sólo aporta los datos, la
|
|
||||||
/// estructura HTML y las clases CSS son responsabilidad del componente base.
|
|
||||||
///
|
|
||||||
/// # Ejemplo
|
|
||||||
///
|
|
||||||
/// ```rust,no_run
|
|
||||||
/// use pagetop::prelude::*;
|
|
||||||
/// use pagetop_menu::component::MenuBreadcrumb;
|
|
||||||
///
|
|
||||||
/// async fn handler(request: HttpRequest) -> Result<Markup, ErrorPage> {
|
|
||||||
/// Page::new(request)
|
|
||||||
/// .with_child(MenuBreadcrumb::with("main"))
|
|
||||||
/// .render().await
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
#[derive(AutoDefault, Clone, Debug)]
|
|
||||||
pub struct MenuBreadcrumb {
|
|
||||||
menu_name: Option<String>,
|
|
||||||
include_current: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Component for MenuBreadcrumb {
|
|
||||||
fn new() -> Self {
|
|
||||||
MenuBreadcrumb {
|
|
||||||
include_current: true,
|
|
||||||
..Self::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
|
||||||
let Some(name) = &self.menu_name else {
|
|
||||||
return Ok(html! {});
|
|
||||||
};
|
|
||||||
|
|
||||||
let opts = TreeOptions::default();
|
|
||||||
let Some(tree) = build_tree(MenuKey::Name(name.clone()), cx, &opts).await else {
|
|
||||||
return Ok(html! {});
|
|
||||||
};
|
|
||||||
|
|
||||||
let path = extract_trail(&tree.roots);
|
|
||||||
if path.is_empty() {
|
|
||||||
return Ok(html! {});
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut inner = Breadcrumb::new();
|
|
||||||
let last = path.len() - 1;
|
|
||||||
for (i, node) in path.iter().enumerate() {
|
|
||||||
let label = Lc::n(node.title.clone());
|
|
||||||
if i == last {
|
|
||||||
if self.include_current {
|
|
||||||
inner = inner.with_crumb(breadcrumb::Crumb::current(label));
|
|
||||||
}
|
|
||||||
} else if let Some(url) = node.url.clone() {
|
|
||||||
let route =
|
|
||||||
Route::with(move |cx| try_resolve_menu_url(Some(&url), cx).unwrap_or_default());
|
|
||||||
inner = inner.with_crumb(breadcrumb::Crumb::new(label, route));
|
|
||||||
} else {
|
|
||||||
inner = inner.with_crumb(breadcrumb::Crumb::text(label));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(inner.render(cx).await)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[builder_impl]
|
|
||||||
impl MenuBreadcrumb {
|
|
||||||
/// Crea un `MenuBreadcrumb` para el menú con el `machine_name` dado.
|
|
||||||
pub fn with(menu_name: impl Into<String>) -> Self {
|
|
||||||
let mut bc = Self::new();
|
|
||||||
bc.menu_name = Some(menu_name.into());
|
|
||||||
bc
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_include_current(mut self, v: impl Into<Option<bool>>) -> Self {
|
|
||||||
if let Some(v) = v.into() {
|
|
||||||
self.include_current = v;
|
|
||||||
}
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extrae la cadena raíz -> nodo activo recorriendo el active trail.
|
|
||||||
fn extract_trail(nodes: &[MenuNode]) -> Vec<&MenuNode> {
|
|
||||||
for node in nodes {
|
|
||||||
if node.in_active_trail || node.is_active {
|
|
||||||
let mut path = extract_trail(&node.children);
|
|
||||||
path.insert(0, node);
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
//! Configuración de `pagetop-menu`.
|
|
||||||
//!
|
|
||||||
//! Todos los valores pueden sobreescribirse en los ficheros TOML de la aplicación:
|
|
||||||
//!
|
|
||||||
//! ```toml
|
|
||||||
//! [menu]
|
|
||||||
//! default_menus = ["main", "footer", "user"]
|
|
||||||
//! ```
|
|
||||||
|
|
||||||
use pagetop::prelude::*;
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use std::sync::LazyLock;
|
|
||||||
|
|
||||||
// **< CONFIG_MENU >********************************************************************************
|
|
||||||
|
|
||||||
include_config!(CONFIG_MENU: MenuTopConfig => [
|
|
||||||
// Menús que se crean automáticamente si no los declara ninguna extensión.
|
|
||||||
// (Nota: los valores de lista no son soportados por config-rs vía set_default; se leen del
|
|
||||||
// TOML.)
|
|
||||||
]);
|
|
||||||
|
|
||||||
// **< MenuTopConfig >******************************************************************************
|
|
||||||
|
|
||||||
/// Estructura raíz para la sección `[menu]` del fichero de configuración.
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
pub struct MenuTopConfig {
|
|
||||||
// `default_menus` (el único campo de `Settings`) no puede recibir su valor por defecto vía
|
|
||||||
// `set_default` en `include_config!` (config-rs no soporta defaults de tipo lista, ver arriba),
|
|
||||||
// así que si la aplicación no declara `[menu]` en ningún TOML, la sección entera falta en la
|
|
||||||
// configuración combinada. `#[serde(default)]` recurre a `Settings::default()` en ese caso.
|
|
||||||
#[serde(default)]
|
|
||||||
pub menu: Settings,
|
|
||||||
}
|
|
||||||
|
|
||||||
// **< SETTINGS >***********************************************************************************
|
|
||||||
|
|
||||||
/// Acceso directo a los ajustes de `pagetop-menu` (alias de `CONFIG_MENU.menu`).
|
|
||||||
pub static SETTINGS: LazyLock<Settings> = LazyLock::new(|| CONFIG_MENU.menu.clone());
|
|
||||||
|
|
||||||
// **< Settings >***********************************************************************************
|
|
||||||
|
|
||||||
/// Ajustes de la extensión `pagetop-menu`, accesibles en la sección `[menu]` del TOML.
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
pub struct Settings {
|
|
||||||
/// Menús que se aseguran en BD al arrancar si ninguna extensión los declara.
|
|
||||||
#[serde(default = "default_menus")]
|
|
||||||
pub default_menus: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Settings {
|
|
||||||
fn default() -> Self {
|
|
||||||
Settings {
|
|
||||||
default_menus: default_menus(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_menus() -> Vec<String> {
|
|
||||||
vec!["main".into(), "footer".into(), "user".into()]
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
//! Entidades SeaORM de `pagetop-menu`.
|
|
||||||
|
|
||||||
pub mod menu;
|
|
||||||
pub mod menu_item;
|
|
||||||
pub mod menu_item_translation;
|
|
||||||
pub mod menu_translation;
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
use pagetop::prelude::*;
|
|
||||||
use pagetop_seaorm::db::*;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
|
|
||||||
#[sea_orm(table_name = "menus")]
|
|
||||||
pub struct Model {
|
|
||||||
#[sea_orm(primary_key)]
|
|
||||||
pub id: i32,
|
|
||||||
#[sea_orm(unique)]
|
|
||||||
pub machine_name: String,
|
|
||||||
pub locked: bool,
|
|
||||||
pub created_at: NaiveDateTime,
|
|
||||||
pub updated_at: NaiveDateTime,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)]
|
|
||||||
pub enum Relation {
|
|
||||||
#[sea_orm(has_many = "super::menu_translation::Entity")]
|
|
||||||
MenuTranslations,
|
|
||||||
#[sea_orm(has_many = "super::menu_item::Entity")]
|
|
||||||
MenuItems,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Related<super::menu_translation::Entity> for Entity {
|
|
||||||
fn to() -> RelationDef {
|
|
||||||
Relation::MenuTranslations.def()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Related<super::menu_item::Entity> for Entity {
|
|
||||||
fn to() -> RelationDef {
|
|
||||||
Relation::MenuItems.def()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ActiveModelBehavior for ActiveModel {}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
use pagetop::prelude::*;
|
|
||||||
use pagetop_seaorm::db::*;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
|
|
||||||
#[sea_orm(table_name = "menu_items")]
|
|
||||||
pub struct Model {
|
|
||||||
#[sea_orm(primary_key)]
|
|
||||||
pub id: i32,
|
|
||||||
pub menu_id: i32,
|
|
||||||
pub parent_id: Option<i32>,
|
|
||||||
pub url: String,
|
|
||||||
pub weight: i32,
|
|
||||||
pub enabled: bool,
|
|
||||||
pub expanded: bool,
|
|
||||||
pub provider: String,
|
|
||||||
pub external_key: Option<String>,
|
|
||||||
pub created_at: NaiveDateTime,
|
|
||||||
pub updated_at: NaiveDateTime,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)]
|
|
||||||
pub enum Relation {
|
|
||||||
#[sea_orm(
|
|
||||||
belongs_to = "super::menu::Entity",
|
|
||||||
from = "Column::MenuId",
|
|
||||||
to = "super::menu::Column::Id",
|
|
||||||
on_delete = "Cascade"
|
|
||||||
)]
|
|
||||||
Menu,
|
|
||||||
#[sea_orm(has_many = "super::menu_item_translation::Entity")]
|
|
||||||
MenuItemTranslations,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Related<super::menu::Entity> for Entity {
|
|
||||||
fn to() -> RelationDef {
|
|
||||||
Relation::Menu.def()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Related<super::menu_item_translation::Entity> for Entity {
|
|
||||||
fn to() -> RelationDef {
|
|
||||||
Relation::MenuItemTranslations.def()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ActiveModelBehavior for ActiveModel {}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
use pagetop_seaorm::db::*;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
|
|
||||||
#[sea_orm(table_name = "menu_item_translations")]
|
|
||||||
pub struct Model {
|
|
||||||
#[sea_orm(primary_key, auto_increment = false)]
|
|
||||||
pub item_id: i32,
|
|
||||||
#[sea_orm(primary_key, auto_increment = false)]
|
|
||||||
pub lang: String,
|
|
||||||
pub title: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)]
|
|
||||||
pub enum Relation {
|
|
||||||
#[sea_orm(
|
|
||||||
belongs_to = "super::menu_item::Entity",
|
|
||||||
from = "Column::ItemId",
|
|
||||||
to = "super::menu_item::Column::Id",
|
|
||||||
on_delete = "Cascade"
|
|
||||||
)]
|
|
||||||
MenuItem,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Related<super::menu_item::Entity> for Entity {
|
|
||||||
fn to() -> RelationDef {
|
|
||||||
Relation::MenuItem.def()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ActiveModelBehavior for ActiveModel {}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
use pagetop_seaorm::db::*;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
|
|
||||||
#[sea_orm(table_name = "menu_translations")]
|
|
||||||
pub struct Model {
|
|
||||||
#[sea_orm(primary_key, auto_increment = false)]
|
|
||||||
pub menu_id: i32,
|
|
||||||
#[sea_orm(primary_key, auto_increment = false)]
|
|
||||||
pub lang: String,
|
|
||||||
pub title: String,
|
|
||||||
pub description: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)]
|
|
||||||
pub enum Relation {
|
|
||||||
#[sea_orm(
|
|
||||||
belongs_to = "super::menu::Entity",
|
|
||||||
from = "Column::MenuId",
|
|
||||||
to = "super::menu::Column::Id",
|
|
||||||
on_delete = "Cascade"
|
|
||||||
)]
|
|
||||||
Menu,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Related<super::menu::Entity> for Entity {
|
|
||||||
fn to() -> RelationDef {
|
|
||||||
Relation::Menu.def()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ActiveModelBehavior for ActiveModel {}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
//! Tipos de error de `pagetop-menu`.
|
|
||||||
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
/// Errores que puede producir `pagetop-menu`.
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub enum MenuError {
|
|
||||||
#[error("invalid machine name: {0}")]
|
|
||||||
InvalidName(String),
|
|
||||||
|
|
||||||
#[error("database error: {0}")]
|
|
||||||
Database(#[from] pagetop_seaorm::db::DbErr),
|
|
||||||
}
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
/*!
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||
<h1>PageTop Menu</h1>
|
|
||||||
|
|
||||||
<p>Gestión centralizada y persistente de menús para <strong>PageTop</strong>.</p>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
## Guía rápida
|
|
||||||
|
|
||||||
Declara la dependencia en tu `Cargo.toml` y reenvía a `pagetop-seaorm` el motor de base de datos
|
|
||||||
que vayas a usar:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[features]
|
|
||||||
sqlite = ["pagetop-seaorm/sqlite"]
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
pagetop-menu = { version = "..." }
|
|
||||||
```
|
|
||||||
|
|
||||||
Añade `&pagetop_menu::Menu` a las dependencias de tu extensión, declara los menús
|
|
||||||
que necesitas y añade los componentes a tus páginas:
|
|
||||||
|
|
||||||
```rust,no_run
|
|
||||||
use pagetop::prelude::*;
|
|
||||||
use pagetop_menu::prelude::*;
|
|
||||||
|
|
||||||
pub struct MyApp;
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Extension for MyApp {
|
|
||||||
fn dependencies(&self) -> Vec<ExtensionRef> {
|
|
||||||
vec![&pagetop_menu::Menu]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn actions(&self) -> Vec<ActionBox> {
|
|
||||||
actions![
|
|
||||||
DeclareDefaultMenuItems::new("main", home_items),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn configure_router(&self, router: Router) -> Router {
|
|
||||||
router.route("/", web::get(home))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn home_items(bag: &mut ItemBag) {
|
|
||||||
bag.add(NewMenuItem::new()
|
|
||||||
.with_provider("myapp")
|
|
||||||
.with_external_key("home")
|
|
||||||
.with_title("Home")
|
|
||||||
.with_url("/")
|
|
||||||
.with_weight(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn home(request: HttpRequest) -> Result<Markup, ErrorPage> {
|
|
||||||
Page::new(request)
|
|
||||||
.with_child(MenuBlock::with("main"))
|
|
||||||
.render().await
|
|
||||||
}
|
|
||||||
```
|
|
||||||
*/
|
|
||||||
|
|
||||||
use pagetop::prelude::*;
|
|
||||||
use pagetop_seaorm::install_migrations;
|
|
||||||
|
|
||||||
include_locales!(LOCALES_MENU);
|
|
||||||
|
|
||||||
pub mod action;
|
|
||||||
pub mod component;
|
|
||||||
pub mod config;
|
|
||||||
pub mod error;
|
|
||||||
pub mod tree;
|
|
||||||
|
|
||||||
pub(crate) mod cache;
|
|
||||||
pub(crate) mod entity;
|
|
||||||
pub(crate) mod migration;
|
|
||||||
pub(crate) mod repo;
|
|
||||||
pub(crate) mod seed;
|
|
||||||
|
|
||||||
pub use action::{
|
|
||||||
AlterMenuTree, DeclareDefaultMenuItems, DeclareDefaultMenus, DecorateMenuItem, ItemBag,
|
|
||||||
MenuDefs, ResolveActiveTrail,
|
|
||||||
};
|
|
||||||
pub use repo::NewMenuItem;
|
|
||||||
pub use tree::{MenuKey, MenuNode, MenuTree, TreeOptions, build_tree};
|
|
||||||
|
|
||||||
/// Prelude de `pagetop-menu`.
|
|
||||||
pub mod prelude {
|
|
||||||
pub use crate::action::{
|
|
||||||
AlterMenuTree, DeclareDefaultMenuItems, DeclareDefaultMenus, DecorateMenuItem, ItemBag,
|
|
||||||
MenuDefs, ResolveActiveTrail,
|
|
||||||
};
|
|
||||||
pub use crate::component::{MenuBlock, MenuBreadcrumb};
|
|
||||||
pub use crate::error::MenuError;
|
|
||||||
pub use crate::repo::NewMenuItem;
|
|
||||||
pub use crate::tree::{MenuKey, MenuNode, MenuTree, TreeOptions, build_tree};
|
|
||||||
}
|
|
||||||
|
|
||||||
// **< Extension >**********************************************************************************
|
|
||||||
|
|
||||||
/// Implementa la extensión `pagetop-menu`.
|
|
||||||
pub struct Menu;
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Extension for Menu {
|
|
||||||
fn name(&self) -> Lc {
|
|
||||||
Lc::t("extension_name", &LOCALES_MENU)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn description(&self) -> Lc {
|
|
||||||
Lc::t("extension_description", &LOCALES_MENU)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn dependencies(&self) -> Vec<ExtensionRef> {
|
|
||||||
vec![&pagetop_seaorm::SeaORM]
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn initialize(&self) {
|
|
||||||
install_migrations!(
|
|
||||||
m20260629_000001_create_menus,
|
|
||||||
m20260629_000002_create_menu_translations,
|
|
||||||
m20260629_000003_create_menu_items,
|
|
||||||
m20260629_000004_create_menu_item_translations,
|
|
||||||
);
|
|
||||||
seed::run().await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
extension_name = PageTop Menu
|
|
||||||
extension_description = Centralized and persistent menu management for PageTop.
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
extension_name = PageTop Menú
|
|
||||||
extension_description = Gestión centralizada y persistente de menús para PageTop.
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
//! Migraciones de `pagetop-menu`.
|
|
||||||
|
|
||||||
pub mod m20260629_000001_create_menus;
|
|
||||||
pub mod m20260629_000002_create_menu_translations;
|
|
||||||
pub mod m20260629_000003_create_menu_items;
|
|
||||||
pub mod m20260629_000004_create_menu_item_translations;
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
use pagetop_seaorm::migration::*;
|
|
||||||
|
|
||||||
pub struct Migration;
|
|
||||||
|
|
||||||
#[pagetop::async_trait]
|
|
||||||
impl MigrationTrait for Migration {
|
|
||||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
|
||||||
manager
|
|
||||||
.create_table(
|
|
||||||
table_auto(Menus::Table)
|
|
||||||
.col(pk_auto(Menus::Id))
|
|
||||||
.col(string_len_uniq(Menus::MachineName, 64))
|
|
||||||
.col(boolean(Menus::Locked).default(false))
|
|
||||||
.to_owned(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
|
||||||
manager
|
|
||||||
.drop_table(Table::drop().table(Menus::Table).to_owned())
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(DeriveIden)]
|
|
||||||
pub enum Menus {
|
|
||||||
Table,
|
|
||||||
Id,
|
|
||||||
MachineName,
|
|
||||||
Locked,
|
|
||||||
}
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
use pagetop_seaorm::migration::*;
|
|
||||||
|
|
||||||
pub struct Migration;
|
|
||||||
|
|
||||||
#[pagetop::async_trait]
|
|
||||||
impl MigrationTrait for Migration {
|
|
||||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
|
||||||
manager
|
|
||||||
.create_table(
|
|
||||||
Table::create()
|
|
||||||
.table(MenuTranslations::Table)
|
|
||||||
.if_not_exists()
|
|
||||||
.col(integer(MenuTranslations::MenuId))
|
|
||||||
.col(string_len(MenuTranslations::Lang, 35))
|
|
||||||
.col(string_len(MenuTranslations::Title, 128))
|
|
||||||
.col(text_null(MenuTranslations::Description))
|
|
||||||
.primary_key(
|
|
||||||
Index::create()
|
|
||||||
.col(MenuTranslations::MenuId)
|
|
||||||
.col(MenuTranslations::Lang),
|
|
||||||
)
|
|
||||||
.to_owned(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
manager
|
|
||||||
.create_foreign_key(
|
|
||||||
ForeignKey::create()
|
|
||||||
.name("fk_menu_translations_menu_id")
|
|
||||||
.from(MenuTranslations::Table, MenuTranslations::MenuId)
|
|
||||||
.to(Menus::Table, Menus::Id)
|
|
||||||
.on_delete(ForeignKeyAction::Cascade)
|
|
||||||
.to_owned(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
|
||||||
manager
|
|
||||||
.drop_table(Table::drop().table(MenuTranslations::Table).to_owned())
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(DeriveIden)]
|
|
||||||
enum MenuTranslations {
|
|
||||||
Table,
|
|
||||||
MenuId,
|
|
||||||
Lang,
|
|
||||||
Title,
|
|
||||||
Description,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(DeriveIden)]
|
|
||||||
enum Menus {
|
|
||||||
Table,
|
|
||||||
Id,
|
|
||||||
}
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
use pagetop_seaorm::migration::*;
|
|
||||||
|
|
||||||
pub struct Migration;
|
|
||||||
|
|
||||||
#[pagetop::async_trait]
|
|
||||||
impl MigrationTrait for Migration {
|
|
||||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
|
||||||
manager
|
|
||||||
.create_table(
|
|
||||||
table_auto(MenuItems::Table)
|
|
||||||
.col(pk_auto(MenuItems::Id))
|
|
||||||
.col(integer(MenuItems::MenuId))
|
|
||||||
.col(integer_null(MenuItems::ParentId))
|
|
||||||
.col(string_len(MenuItems::Url, 2048))
|
|
||||||
.col(integer(MenuItems::Weight).default(0))
|
|
||||||
.col(boolean(MenuItems::Enabled).default(true))
|
|
||||||
.col(boolean(MenuItems::Expanded).default(false))
|
|
||||||
.col(string_len(MenuItems::Provider, 64).default("user"))
|
|
||||||
.col(string_len_null(MenuItems::ExternalKey, 128))
|
|
||||||
.to_owned(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
manager
|
|
||||||
.create_foreign_key(
|
|
||||||
ForeignKey::create()
|
|
||||||
.name("fk_menu_items_menu_id")
|
|
||||||
.from(MenuItems::Table, MenuItems::MenuId)
|
|
||||||
.to(Menus::Table, Menus::Id)
|
|
||||||
.on_delete(ForeignKeyAction::Cascade)
|
|
||||||
.to_owned(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Índice para listar hijos ordenados.
|
|
||||||
manager
|
|
||||||
.create_index(
|
|
||||||
Index::create()
|
|
||||||
.name("idx_menu_items_menu_parent_weight")
|
|
||||||
.table(MenuItems::Table)
|
|
||||||
.col(MenuItems::MenuId)
|
|
||||||
.col(MenuItems::ParentId)
|
|
||||||
.col(MenuItems::Weight)
|
|
||||||
.to_owned(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Índice de unicidad para upserts por extensión.
|
|
||||||
manager
|
|
||||||
.create_index(
|
|
||||||
Index::create()
|
|
||||||
.name("idx_menu_items_provider_key")
|
|
||||||
.table(MenuItems::Table)
|
|
||||||
.col(MenuItems::Provider)
|
|
||||||
.col(MenuItems::ExternalKey)
|
|
||||||
.unique()
|
|
||||||
.to_owned(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
|
||||||
manager
|
|
||||||
.drop_table(Table::drop().table(MenuItems::Table).to_owned())
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(DeriveIden)]
|
|
||||||
pub enum MenuItems {
|
|
||||||
Table,
|
|
||||||
Id,
|
|
||||||
MenuId,
|
|
||||||
ParentId,
|
|
||||||
Url,
|
|
||||||
Weight,
|
|
||||||
Enabled,
|
|
||||||
Expanded,
|
|
||||||
Provider,
|
|
||||||
ExternalKey,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(DeriveIden)]
|
|
||||||
enum Menus {
|
|
||||||
Table,
|
|
||||||
Id,
|
|
||||||
}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
use pagetop_seaorm::migration::*;
|
|
||||||
|
|
||||||
pub struct Migration;
|
|
||||||
|
|
||||||
#[pagetop::async_trait]
|
|
||||||
impl MigrationTrait for Migration {
|
|
||||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
|
||||||
manager
|
|
||||||
.create_table(
|
|
||||||
Table::create()
|
|
||||||
.table(MenuItemTranslations::Table)
|
|
||||||
.if_not_exists()
|
|
||||||
.col(integer(MenuItemTranslations::ItemId))
|
|
||||||
.col(string_len(MenuItemTranslations::Lang, 35))
|
|
||||||
.col(string_len(MenuItemTranslations::Title, 255))
|
|
||||||
.primary_key(
|
|
||||||
Index::create()
|
|
||||||
.col(MenuItemTranslations::ItemId)
|
|
||||||
.col(MenuItemTranslations::Lang),
|
|
||||||
)
|
|
||||||
.to_owned(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
manager
|
|
||||||
.create_foreign_key(
|
|
||||||
ForeignKey::create()
|
|
||||||
.name("fk_menu_item_translations_item_id")
|
|
||||||
.from(MenuItemTranslations::Table, MenuItemTranslations::ItemId)
|
|
||||||
.to(MenuItems::Table, MenuItems::Id)
|
|
||||||
.on_delete(ForeignKeyAction::Cascade)
|
|
||||||
.to_owned(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
|
||||||
manager
|
|
||||||
.drop_table(Table::drop().table(MenuItemTranslations::Table).to_owned())
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(DeriveIden)]
|
|
||||||
enum MenuItemTranslations {
|
|
||||||
Table,
|
|
||||||
ItemId,
|
|
||||||
Lang,
|
|
||||||
Title,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(DeriveIden)]
|
|
||||||
enum MenuItems {
|
|
||||||
Table,
|
|
||||||
Id,
|
|
||||||
}
|
|
||||||
|
|
@ -1,307 +0,0 @@
|
||||||
//! Operaciones de base de datos para menús e ítems de menú.
|
|
||||||
|
|
||||||
use pagetop::prelude::*;
|
|
||||||
use pagetop_seaorm::db::{
|
|
||||||
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, EntityTrait, QueryFilter, Set, dbconn,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::entity::{menu, menu_item, menu_item_translation, menu_translation};
|
|
||||||
use crate::error::MenuError;
|
|
||||||
use crate::tree::MenuKey;
|
|
||||||
|
|
||||||
// **< Tipos de entrada >***************************************************************************
|
|
||||||
|
|
||||||
/// Datos necesarios para crear un nuevo menú.
|
|
||||||
pub struct NewMenu {
|
|
||||||
pub machine_name: String,
|
|
||||||
/// Títulos por idioma: `(lang, title)`. Al menos uno es obligatorio.
|
|
||||||
pub titles: Vec<(String, String)>,
|
|
||||||
pub locked: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Datos de un ítem nuevo o para hacer upsert.
|
|
||||||
pub struct NewMenuItem {
|
|
||||||
pub parent_key: Option<String>,
|
|
||||||
/// Títulos por idioma: `(lang, title)`. Al menos uno es obligatorio.
|
|
||||||
pub titles: Vec<(String, String)>,
|
|
||||||
pub url: String,
|
|
||||||
pub weight: i32,
|
|
||||||
pub enabled: bool,
|
|
||||||
pub expanded: bool,
|
|
||||||
pub provider: String,
|
|
||||||
pub external_key: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl NewMenuItem {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
NewMenuItem {
|
|
||||||
parent_key: None,
|
|
||||||
titles: Vec::new(),
|
|
||||||
url: String::new(),
|
|
||||||
weight: 0,
|
|
||||||
enabled: true,
|
|
||||||
expanded: false,
|
|
||||||
provider: "user".into(),
|
|
||||||
external_key: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Añade el título en el idioma por defecto de la aplicación.
|
|
||||||
pub fn with_title(mut self, title: impl Into<String>) -> Self {
|
|
||||||
let lang = Locale::default_langid().to_string();
|
|
||||||
self.titles.push((lang, title.into()));
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Añade el título en el idioma indicado.
|
|
||||||
pub fn with_title_for(mut self, lang: impl Into<String>, title: impl Into<String>) -> Self {
|
|
||||||
self.titles.push((lang.into(), title.into()));
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_url(mut self, u: impl Into<String>) -> Self {
|
|
||||||
self.url = u.into();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_weight(mut self, w: i32) -> Self {
|
|
||||||
self.weight = w;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_enabled(mut self, v: bool) -> Self {
|
|
||||||
self.enabled = v;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_expanded(mut self, v: bool) -> Self {
|
|
||||||
self.expanded = v;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_provider(mut self, p: impl Into<String>) -> Self {
|
|
||||||
self.provider = p.into();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_external_key(mut self, k: impl Into<String>) -> Self {
|
|
||||||
self.external_key = Some(k.into());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_parent_key(mut self, k: impl Into<String>) -> Self {
|
|
||||||
self.parent_key = Some(k.into());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for NewMenuItem {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **< find_menu_model >****************************************************************************
|
|
||||||
|
|
||||||
/// Devuelve el modelo de BD del menú dado, o `None` si no existe.
|
|
||||||
pub async fn find_menu_model(key: &MenuKey) -> Option<menu::Model> {
|
|
||||||
match key {
|
|
||||||
MenuKey::Id(id) => menu::Entity::find_by_id(*id).one(dbconn()).await.ok()?,
|
|
||||||
MenuKey::Name(n) => menu::Entity::find()
|
|
||||||
.filter(menu::Column::MachineName.eq(n.as_str()))
|
|
||||||
.one(dbconn())
|
|
||||||
.await
|
|
||||||
.ok()?,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **< create_menu >********************************************************************************
|
|
||||||
|
|
||||||
/// Crea un nuevo menú en la base de datos con sus traducciones iniciales.
|
|
||||||
pub async fn create_menu(input: NewMenu) -> Result<menu::Model, MenuError> {
|
|
||||||
if input.machine_name.is_empty()
|
|
||||||
|| !input
|
|
||||||
.machine_name
|
|
||||||
.chars()
|
|
||||||
.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
|
||||||
{
|
|
||||||
return Err(MenuError::InvalidName(input.machine_name));
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = Utc::now().naive_utc();
|
|
||||||
let result = menu::Entity::insert(menu::ActiveModel {
|
|
||||||
id: NotSet,
|
|
||||||
machine_name: Set(input.machine_name),
|
|
||||||
locked: Set(input.locked),
|
|
||||||
created_at: Set(now),
|
|
||||||
updated_at: Set(now),
|
|
||||||
})
|
|
||||||
.exec_with_returning(dbconn())
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
for (lang, title) in input.titles {
|
|
||||||
upsert_menu_translation(result.id, &lang, &title, None).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Crea un menú sólo si no existe ya uno con el mismo `machine_name`, y sincroniza
|
|
||||||
/// la traducción del título en el idioma por defecto.
|
|
||||||
pub async fn ensure_menu(machine_name: &str, title: &str) -> Result<(), MenuError> {
|
|
||||||
let lang = Locale::default_langid().to_string();
|
|
||||||
|
|
||||||
let existing = find_menu_model(&MenuKey::Name(machine_name.to_owned())).await;
|
|
||||||
|
|
||||||
let menu_id = if let Some(m) = existing {
|
|
||||||
m.id
|
|
||||||
} else {
|
|
||||||
create_menu(NewMenu {
|
|
||||||
machine_name: machine_name.to_owned(),
|
|
||||||
titles: vec![(lang.clone(), title.to_owned())],
|
|
||||||
locked: false,
|
|
||||||
})
|
|
||||||
.await?
|
|
||||||
.id
|
|
||||||
};
|
|
||||||
|
|
||||||
upsert_menu_translation(menu_id, &lang, title, None).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// **< upsert_item >********************************************************************************
|
|
||||||
|
|
||||||
/// Inserta o actualiza un ítem identificado por `(provider, external_key)`.
|
|
||||||
///
|
|
||||||
/// Si el ítem ya existe, actualiza `url`, `weight` y `parent_id`; los campos `enabled`
|
|
||||||
/// y `expanded` modificados por el administrador no se sobreescriben. Las traducciones
|
|
||||||
/// se sincronizan para los idiomas incluidos en `input.titles`.
|
|
||||||
pub async fn upsert_item(
|
|
||||||
menu_id: i32,
|
|
||||||
provider: &str,
|
|
||||||
external_key: &str,
|
|
||||||
input: NewMenuItem,
|
|
||||||
) -> Result<(), MenuError> {
|
|
||||||
let existing = menu_item::Entity::find()
|
|
||||||
.filter(menu_item::Column::Provider.eq(provider))
|
|
||||||
.filter(menu_item::Column::ExternalKey.eq(external_key))
|
|
||||||
.one(dbconn())
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let parent_id = resolve_parent_id(menu_id, &input.parent_key).await;
|
|
||||||
let now = Utc::now().naive_utc();
|
|
||||||
|
|
||||||
let item_id = if let Some(row) = existing {
|
|
||||||
menu_item::ActiveModel {
|
|
||||||
id: Set(row.id),
|
|
||||||
url: Set(input.url),
|
|
||||||
weight: Set(input.weight),
|
|
||||||
parent_id: Set(parent_id),
|
|
||||||
updated_at: Set(now),
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
.update(dbconn())
|
|
||||||
.await?;
|
|
||||||
row.id
|
|
||||||
} else {
|
|
||||||
menu_item::Entity::insert(menu_item::ActiveModel {
|
|
||||||
id: NotSet,
|
|
||||||
menu_id: Set(menu_id),
|
|
||||||
parent_id: Set(parent_id),
|
|
||||||
url: Set(input.url),
|
|
||||||
weight: Set(input.weight),
|
|
||||||
enabled: Set(input.enabled),
|
|
||||||
expanded: Set(input.expanded),
|
|
||||||
provider: Set(provider.to_owned()),
|
|
||||||
external_key: Set(Some(external_key.to_owned())),
|
|
||||||
created_at: Set(now),
|
|
||||||
updated_at: Set(now),
|
|
||||||
})
|
|
||||||
.exec_with_returning(dbconn())
|
|
||||||
.await?
|
|
||||||
.id
|
|
||||||
};
|
|
||||||
|
|
||||||
for (lang, title) in input.titles {
|
|
||||||
upsert_item_translation(item_id, &lang, &title).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// **< Funciones internas >*************************************************************************
|
|
||||||
|
|
||||||
async fn resolve_parent_id(menu_id: i32, parent_key: &Option<String>) -> Option<i32> {
|
|
||||||
let key = parent_key.as_deref()?;
|
|
||||||
let row = menu_item::Entity::find()
|
|
||||||
.filter(menu_item::Column::MenuId.eq(menu_id))
|
|
||||||
.filter(menu_item::Column::ExternalKey.eq(key))
|
|
||||||
.one(dbconn())
|
|
||||||
.await
|
|
||||||
.ok()??;
|
|
||||||
Some(row.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn upsert_menu_translation(
|
|
||||||
menu_id: i32,
|
|
||||||
lang: &str,
|
|
||||||
title: &str,
|
|
||||||
description: Option<&str>,
|
|
||||||
) -> Result<(), MenuError> {
|
|
||||||
let existing = menu_translation::Entity::find()
|
|
||||||
.filter(menu_translation::Column::MenuId.eq(menu_id))
|
|
||||||
.filter(menu_translation::Column::Lang.eq(lang))
|
|
||||||
.one(dbconn())
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if existing.is_some() {
|
|
||||||
menu_translation::ActiveModel {
|
|
||||||
menu_id: Set(menu_id),
|
|
||||||
lang: Set(lang.to_owned()),
|
|
||||||
title: Set(title.to_owned()),
|
|
||||||
description: Set(description.map(str::to_owned)),
|
|
||||||
}
|
|
||||||
.update(dbconn())
|
|
||||||
.await?;
|
|
||||||
} else {
|
|
||||||
menu_translation::Entity::insert(menu_translation::ActiveModel {
|
|
||||||
menu_id: Set(menu_id),
|
|
||||||
lang: Set(lang.to_owned()),
|
|
||||||
title: Set(title.to_owned()),
|
|
||||||
description: Set(description.map(str::to_owned)),
|
|
||||||
})
|
|
||||||
.exec(dbconn())
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn upsert_item_translation(item_id: i32, lang: &str, title: &str) -> Result<(), MenuError> {
|
|
||||||
let existing = menu_item_translation::Entity::find()
|
|
||||||
.filter(menu_item_translation::Column::ItemId.eq(item_id))
|
|
||||||
.filter(menu_item_translation::Column::Lang.eq(lang))
|
|
||||||
.one(dbconn())
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if existing.is_some() {
|
|
||||||
menu_item_translation::ActiveModel {
|
|
||||||
item_id: Set(item_id),
|
|
||||||
lang: Set(lang.to_owned()),
|
|
||||||
title: Set(title.to_owned()),
|
|
||||||
}
|
|
||||||
.update(dbconn())
|
|
||||||
.await?;
|
|
||||||
} else {
|
|
||||||
menu_item_translation::Entity::insert(menu_item_translation::ActiveModel {
|
|
||||||
item_id: Set(item_id),
|
|
||||||
lang: Set(lang.to_owned()),
|
|
||||||
title: Set(title.to_owned()),
|
|
||||||
})
|
|
||||||
.exec(dbconn())
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
//! Sembrado inicial de menús e ítems declarados por extensiones.
|
|
||||||
//!
|
|
||||||
//! Se invoca desde `Extension::initialize()` después de aplicar las migraciones.
|
|
||||||
//! Es idempotente: puede ejecutarse en cada arranque sin duplicar datos.
|
|
||||||
|
|
||||||
use crate::action::{DeclareDefaultMenuItems, DeclareDefaultMenus, ItemBag, MenuDefs};
|
|
||||||
use crate::config::SETTINGS;
|
|
||||||
use crate::repo;
|
|
||||||
|
|
||||||
/// Ejecuta el sembrado completo:
|
|
||||||
///
|
|
||||||
/// 1. Recoge los menús declarados por extensiones vía `DeclareDefaultMenus`.
|
|
||||||
/// 2. Asegura que los menús configurados en `menu.default_menus` también existen.
|
|
||||||
/// 3. Para cada menú conocido, recoge y aplica los ítems declarados por extensiones.
|
|
||||||
pub(crate) async fn run() {
|
|
||||||
// Paso 1: recoger declaraciones de menú.
|
|
||||||
let mut defs = MenuDefs {
|
|
||||||
entries: Vec::new(),
|
|
||||||
};
|
|
||||||
DeclareDefaultMenus::dispatch(&mut defs);
|
|
||||||
|
|
||||||
// Paso 2: añadir los menús por defecto de configuración si no se declararon ya.
|
|
||||||
for name in &SETTINGS.default_menus {
|
|
||||||
if !defs.entries.iter().any(|(n, _)| n == name) {
|
|
||||||
let title = capitalize(name);
|
|
||||||
defs.entries.push((name.clone(), title));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Paso 3: crear cada menú que no exista y sembrar sus ítems.
|
|
||||||
for (machine_name, title) in &defs.entries {
|
|
||||||
repo::ensure_menu(machine_name, title).await.ok();
|
|
||||||
|
|
||||||
let Some(menu_id) =
|
|
||||||
repo::find_menu_model(&crate::tree::MenuKey::Name(machine_name.to_owned()))
|
|
||||||
.await
|
|
||||||
.map(|m| m.id)
|
|
||||||
else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut bag = ItemBag { items: Vec::new() };
|
|
||||||
DeclareDefaultMenuItems::dispatch(machine_name, &mut bag);
|
|
||||||
|
|
||||||
for item in bag.items {
|
|
||||||
if let Some(key) = item.external_key.clone() {
|
|
||||||
let provider = item.provider.clone();
|
|
||||||
repo::upsert_item(menu_id, &provider, &key, item).await.ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn capitalize(s: &str) -> String {
|
|
||||||
let mut c = s.chars();
|
|
||||||
match c.next() {
|
|
||||||
None => String::new(),
|
|
||||||
Some(f) => f.to_uppercase().to_string() + c.as_str(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,288 +0,0 @@
|
||||||
//! Tipos en memoria del árbol de menús y función de construcción.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use pagetop::prelude::*;
|
|
||||||
|
|
||||||
use crate::cache::FlatMenu;
|
|
||||||
use crate::entity::menu_item_translation;
|
|
||||||
use crate::{action, cache, repo};
|
|
||||||
|
|
||||||
// **< MenuKey >************************************************************************************
|
|
||||||
|
|
||||||
/// Selector para localizar un menú por `id` o por `machine_name`.
|
|
||||||
pub enum MenuKey {
|
|
||||||
Id(i32),
|
|
||||||
Name(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<i32> for MenuKey {
|
|
||||||
fn from(id: i32) -> Self {
|
|
||||||
MenuKey::Id(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&str> for MenuKey {
|
|
||||||
fn from(name: &str) -> Self {
|
|
||||||
MenuKey::Name(name.to_owned())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<String> for MenuKey {
|
|
||||||
fn from(name: String) -> Self {
|
|
||||||
MenuKey::Name(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **< URL de un ítem de menú >*********************************************************************
|
|
||||||
|
|
||||||
/// Construye la URL de un ítem de menú a partir del texto crudo guardado en BD.
|
|
||||||
///
|
|
||||||
/// Devuelve `None` para `<nolink>` o una cadena vacía (título de sección, sin enlace). El
|
|
||||||
/// `RoutePath` devuelto está sin resolver todavía: [`try_resolve_menu_url()`] es quien decide, en
|
|
||||||
/// el momento del renderizado, si debe pasar por [`Context::route()`] o dejarse tal cual.
|
|
||||||
pub fn menu_item_url(url: &str) -> Option<RoutePath> {
|
|
||||||
(!url.is_empty() && url != "<nolink>").then(|| RoutePath::new(url.to_owned()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resuelve la URL de un ítem de menú para renderizado.
|
|
||||||
///
|
|
||||||
/// Las internas pasan por [`Context::route()`] para preservar `lang` cuando corresponda; las
|
|
||||||
/// externas ([`RoutePath::is_external()`]) se devuelven tal cual, sin tocar el idioma, porque no
|
|
||||||
/// pertenecen al espacio de rutas de la aplicación. `None` (sin enlace) se propaga tal cual.
|
|
||||||
pub fn try_resolve_menu_url(url: Option<&RoutePath>, cx: &Context) -> Option<RoutePath> {
|
|
||||||
url.map(|path| {
|
|
||||||
if path.is_external() {
|
|
||||||
path.clone()
|
|
||||||
} else {
|
|
||||||
cx.route(path.path().to_owned())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// **< TreeOptions >********************************************************************************
|
|
||||||
|
|
||||||
/// Opciones de construcción del árbol de menú.
|
|
||||||
#[derive(Clone, Debug, Default)]
|
|
||||||
pub struct TreeOptions {
|
|
||||||
/// Profundidad máxima de nodos a incluir (`None` = sin límite).
|
|
||||||
pub max_depth: Option<u8>,
|
|
||||||
/// Si `true`, incluye también los ítems con `enabled = false`.
|
|
||||||
pub include_disabled: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
// **< MenuNode >***********************************************************************************
|
|
||||||
|
|
||||||
/// Nodo del árbol de menú. Sus campos son `pub` para que las acciones puedan modificarlos.
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct MenuNode {
|
|
||||||
pub item_id: i32,
|
|
||||||
pub title: String,
|
|
||||||
/// `None` si el ítem no tiene enlace (`<nolink>` o URL vacía): título de sección.
|
|
||||||
pub url: Option<RoutePath>,
|
|
||||||
pub weight: i32,
|
|
||||||
pub depth: u8,
|
|
||||||
pub enabled: bool,
|
|
||||||
pub expanded: bool,
|
|
||||||
pub provider: String,
|
|
||||||
pub external_key: Option<String>,
|
|
||||||
/// Atributos HTML adicionales inyectados por `DecorateMenuItem` en tiempo de render.
|
|
||||||
pub attrs: HashMap<String, String>,
|
|
||||||
pub children: Vec<MenuNode>,
|
|
||||||
/// `true` si este nodo o algún descendiente coincide con la ruta actual.
|
|
||||||
pub in_active_trail: bool,
|
|
||||||
/// `true` si este nodo coincide exactamente con la ruta actual.
|
|
||||||
pub is_active: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
// **< MenuTree >***********************************************************************************
|
|
||||||
|
|
||||||
/// Árbol completo de un menú, listo para renderizar.
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct MenuTree {
|
|
||||||
pub menu_id: i32,
|
|
||||||
pub machine_name: String,
|
|
||||||
pub title: String,
|
|
||||||
pub roots: Vec<MenuNode>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MenuTree {
|
|
||||||
/// Aplica `f` recursivamente a todos los nodos del árbol (post-orden).
|
|
||||||
pub fn walk_mut<F: FnMut(&mut MenuNode)>(&mut self, f: &mut F) {
|
|
||||||
walk_nodes_mut(&mut self.roots, f);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn walk_nodes_mut<F: FnMut(&mut MenuNode)>(nodes: &mut [MenuNode], f: &mut F) {
|
|
||||||
for node in nodes.iter_mut() {
|
|
||||||
walk_nodes_mut(&mut node.children, f);
|
|
||||||
f(node);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **< build_tree >*********************************************************************************
|
|
||||||
|
|
||||||
/// Construye el árbol del menú indicado aplicando caché, acciones y active trail.
|
|
||||||
///
|
|
||||||
/// Devuelve `None` si el menú no existe en la base de datos.
|
|
||||||
pub async fn build_tree(key: MenuKey, cx: &Context, opts: &TreeOptions) -> Option<MenuTree> {
|
|
||||||
let menu = repo::find_menu_model(&key).await?;
|
|
||||||
|
|
||||||
let flat = cache::get_or_load(menu.id, &menu.machine_name).await;
|
|
||||||
|
|
||||||
let lang = RequestLocale::from_request(cx.request())
|
|
||||||
.langid()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let menu_title = resolve_menu_title(menu.id, &lang).await;
|
|
||||||
|
|
||||||
let roots = build_nodes(&flat, None, 1, opts, &lang);
|
|
||||||
|
|
||||||
let mut tree = MenuTree {
|
|
||||||
menu_id: menu.id,
|
|
||||||
machine_name: menu.machine_name.clone(),
|
|
||||||
title: menu_title,
|
|
||||||
roots,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Acciones de alteración del árbol (filtros, reordenamientos, etc.).
|
|
||||||
action::AlterMenuTree::dispatch(&menu.machine_name, &mut tree, cx);
|
|
||||||
|
|
||||||
// Cálculo del active trail por coincidencia de URL.
|
|
||||||
let current_path = cx.request().map(|r| r.path()).unwrap_or("/");
|
|
||||||
compute_active_trail(&mut tree.roots, current_path);
|
|
||||||
|
|
||||||
// Acciones de resolución de active trail para rutas paramétricas o especiales.
|
|
||||||
action::ResolveActiveTrail::dispatch(&menu.machine_name, &mut tree, cx);
|
|
||||||
|
|
||||||
// Decoración de nodos (atributos HTML, iconos, badges...).
|
|
||||||
let name = menu.machine_name.clone();
|
|
||||||
tree.walk_mut(&mut |node| {
|
|
||||||
action::DecorateMenuItem::dispatch(&name, node, cx);
|
|
||||||
});
|
|
||||||
|
|
||||||
Some(tree)
|
|
||||||
}
|
|
||||||
|
|
||||||
// **< Funciones internas >*************************************************************************
|
|
||||||
|
|
||||||
pub(crate) fn build_nodes(
|
|
||||||
flat: &FlatMenu,
|
|
||||||
parent_id: Option<i32>,
|
|
||||||
depth: u8,
|
|
||||||
opts: &TreeOptions,
|
|
||||||
lang: &str,
|
|
||||||
) -> Vec<MenuNode> {
|
|
||||||
if opts.max_depth.map(|d| depth > d).unwrap_or(false) {
|
|
||||||
return vec![];
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut nodes: Vec<MenuNode> = flat
|
|
||||||
.items
|
|
||||||
.iter()
|
|
||||||
.filter(|m| m.parent_id == parent_id && (opts.include_disabled || m.enabled))
|
|
||||||
.map(|m| {
|
|
||||||
let translations = flat
|
|
||||||
.translations
|
|
||||||
.get(&m.id)
|
|
||||||
.map(Vec::as_slice)
|
|
||||||
.unwrap_or(&[]);
|
|
||||||
MenuNode {
|
|
||||||
item_id: m.id,
|
|
||||||
title: resolve_item_title(translations, lang),
|
|
||||||
url: menu_item_url(&m.url),
|
|
||||||
weight: m.weight,
|
|
||||||
depth,
|
|
||||||
enabled: m.enabled,
|
|
||||||
expanded: m.expanded,
|
|
||||||
provider: m.provider.clone(),
|
|
||||||
external_key: m.external_key.clone(),
|
|
||||||
attrs: HashMap::new(),
|
|
||||||
children: build_nodes(flat, Some(m.id), depth + 1, opts, lang),
|
|
||||||
in_active_trail: false,
|
|
||||||
is_active: false,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
nodes.sort_by_key(|n| n.weight);
|
|
||||||
nodes
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn compute_active_trail(nodes: &mut [MenuNode], current_path: &str) -> bool {
|
|
||||||
let mut any_active = false;
|
|
||||||
for node in nodes.iter_mut() {
|
|
||||||
let self_active = node
|
|
||||||
.url
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|p| !p.is_external() && p.path() == current_path);
|
|
||||||
let child_active = compute_active_trail(&mut node.children, current_path);
|
|
||||||
node.is_active = self_active;
|
|
||||||
node.in_active_trail = self_active || child_active;
|
|
||||||
if node.in_active_trail {
|
|
||||||
any_active = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
any_active
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resuelve el título del menú desde la BD aplicando la cadena de fallback de idioma.
|
|
||||||
async fn resolve_menu_title(menu_id: i32, lang: &str) -> String {
|
|
||||||
use crate::entity::menu_translation;
|
|
||||||
use pagetop_seaorm::db::{ColumnTrait, EntityTrait, QueryFilter, dbconn};
|
|
||||||
|
|
||||||
let rows = menu_translation::Entity::find()
|
|
||||||
.filter(menu_translation::Column::MenuId.eq(menu_id))
|
|
||||||
.all(dbconn())
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
resolve_title_from(
|
|
||||||
rows.iter().map(|r| (r.lang.as_str(), r.title.as_str())),
|
|
||||||
lang,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resuelve el título de un ítem aplicando la cadena de fallback de idioma.
|
|
||||||
fn resolve_item_title(translations: &[menu_item_translation::Model], lang: &str) -> String {
|
|
||||||
resolve_title_from(
|
|
||||||
translations
|
|
||||||
.iter()
|
|
||||||
.map(|t| (t.lang.as_str(), t.title.as_str())),
|
|
||||||
lang,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cadena de fallback: exacto -> base del lang -> idioma por defecto -> base del defecto ->
|
|
||||||
// cualquiera.
|
|
||||||
fn resolve_title_from<'a>(
|
|
||||||
translations: impl Iterator<Item = (&'a str, &'a str)> + Clone,
|
|
||||||
lang: &str,
|
|
||||||
) -> String {
|
|
||||||
let base_lang = lang.split('-').next().unwrap_or(lang);
|
|
||||||
let default_lang = Locale::default_langid().to_string();
|
|
||||||
let base_default = default_lang.split('-').next().unwrap_or("").to_owned();
|
|
||||||
|
|
||||||
let candidates = [
|
|
||||||
lang,
|
|
||||||
base_lang,
|
|
||||||
default_lang.as_str(),
|
|
||||||
base_default.as_str(),
|
|
||||||
];
|
|
||||||
|
|
||||||
for candidate in candidates {
|
|
||||||
if candidate.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Some((_, title)) = translations.clone().find(|(l, _)| *l == candidate) {
|
|
||||||
return title.to_owned();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cualquier traducción disponible como último recurso.
|
|
||||||
translations
|
|
||||||
.clone()
|
|
||||||
.next()
|
|
||||||
.map(|(_, t)| t.to_owned())
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue