Compare commits
No commits in common. "043873a9541319636ebfd98ba5f646acd6fade26" and "c0a5a8c3ab8313360650197aa94a5d43b822979d" have entirely different histories.
043873a954
...
c0a5a8c3ab
43 changed files with 2 additions and 3810 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -1884,8 +1884,10 @@ dependencies = [
|
|||
"chrono",
|
||||
"pagetop",
|
||||
"pagetop-seaorm",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
[package]
|
||||
name = "pagetop-admin"
|
||||
description = "Panel de administración extensible para PageTop."
|
||||
version = "0.1.0"
|
||||
categories = ["web-programming", "development-tools"]
|
||||
keywords = ["pagetop", "admin", "cms", "settings", "ssr"]
|
||||
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
pagetop.workspace = true
|
||||
pagetop-seaorm.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
|
@ -1,291 +0,0 @@
|
|||
//! Tipos de acción que `pagetop-admin` expone para que otras extensiones registren
|
||||
//! secciones, páginas, tareas y acciones en el panel de administración.
|
||||
//!
|
||||
//! El flujo de `registry::build()` despacha estas acciones en este orden:
|
||||
//!
|
||||
//! 1. `DeclareAdminSections` - secciones (agrupaciones del sidebar).
|
||||
//! 2. `DeclareAdminPages` - páginas del panel.
|
||||
//! 3. `DeclareAdminTasks` - tareas (pestañas) locales por página.
|
||||
//! 4. `DeclareAdminActions` - acciones locales (botones) por página.
|
||||
|
||||
use pagetop::prelude::*;
|
||||
|
||||
use crate::registry::{AdminAction, AdminPage, AdminSection, AdminTask};
|
||||
|
||||
// **< Tipo de callbacks >**************************************************************************
|
||||
|
||||
pub type FnSectionBag = fn(&mut SectionBag);
|
||||
pub type FnPageBag = fn(&mut PageBag);
|
||||
pub type FnTaskBag = fn(&mut TaskBag);
|
||||
pub type FnActionBag = fn(&mut ActionBag);
|
||||
|
||||
// **< Bolsas de declaración >**********************************************************************
|
||||
|
||||
/// Bolsa de secciones para [`DeclareAdminSections`].
|
||||
pub struct SectionBag {
|
||||
pub(crate) sections: Vec<AdminSection>,
|
||||
}
|
||||
|
||||
impl SectionBag {
|
||||
/// Añade una sección al registro.
|
||||
pub fn add(&mut self, section: AdminSection) {
|
||||
self.sections.push(section);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bolsa de páginas para [`DeclareAdminPages`].
|
||||
pub struct PageBag {
|
||||
pub(crate) pages: Vec<AdminPage>,
|
||||
}
|
||||
|
||||
impl PageBag {
|
||||
/// Añade una página al registro.
|
||||
pub fn add(&mut self, page: AdminPage) {
|
||||
self.pages.push(page);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bolsa de tareas para [`DeclareAdminTasks`].
|
||||
pub struct TaskBag {
|
||||
pub(crate) tasks: Vec<AdminTask>,
|
||||
}
|
||||
|
||||
impl TaskBag {
|
||||
/// Añade una tarea local al registro.
|
||||
pub fn add(&mut self, task: AdminTask) {
|
||||
self.tasks.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bolsa de acciones locales para [`DeclareAdminActions`].
|
||||
pub struct ActionBag {
|
||||
pub(crate) actions: Vec<AdminAction>,
|
||||
}
|
||||
|
||||
impl ActionBag {
|
||||
/// Añade una acción local al registro.
|
||||
pub fn add(&mut self, action: AdminAction) {
|
||||
self.actions.push(action);
|
||||
}
|
||||
}
|
||||
|
||||
// **< DeclareAdminSections >***********************************************************************
|
||||
|
||||
/// Acción para declarar secciones del panel de administración.
|
||||
///
|
||||
/// Se despacha durante `registry::build()` (dentro de `initialize()`). El callback
|
||||
/// recibe un [`SectionBag`] y puede llamar a `add()` para registrar secciones.
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// use pagetop::locale::Lc;
|
||||
/// use pagetop_admin::action::{DeclareAdminSections, SectionBag};
|
||||
/// use pagetop_admin::registry::AdminSection;
|
||||
///
|
||||
/// fn declare_sections(bag: &mut SectionBag) {
|
||||
/// bag.add(AdminSection {
|
||||
/// key: "tools".to_owned(),
|
||||
/// path: "/admin/tools".to_owned(),
|
||||
/// title: Lc::n("Tools"),
|
||||
/// permission: None,
|
||||
/// weight: 60,
|
||||
/// });
|
||||
/// }
|
||||
/// // En Extension::actions():
|
||||
/// // DeclareAdminSections::new(declare_sections)
|
||||
/// ```
|
||||
pub struct DeclareAdminSections {
|
||||
f: FnSectionBag,
|
||||
weight: Weight,
|
||||
}
|
||||
|
||||
impl ActionDispatcher for DeclareAdminSections {
|
||||
fn weight(&self) -> Weight {
|
||||
self.weight
|
||||
}
|
||||
}
|
||||
|
||||
impl DeclareAdminSections {
|
||||
pub fn new(f: FnSectionBag) -> Self {
|
||||
DeclareAdminSections { f, weight: 0 }
|
||||
}
|
||||
|
||||
pub fn with_weight(mut self, w: Weight) -> Self {
|
||||
self.weight = w;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn dispatch(bag: &mut SectionBag) {
|
||||
dispatch_actions(
|
||||
&ActionKey::new(UniqueId::of::<Self>(), None, None),
|
||||
|action: &Self| (action.f)(bag),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// **< DeclareAdminPages >**************************************************************************
|
||||
|
||||
/// Acción para declarar páginas del panel de administración.
|
||||
///
|
||||
/// Se despacha durante `registry::build()`. El callback recibe un [`PageBag`].
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// use pagetop::locale::Lc;
|
||||
/// use pagetop_admin::action::{DeclareAdminPages, PageBag};
|
||||
/// use pagetop_admin::registry::{AdminPage, AdminPageKind};
|
||||
///
|
||||
/// fn declare_pages(bag: &mut PageBag) {
|
||||
/// bag.add(AdminPage {
|
||||
/// path: "/admin/tools/export".to_owned(),
|
||||
/// section: "tools".to_owned(),
|
||||
/// title: Lc::n("Export"),
|
||||
/// description: Some(Lc::n("Export site data.")),
|
||||
/// weight: 0,
|
||||
/// permission: None,
|
||||
/// kind: AdminPageKind::View,
|
||||
/// });
|
||||
/// }
|
||||
/// // En Extension::actions():
|
||||
/// // DeclareAdminPages::new(declare_pages)
|
||||
/// ```
|
||||
pub struct DeclareAdminPages {
|
||||
f: FnPageBag,
|
||||
weight: Weight,
|
||||
}
|
||||
|
||||
impl ActionDispatcher for DeclareAdminPages {
|
||||
fn weight(&self) -> Weight {
|
||||
self.weight
|
||||
}
|
||||
}
|
||||
|
||||
impl DeclareAdminPages {
|
||||
pub fn new(f: FnPageBag) -> Self {
|
||||
DeclareAdminPages { f, weight: 0 }
|
||||
}
|
||||
|
||||
pub fn with_weight(mut self, w: Weight) -> Self {
|
||||
self.weight = w;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn dispatch(bag: &mut PageBag) {
|
||||
dispatch_actions(
|
||||
&ActionKey::new(UniqueId::of::<Self>(), None, None),
|
||||
|action: &Self| (action.f)(bag),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// **< DeclareAdminTasks >**************************************************************************
|
||||
|
||||
/// Acción para declarar tareas (pestañas) locales en páginas del panel.
|
||||
///
|
||||
/// Se despacha durante `registry::build()`. El callback recibe un [`TaskBag`].
|
||||
/// Cada [`AdminTask`] indica en `parent_path` la página a la que pertenece.
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// use pagetop::locale::Lc;
|
||||
/// use pagetop_admin::action::{DeclareAdminTasks, TaskBag};
|
||||
/// use pagetop_admin::registry::AdminTask;
|
||||
///
|
||||
/// fn declare_tasks(bag: &mut TaskBag) {
|
||||
/// bag.add(AdminTask {
|
||||
/// path: "/admin/tools/export/csv".to_owned(),
|
||||
/// parent_path: "/admin/tools/export".to_owned(),
|
||||
/// title: Lc::n("CSV"),
|
||||
/// weight: 0,
|
||||
/// is_default: true,
|
||||
/// permission: None,
|
||||
/// });
|
||||
/// }
|
||||
/// // En Extension::actions():
|
||||
/// // DeclareAdminTasks::new(declare_tasks)
|
||||
/// ```
|
||||
pub struct DeclareAdminTasks {
|
||||
f: FnTaskBag,
|
||||
weight: Weight,
|
||||
}
|
||||
|
||||
impl ActionDispatcher for DeclareAdminTasks {
|
||||
fn weight(&self) -> Weight {
|
||||
self.weight
|
||||
}
|
||||
}
|
||||
|
||||
impl DeclareAdminTasks {
|
||||
pub fn new(f: FnTaskBag) -> Self {
|
||||
DeclareAdminTasks { f, weight: 0 }
|
||||
}
|
||||
|
||||
pub fn with_weight(mut self, w: Weight) -> Self {
|
||||
self.weight = w;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn dispatch(bag: &mut TaskBag) {
|
||||
dispatch_actions(
|
||||
&ActionKey::new(UniqueId::of::<Self>(), None, None),
|
||||
|action: &Self| (action.f)(bag),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// **< DeclareAdminActions >************************************************************************
|
||||
|
||||
/// Acción para declarar acciones locales (botones de acción) en páginas del panel.
|
||||
///
|
||||
/// Se despacha durante `registry::build()`. El callback recibe un [`ActionBag`].
|
||||
/// Cada [`AdminAction`] indica en `for_path` la página en la que aparece el botón.
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// use pagetop::locale::Lc;
|
||||
/// use pagetop_admin::action::{DeclareAdminActions, ActionBag};
|
||||
/// use pagetop_admin::registry::AdminAction;
|
||||
///
|
||||
/// fn declare_actions(bag: &mut ActionBag) {
|
||||
/// bag.add(AdminAction {
|
||||
/// url: "/admin/tools/export/new".to_owned(),
|
||||
/// for_path: "/admin/tools/export".to_owned(),
|
||||
/// title: Lc::n("Add export"),
|
||||
/// weight: 0,
|
||||
/// });
|
||||
/// }
|
||||
/// // En Extension::actions():
|
||||
/// // DeclareAdminActions::new(declare_actions)
|
||||
/// ```
|
||||
pub struct DeclareAdminActions {
|
||||
f: FnActionBag,
|
||||
weight: Weight,
|
||||
}
|
||||
|
||||
impl ActionDispatcher for DeclareAdminActions {
|
||||
fn weight(&self) -> Weight {
|
||||
self.weight
|
||||
}
|
||||
}
|
||||
|
||||
impl DeclareAdminActions {
|
||||
pub fn new(f: FnActionBag) -> Self {
|
||||
DeclareAdminActions { f, weight: 0 }
|
||||
}
|
||||
|
||||
pub fn with_weight(mut self, w: Weight) -> Self {
|
||||
self.weight = w;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn dispatch(bag: &mut ActionBag) {
|
||||
dispatch_actions(
|
||||
&ActionKey::new(UniqueId::of::<Self>(), None, None),
|
||||
|action: &Self| (action.f)(bag),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
//! Componentes de renderizado del panel de administración.
|
||||
|
||||
mod admin_frame;
|
||||
mod admin_menu;
|
||||
mod config_form;
|
||||
|
||||
pub use admin_frame::AdminFrame;
|
||||
pub use admin_menu::AdminMenu;
|
||||
pub use config_form::ConfigForm;
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
use pagetop::base::component::breadcrumb;
|
||||
use pagetop::prelude::*;
|
||||
|
||||
use crate::LOCALES_ADMIN;
|
||||
use crate::registry;
|
||||
|
||||
/// Componente de layout principal de una página del panel de administración.
|
||||
///
|
||||
/// Renderiza breadcrumb, encabezado (título + acciones locales), tareas locales (pestañas) y el
|
||||
/// contenido de la página. No incluye ningún menú de navegación: mostrar las secciones del panel
|
||||
/// (el "sidebar" o el "menú superior", según el tema) es responsabilidad del tema que intercepte
|
||||
/// [`CoreTemplates::Admin`](pagetop::core::theme::CoreTemplates::Admin) -- por ejemplo
|
||||
/// `pagetop-bootsier` -- leyendo directamente [`crate::registry::global()`]. El tema básico de
|
||||
/// PageTop no lo hace, y una página de administración sigue siendo completamente navegable sin él,
|
||||
/// a través del *dashboard* (`/admin`), las páginas de sección y este mismo breadcrumb.
|
||||
///
|
||||
/// ```html
|
||||
/// <nav class="admin-breadcrumb">...</nav>
|
||||
/// <header class="admin-header">
|
||||
/// <h1 class="admin-page-title">Title</h1>
|
||||
/// <ul class="admin-actions-list">...</ul>
|
||||
/// </header>
|
||||
/// <nav class="admin-local-tasks">...</nav>
|
||||
/// <div class="admin-content">...</div>
|
||||
/// ```
|
||||
///
|
||||
/// El contenido son sus propios hijos ([`Children`]), igual que en cualquier otro componente
|
||||
/// contenedor (p. ej. [`Block`](pagetop::base::component::Block)): admite tantos `with_child(...)`
|
||||
/// como haga falta, cada uno renderizado en el ciclo normal de componentes.
|
||||
#[derive(AutoDefault, Clone, Debug, Getters)]
|
||||
pub struct AdminFrame {
|
||||
/// Devuelve el título de la página.
|
||||
title: Lc,
|
||||
/// Devuelve la lista de componentes hijo de la página.
|
||||
children: Children,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Component for AdminFrame {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||
let path = cx
|
||||
.request()
|
||||
.map(|r| r.path().to_string())
|
||||
.unwrap_or_else(|| "/".to_string());
|
||||
|
||||
let breadcrumbs = render_breadcrumbs(cx, &path).await;
|
||||
let local_tasks = render_local_tasks(cx, &path);
|
||||
let local_acts = render_local_actions(cx, &path);
|
||||
let body = self.children().render(cx).await;
|
||||
|
||||
Ok(html! {
|
||||
(breadcrumbs)
|
||||
header.admin-header {
|
||||
h1.admin-page-title { (self.title().using(cx)) }
|
||||
(local_acts)
|
||||
}
|
||||
@if !local_tasks.0.is_empty() {
|
||||
nav.admin-local-tasks { (local_tasks) }
|
||||
}
|
||||
div.admin-content {
|
||||
(body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl AdminFrame {
|
||||
/// Establece el título de la página.
|
||||
#[builder_fn]
|
||||
pub fn with_title(mut self, title: Lc) -> Self {
|
||||
self.title = title;
|
||||
self
|
||||
}
|
||||
|
||||
/// Añade un componente hijo al contenido de la página.
|
||||
#[builder_fn]
|
||||
pub fn with_child(mut self, op: impl Into<ChildOp>) -> Self {
|
||||
self.children.alter_child(op.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// **< Breadcrumbs >********************************************************************************
|
||||
|
||||
async fn render_breadcrumbs(cx: &mut Context, current_path: &str) -> Markup {
|
||||
let reg = registry::global();
|
||||
let base = crate::ADMIN_BASE_PATH;
|
||||
|
||||
let home_label = Lc::t("admin-breadcrumb-home", &LOCALES_ADMIN);
|
||||
let mut bc = Breadcrumb::new().with_crumb(breadcrumb::Crumb::new(home_label, base));
|
||||
|
||||
if let Some(page) = reg.pages().get(current_path) {
|
||||
if let Some(section) = reg.sections().get(&page.section) {
|
||||
bc = bc.with_crumb(breadcrumb::Crumb::new(
|
||||
section.title.clone(),
|
||||
section.path.as_str(),
|
||||
));
|
||||
}
|
||||
bc = bc.with_crumb(breadcrumb::Crumb::current(page.title.clone()));
|
||||
} else if let Some(section) = reg.sections().values().find(|s| s.path == current_path) {
|
||||
// La ruta actual es la propia sección (su página de aterrizaje), no una `AdminPage`
|
||||
// registrada dentro de ella.
|
||||
bc = bc.with_crumb(breadcrumb::Crumb::current(section.title.clone()));
|
||||
}
|
||||
|
||||
bc.render(cx).await
|
||||
}
|
||||
|
||||
// **< Tareas locales >*****************************************************************************
|
||||
|
||||
fn render_local_tasks(cx: &Context, current_path: &str) -> Markup {
|
||||
let reg = registry::global();
|
||||
let tasks = reg.tasks_for(current_path);
|
||||
if tasks.is_empty() {
|
||||
return html! {};
|
||||
}
|
||||
html! {
|
||||
ul.admin-tasks-list {
|
||||
@for task in tasks {
|
||||
@let is_active = current_path == task.path;
|
||||
li class=(if is_active { "admin-task admin-task-active" } else { "admin-task" }) {
|
||||
a href=(cx.route(task.path.as_str())) { (task.title.using(cx)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **< Acciones locales >***************************************************************************
|
||||
|
||||
fn render_local_actions(cx: &Context, current_path: &str) -> Markup {
|
||||
let reg = registry::global();
|
||||
let actions = reg.actions_for(current_path);
|
||||
if actions.is_empty() {
|
||||
return html! {};
|
||||
}
|
||||
html! {
|
||||
ul.admin-actions-list {
|
||||
@for action in actions {
|
||||
li.admin-action {
|
||||
a.admin-action-link href=(cx.route(action.url.as_str())) { (action.title.using(cx)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
use pagetop::prelude::*;
|
||||
|
||||
use crate::registry;
|
||||
|
||||
/// Componente que renderiza el menú de secciones visibles del panel de administración.
|
||||
///
|
||||
/// Construye el [`Nav`] en cada petición a partir de [`registry::admin_menu()`], ya filtrado por
|
||||
/// el usuario de la petición actual. Pensado para que un tema lo registre en su propia región de
|
||||
/// navegación (p. ej. `pagetop-bootsier` lo añade a su sidebar) e intercepte `Nav`/`nav::Item` en
|
||||
/// [`Theme::handle_component()`](pagetop::core::theme::Theme::handle_component) si quiere darle un
|
||||
/// aspecto propio; sin intercepción, se renderiza con el marcado por defecto de [`Nav`].
|
||||
///
|
||||
/// Sólo se renderiza en páginas creadas con
|
||||
/// [`Page::admin()`](pagetop::response::Page::admin) (plantilla
|
||||
/// [`CoreTemplates::Admin`](pagetop::core::theme::CoreTemplates::Admin)). Es necesario comprobarlo
|
||||
/// explícitamente porque, si se registra en una región de propósito general como
|
||||
/// [`CoreRegions::Aside`](pagetop::core::theme::CoreRegions::Aside), se renderizaría también en
|
||||
/// páginas `Standard` si no se autolimitara.
|
||||
#[derive(AutoDefault, Clone, Debug)]
|
||||
pub struct AdminMenu;
|
||||
|
||||
#[async_trait]
|
||||
impl Component for AdminMenu {
|
||||
fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||
if !matches!(
|
||||
cx.template().downcast_ref::<CoreTemplates>(),
|
||||
Some(CoreTemplates::Admin)
|
||||
) {
|
||||
return Ok(html! {});
|
||||
}
|
||||
Ok(registry::admin_menu(cx).render(cx).await)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,226 +0,0 @@
|
|||
use pagetop::prelude::*;
|
||||
|
||||
use crate::LOCALES_ADMIN;
|
||||
use crate::settings::{SettingFieldType, SettingsSchema, get_or};
|
||||
|
||||
/// Componente que renderiza un formulario de configuración persistente.
|
||||
///
|
||||
/// Genera campos HTML a partir de un [`SettingsSchema`] y carga los valores
|
||||
/// actuales desde `settings`. El POST es procesado por el handler interno
|
||||
/// `config_form_post`.
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// use pagetop::prelude::*;
|
||||
/// use pagetop_admin::component::{AdminFrame, ConfigForm};
|
||||
/// use pagetop_admin::settings::{SettingField, SettingsSchema};
|
||||
///
|
||||
/// async fn settings_handler(request: HttpRequest) -> Result<Markup, ErrorPage> {
|
||||
/// let schema = SettingsSchema::new("myapp.general")
|
||||
/// .with_field(SettingField::text("site_name", "Site name").with_required(true));
|
||||
///
|
||||
/// let title = "General settings";
|
||||
/// let mut form = ConfigForm::with_schema(schema);
|
||||
/// let mut cx = Context::new(request.clone());
|
||||
/// let content = form.render(&mut cx).await;
|
||||
///
|
||||
/// Page::admin(request)
|
||||
/// .with_title(Lc::n(title))
|
||||
/// .with_child(
|
||||
/// AdminFrame::new()
|
||||
/// .with_title(Lc::n(title))
|
||||
/// .with_child(Html::with(move |_| content.clone())),
|
||||
/// )
|
||||
/// .render()
|
||||
/// .await
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(AutoDefault, Clone, Debug, Getters)]
|
||||
pub struct ConfigForm {
|
||||
/// Devuelve el esquema del formulario, si se ha establecido uno.
|
||||
schema: Option<SettingsSchema>,
|
||||
/// Devuelve la ruta de destino del formulario, si se ha personalizado.
|
||||
action_path: Option<Route>,
|
||||
/// Devuelve si el último envío se guardó correctamente.
|
||||
saved: bool,
|
||||
/// Devuelve si el último envío produjo un error al guardar.
|
||||
error: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Component for ConfigForm {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||
let Some(schema) = self.schema() else {
|
||||
return Ok(html! {});
|
||||
};
|
||||
|
||||
let action = match self.action_path() {
|
||||
Some(route) => route.resolve(cx),
|
||||
None => cx.route(cx.request().map(|r| r.path()).unwrap_or("/")),
|
||||
};
|
||||
|
||||
let save_label = Lc::t("config-save-label", &LOCALES_ADMIN)
|
||||
.lookup(cx)
|
||||
.unwrap_or_else(|| "Save configuration".into());
|
||||
|
||||
let saved_msg = Lc::t("config-saved-ok", &LOCALES_ADMIN)
|
||||
.lookup(cx)
|
||||
.unwrap_or_else(|| "Configuration saved.".into());
|
||||
|
||||
let error_msg = Lc::t("config-saved-error", &LOCALES_ADMIN)
|
||||
.lookup(cx)
|
||||
.unwrap_or_else(|| "Could not save configuration.".into());
|
||||
|
||||
// Pre-computa los valores de BD para cada campo antes del macro html! (que es síncrono).
|
||||
struct FieldVals {
|
||||
raw_val: String,
|
||||
num_val: String,
|
||||
checked: bool,
|
||||
}
|
||||
let mut prepared: Vec<FieldVals> = Vec::with_capacity(schema.fields().len());
|
||||
for field in schema.fields() {
|
||||
let key = schema.key_for(field.name());
|
||||
let raw_val =
|
||||
get_or::<String>(&key, field.default_value().cloned().unwrap_or_default()).await;
|
||||
let num_val = get_or::<f64>(&key, 0.0).await.to_string();
|
||||
let checked = get_or::<bool>(&key, false).await;
|
||||
prepared.push(FieldVals {
|
||||
raw_val,
|
||||
num_val,
|
||||
checked,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(html! {
|
||||
@if *self.saved() {
|
||||
div.admin-message."admin-message-ok" { (&saved_msg) }
|
||||
}
|
||||
@if *self.error() {
|
||||
div.admin-message."admin-message-error" { (&error_msg) }
|
||||
}
|
||||
form.admin-config-form method="post" action=(action) {
|
||||
@for (i, field) in schema.fields().iter().enumerate() {
|
||||
@let pf = &prepared[i];
|
||||
div.admin-form-field {
|
||||
label.admin-form-label for=(field.name()) { (field.label()) }
|
||||
@match field.field_type() {
|
||||
SettingFieldType::Text { max_length } => {
|
||||
@if let Some(max) = max_length {
|
||||
input.admin-form-input
|
||||
type="text"
|
||||
id=(field.name())
|
||||
name=(field.name())
|
||||
value=(&pf.raw_val)
|
||||
maxlength=(max)
|
||||
required[*field.required()];
|
||||
} @else {
|
||||
input.admin-form-input
|
||||
type="text"
|
||||
id=(field.name())
|
||||
name=(field.name())
|
||||
value=(&pf.raw_val)
|
||||
required[*field.required()];
|
||||
}
|
||||
}
|
||||
SettingFieldType::Number { min, max } => {
|
||||
@if let (Some(lo), Some(hi)) = (min, max) {
|
||||
input.admin-form-input
|
||||
type="number"
|
||||
id=(field.name())
|
||||
name=(field.name())
|
||||
value=(&pf.num_val)
|
||||
min=(lo)
|
||||
max=(hi)
|
||||
required[*field.required()];
|
||||
} @else if let Some(lo) = min {
|
||||
input.admin-form-input
|
||||
type="number"
|
||||
id=(field.name())
|
||||
name=(field.name())
|
||||
value=(&pf.num_val)
|
||||
min=(lo)
|
||||
required[*field.required()];
|
||||
} @else if let Some(hi) = max {
|
||||
input.admin-form-input
|
||||
type="number"
|
||||
id=(field.name())
|
||||
name=(field.name())
|
||||
value=(&pf.num_val)
|
||||
max=(hi)
|
||||
required[*field.required()];
|
||||
} @else {
|
||||
input.admin-form-input
|
||||
type="number"
|
||||
id=(field.name())
|
||||
name=(field.name())
|
||||
value=(&pf.num_val)
|
||||
required[*field.required()];
|
||||
}
|
||||
}
|
||||
SettingFieldType::Boolean => {
|
||||
input.admin-form-checkbox
|
||||
type="checkbox"
|
||||
id=(field.name())
|
||||
name=(field.name())
|
||||
value="true"
|
||||
checked[pf.checked];
|
||||
}
|
||||
SettingFieldType::Select { options } => {
|
||||
select.admin-form-select
|
||||
id=(field.name())
|
||||
name=(field.name())
|
||||
required[*field.required()]
|
||||
{
|
||||
@for (val, label) in options {
|
||||
@if &pf.raw_val == val {
|
||||
option value=(val) selected { (label) }
|
||||
} @else {
|
||||
option value=(val) { (label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@if let Some(help) = field.help_text() {
|
||||
small.admin-form-help { (help) }
|
||||
}
|
||||
}
|
||||
}
|
||||
div.admin-form-actions {
|
||||
button.admin-btn."admin-btn-primary" type="submit" {
|
||||
(&save_label)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigForm {
|
||||
/// Crea el componente con el [`SettingsSchema`] dado.
|
||||
pub fn with_schema(schema: SettingsSchema) -> Self {
|
||||
ConfigForm {
|
||||
schema: Some(schema),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub fn with_action_path(mut self, v: impl Into<Option<Route>>) -> Self {
|
||||
if let Some(v) = v.into() {
|
||||
self.action_path = Some(v);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_saved(mut self, saved: bool, error: bool) -> Self {
|
||||
self.saved = saved;
|
||||
self.error = error;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
//! Entidades SeaORM de `pagetop-admin`.
|
||||
|
||||
pub(crate) mod setting;
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
use pagetop_seaorm::db::*;
|
||||
|
||||
use pagetop::datetime::NaiveDateTime;
|
||||
|
||||
/// Entidad SeaORM para la tabla `settings`.
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "settings")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub key: String,
|
||||
pub scope: String,
|
||||
pub value: String,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub updated_by: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
use thiserror::Error;
|
||||
|
||||
/// Errores que puede producir la extensión `pagetop-admin`.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AdminError {
|
||||
#[error("settings DB error: {0}")]
|
||||
DbError(#[from] pagetop_seaorm::db::DbErr),
|
||||
|
||||
#[error("settings serialization error: {0}")]
|
||||
SerializeError(#[from] serde_json::Error),
|
||||
|
||||
#[error("setting key not found: {0}")]
|
||||
NotFound(String),
|
||||
}
|
||||
|
|
@ -1,207 +0,0 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use pagetop::prelude::*;
|
||||
|
||||
use crate::LOCALES_ADMIN;
|
||||
use crate::component::{AdminFrame, ConfigForm};
|
||||
use crate::registry::{self, AdminPageKind, AdminPermission, AdminSection};
|
||||
use crate::settings;
|
||||
|
||||
// **< Dashboard >**********************************************************************************
|
||||
|
||||
/// GET /admin - Dashboard de administración: lista todas las secciones disponibles.
|
||||
pub async fn dashboard(request: HttpRequest) -> Result<Markup, ErrorPage> {
|
||||
let reg = registry::global();
|
||||
let cx = Context::new(request.clone());
|
||||
let sections = reg.ordered_sections();
|
||||
|
||||
let title = Lc::t("dashboard-title", &LOCALES_ADMIN);
|
||||
let content = render_sections(&cx, §ions);
|
||||
|
||||
Page::admin(request)
|
||||
.with_title(title.clone())
|
||||
.with_child(
|
||||
AdminFrame::new()
|
||||
.with_title(title)
|
||||
.with_child(Html::with(move |_| content.clone())),
|
||||
)
|
||||
.render()
|
||||
.await
|
||||
}
|
||||
|
||||
// **< Sección >************************************************************************************
|
||||
|
||||
/// GET /admin/{section} - Página de aterrizaje de una sección: lista sus páginas.
|
||||
///
|
||||
/// Sólo se monta (ver `configure_router()` en `lib.rs`) para las secciones cuyo `path` no coincida
|
||||
/// con ninguna [`AdminPage`](crate::registry::AdminPage) registrada explícitamente por una
|
||||
/// extensión; si una extensión reclama esa ruta con su propia página, esa página gana y este
|
||||
/// handler nunca llega a montarse para ella.
|
||||
pub async fn section_page(request: HttpRequest) -> Result<Markup, ErrorPage> {
|
||||
let path = request.path().to_owned();
|
||||
let reg = registry::global();
|
||||
let section = reg
|
||||
.sections()
|
||||
.values()
|
||||
.find(|s| s.path == path)
|
||||
.ok_or_else(|| ErrorPage::NotFound(Some(request.clone())))?;
|
||||
|
||||
require_permission(
|
||||
&request,
|
||||
section.permission.unwrap_or(&AdminPermission::Access),
|
||||
)?;
|
||||
|
||||
let cx = Context::new(request.clone());
|
||||
let title = section.title.clone();
|
||||
let content = render_sections(&cx, &[section]);
|
||||
|
||||
Page::admin(request)
|
||||
.with_title(title.clone())
|
||||
.with_child(
|
||||
AdminFrame::new()
|
||||
.with_title(title)
|
||||
.with_child(Html::with(move |_| content.clone())),
|
||||
)
|
||||
.render()
|
||||
.await
|
||||
}
|
||||
|
||||
// **< Render compartido >**************************************************************************
|
||||
|
||||
// Rejilla de secciones con sus páginas (título + descripción). La usan tanto `dashboard()` (todas
|
||||
// las secciones) como `section_page()` (una sola), filtrando siempre por permiso del usuario actual.
|
||||
fn render_sections(cx: &Context, sections: &[&AdminSection]) -> Markup {
|
||||
let reg = registry::global();
|
||||
html! {
|
||||
div.admin-dashboard-sections {
|
||||
@for section in sections {
|
||||
@if section.is_visible(cx) {
|
||||
@let pages: Vec<_> = reg
|
||||
.pages_for_section(§ion.key)
|
||||
.into_iter()
|
||||
.filter(|p| p.is_accessible(cx))
|
||||
.collect();
|
||||
@if !pages.is_empty() {
|
||||
div.admin-dashboard-section {
|
||||
h2.admin-dashboard-section-title {
|
||||
a href=(cx.route(section.path.as_str())) { (section.title.using(cx)) }
|
||||
}
|
||||
ul.admin-dashboard-section-links {
|
||||
@for page in pages {
|
||||
li.admin-dashboard-link {
|
||||
a href=(cx.route(page.path.as_str())) { (page.title.using(cx)) }
|
||||
@if let Some(description) = &page.description {
|
||||
span.admin-dashboard-link-desc {
|
||||
" - " (description.using(cx))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **< Config form - GET >**************************************************************************
|
||||
|
||||
/// GET de una página [`AdminPageKind::ConfigForm`].
|
||||
///
|
||||
/// Lee el schema del registro y renderiza el formulario con los valores actuales.
|
||||
pub async fn config_form_get(request: HttpRequest) -> Result<Markup, ErrorPage> {
|
||||
let path = request.path().to_owned();
|
||||
let reg = registry::global();
|
||||
let page = reg
|
||||
.pages()
|
||||
.get(&path)
|
||||
.ok_or_else(|| ErrorPage::NotFound(Some(request.clone())))?;
|
||||
|
||||
let AdminPageKind::ConfigForm(ref schema) = page.kind else {
|
||||
return Err(ErrorPage::NotFound(Some(request.clone())));
|
||||
};
|
||||
|
||||
require_permission(&request, page.permission_key())?;
|
||||
|
||||
let title = page.title.clone();
|
||||
let mut form = ConfigForm::with_schema(schema.clone());
|
||||
let mut cx = Context::new(request.clone());
|
||||
let content = form.render(&mut cx).await;
|
||||
|
||||
Page::admin(request)
|
||||
.with_title(title.clone())
|
||||
.with_child(
|
||||
AdminFrame::new()
|
||||
.with_title(title)
|
||||
.with_child(Html::with(move |_| content.clone())),
|
||||
)
|
||||
.render()
|
||||
.await
|
||||
}
|
||||
|
||||
// **< Config form - POST >*************************************************************************
|
||||
|
||||
/// POST de una página [`AdminPageKind::ConfigForm`].
|
||||
///
|
||||
/// Valida y persiste los valores en `settings`, luego redirige al GET con
|
||||
/// estado `saved=true`.
|
||||
pub async fn config_form_post(
|
||||
request: HttpRequest,
|
||||
web::Form(data): web::Form<HashMap<String, String>>,
|
||||
) -> Result<Markup, ErrorPage> {
|
||||
let path = request.path().to_owned();
|
||||
let reg = registry::global();
|
||||
let page = reg
|
||||
.pages()
|
||||
.get(&path)
|
||||
.ok_or_else(|| ErrorPage::NotFound(Some(request.clone())))?;
|
||||
|
||||
let AdminPageKind::ConfigForm(ref schema) = page.kind else {
|
||||
return Err(ErrorPage::NotFound(Some(request.clone())));
|
||||
};
|
||||
|
||||
require_permission(&request, page.permission_key())?;
|
||||
|
||||
let mut save_error = false;
|
||||
|
||||
for field in schema.fields() {
|
||||
let key = schema.key_for(field.name());
|
||||
|
||||
// Los checkbox HTML sólo envían el campo si están marcados.
|
||||
let raw = match field.field_type() {
|
||||
crate::settings::SettingFieldType::Boolean => data
|
||||
.get(field.name())
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("false")
|
||||
.to_owned(),
|
||||
_ => {
|
||||
let Some(raw) = data.get(field.name()) else {
|
||||
if *field.required() {
|
||||
save_error = true;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
raw.clone()
|
||||
}
|
||||
};
|
||||
|
||||
settings::set::<String>(&key, &raw, schema.scope(), None).await;
|
||||
}
|
||||
|
||||
let title = page.title.clone();
|
||||
let mut form = ConfigForm::with_schema(schema.clone()).with_saved(!save_error, save_error);
|
||||
let mut cx = Context::new(request.clone());
|
||||
let content = form.render(&mut cx).await;
|
||||
|
||||
Page::admin(request)
|
||||
.with_title(title.clone())
|
||||
.with_child(
|
||||
AdminFrame::new()
|
||||
.with_title(title)
|
||||
.with_child(Html::with(move |_| content.clone())),
|
||||
)
|
||||
.render()
|
||||
.await
|
||||
}
|
||||
|
|
@ -1,194 +0,0 @@
|
|||
/*!
|
||||
<div align="center">
|
||||
|
||||
<h1>PageTop Admin</h1>
|
||||
|
||||
<p>Panel de administración extensible para <strong>PageTop</strong>.</p>
|
||||
|
||||
</div>
|
||||
|
||||
## Guía rápida
|
||||
|
||||
Declara la dependencia en tu `Cargo.toml` activando el motor de base de datos:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
pagetop-admin = { version = "...", features = ["sqlite"] }
|
||||
```
|
||||
|
||||
Añade `&pagetop_admin::Admin` a las dependencias de tu extensión y declara tus
|
||||
secciones, páginas o configuración:
|
||||
|
||||
```rust,no_run
|
||||
use pagetop::prelude::*;
|
||||
use pagetop_admin::prelude::*;
|
||||
use pagetop_admin::settings::{SettingField, SettingsSchema};
|
||||
|
||||
pub struct MyApp;
|
||||
|
||||
#[async_trait]
|
||||
impl Extension for MyApp {
|
||||
fn dependencies(&self) -> Vec<ExtensionRef> {
|
||||
vec![&pagetop_admin::Admin]
|
||||
}
|
||||
|
||||
fn actions(&self) -> Vec<ActionBox> {
|
||||
actions![
|
||||
DeclareAdminPages::new(declare_pages),
|
||||
]
|
||||
}
|
||||
|
||||
fn configure_router(&self, router: Router) -> Router {
|
||||
router.route("/admin/config/myapp", web::get(config_handler))
|
||||
}
|
||||
}
|
||||
|
||||
fn declare_pages(bag: &mut PageBag) {
|
||||
bag.add(AdminPage {
|
||||
path: "/admin/config/myapp".to_owned(),
|
||||
section: "config".to_owned(),
|
||||
title: Lc::n("My App"),
|
||||
description: Some(Lc::n("Configure My App.")),
|
||||
weight: 0,
|
||||
permission: Some(&MyPermission::Config),
|
||||
kind: AdminPageKind::View,
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum MyPermission {
|
||||
Config,
|
||||
}
|
||||
|
||||
impl Permission for MyPermission {
|
||||
fn key(&self) -> CowStr {
|
||||
match self {
|
||||
Self::Config => "myapp.config".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn config_handler(request: HttpRequest) -> Result<Markup, ErrorPage> {
|
||||
let schema = SettingsSchema::new("myapp.config")
|
||||
.with_field(SettingField::text("site_name", "Site name").with_required(true));
|
||||
|
||||
let title = "My App configuration";
|
||||
let mut form = ConfigForm::with_schema(schema);
|
||||
let mut cx = Context::new(request.clone());
|
||||
let content = form.render(&mut cx).await;
|
||||
|
||||
Page::admin(request)
|
||||
.with_title(Lc::n(title))
|
||||
.with_child(
|
||||
AdminFrame::new()
|
||||
.with_title(Lc::n(title))
|
||||
.with_child(Html::with(move |_| content.clone())),
|
||||
)
|
||||
.render()
|
||||
.await
|
||||
}
|
||||
```
|
||||
|
||||
Para formularios de configuración enteramente automáticos (GET y POST gestionados
|
||||
por `pagetop-admin`), usa `AdminPageKind::ConfigForm(schema)` en lugar de `View` y
|
||||
no registres la ruta tú mismo.
|
||||
*/
|
||||
|
||||
use pagetop::prelude::*;
|
||||
use pagetop_seaorm::install_migrations;
|
||||
|
||||
include_locales!(LOCALES_ADMIN);
|
||||
|
||||
/// Ruta raíz del panel de administración. No es configurable: otras piezas del ecosistema (p. ej.
|
||||
/// las rutas de `pagetop-user`, `/admin/user/...`) ya asumen este valor de forma literal.
|
||||
pub(crate) const ADMIN_BASE_PATH: &str = "/admin";
|
||||
|
||||
pub mod action;
|
||||
pub mod component;
|
||||
pub mod error;
|
||||
pub mod registry;
|
||||
pub mod settings;
|
||||
|
||||
pub(crate) mod entity;
|
||||
pub(crate) mod handlers;
|
||||
pub(crate) mod migration;
|
||||
pub(crate) mod seed;
|
||||
|
||||
pub use action::{
|
||||
ActionBag, DeclareAdminActions, DeclareAdminPages, DeclareAdminSections, DeclareAdminTasks,
|
||||
PageBag, SectionBag, TaskBag,
|
||||
};
|
||||
pub use component::{AdminFrame, AdminMenu, ConfigForm};
|
||||
pub use registry::{AdminAction, AdminPage, AdminPageKind, AdminSection, AdminTask};
|
||||
pub use settings::{SettingField, SettingFieldType, SettingsSchema};
|
||||
|
||||
/// Prelude de `pagetop-admin`.
|
||||
pub mod prelude {
|
||||
pub use crate::action::{
|
||||
ActionBag, DeclareAdminActions, DeclareAdminPages, DeclareAdminSections, DeclareAdminTasks,
|
||||
PageBag, SectionBag, TaskBag,
|
||||
};
|
||||
pub use crate::component::{AdminFrame, AdminMenu, ConfigForm};
|
||||
pub use crate::error::AdminError;
|
||||
pub use crate::registry::{AdminAction, AdminPage, AdminPageKind, AdminSection, AdminTask};
|
||||
pub use crate::settings::{SettingField, SettingFieldType, SettingsSchema};
|
||||
}
|
||||
|
||||
// **< Extension >**********************************************************************************
|
||||
|
||||
/// Implementa la extensión `pagetop-admin`.
|
||||
pub struct Admin;
|
||||
|
||||
#[async_trait]
|
||||
impl Extension for Admin {
|
||||
fn name(&self) -> Lc {
|
||||
Lc::t("extension_name", &LOCALES_ADMIN)
|
||||
}
|
||||
|
||||
fn description(&self) -> Lc {
|
||||
Lc::t("extension_description", &LOCALES_ADMIN)
|
||||
}
|
||||
|
||||
fn dependencies(&self) -> Vec<ExtensionRef> {
|
||||
vec![&pagetop_seaorm::SeaORM]
|
||||
}
|
||||
|
||||
fn actions(&self) -> Vec<ActionBox> {
|
||||
actions![action::DeclareAdminSections::new(seed::declare_default_sections).with_weight(-99),]
|
||||
}
|
||||
|
||||
async fn initialize(&self) {
|
||||
install_migrations!(m20260629_000001_create_settings);
|
||||
registry::build();
|
||||
|
||||
// Región neutra del core: cualquier tema puede decidir renderizarla (o no) sin que
|
||||
// `pagetop-admin` dependa de ninguno en concreto -- ver `CoreRegions::Aside`.
|
||||
InRegion::Global(&CoreRegions::Aside).add(component::AdminMenu::new());
|
||||
}
|
||||
|
||||
fn configure_router(&self, router: Router) -> Router {
|
||||
let base = ADMIN_BASE_PATH;
|
||||
let reg = registry::global();
|
||||
|
||||
let mut r = router.route(base, web::get(handlers::dashboard));
|
||||
|
||||
// Página de aterrizaje de cada sección, salvo que una extensión ya haya reclamado esa
|
||||
// misma ruta con su propia `AdminPage` (esa página gana y se monta más abajo).
|
||||
for section in reg.sections().values() {
|
||||
if !reg.pages().contains_key(§ion.path) {
|
||||
r = r.route(section.path.as_str(), web::get(handlers::section_page));
|
||||
}
|
||||
}
|
||||
|
||||
for (path, page) in reg.pages() {
|
||||
if let AdminPageKind::ConfigForm(_) = &page.kind {
|
||||
r = r.route(
|
||||
path.as_str(),
|
||||
web::get(handlers::config_form_get).post(handlers::config_form_post),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
r
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
extension_name = PageTop Admin
|
||||
extension_description = Extensible administration panel for PageTop.
|
||||
|
||||
# Dashboard
|
||||
dashboard-title = Administration
|
||||
dashboard-description = Manage your site.
|
||||
|
||||
# Sections
|
||||
section-people = People
|
||||
section-structure = Structure
|
||||
section-config = Configuration
|
||||
section-reports = Reports
|
||||
section-help = Help
|
||||
|
||||
# Config form
|
||||
config-save-label = Save configuration
|
||||
config-saved-ok = Configuration saved.
|
||||
config-saved-error = Could not save configuration.
|
||||
|
||||
# Navigation
|
||||
admin-breadcrumb-home = Administration
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
extension_name = PageTop Admin
|
||||
extension_description = Panel de administración extensible para PageTop.
|
||||
|
||||
# Dashboard
|
||||
dashboard-title = Administración
|
||||
dashboard-description = Gestiona tu sitio.
|
||||
|
||||
# Sections
|
||||
section-people = Personas
|
||||
section-structure = Estructura
|
||||
section-config = Configuración
|
||||
section-reports = Informes
|
||||
section-help = Ayuda
|
||||
|
||||
# Config form
|
||||
config-save-label = Guardar configuración
|
||||
config-saved-ok = Configuración guardada.
|
||||
config-saved-error = No se pudo guardar la configuración.
|
||||
|
||||
# Navigation
|
||||
admin-breadcrumb-home = Administración
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
//! Migraciones de base de datos de `pagetop-admin`.
|
||||
|
||||
pub(crate) mod m20260629_000001_create_settings;
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
use pagetop_seaorm::migration::*;
|
||||
|
||||
/// Tabla `settings`: almacén clave-valor JSON para configuración persistente.
|
||||
pub struct Migration;
|
||||
|
||||
#[pagetop::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Settings::Table)
|
||||
.if_not_exists()
|
||||
.col(string_len(Settings::Key, 190).primary_key())
|
||||
.col(string_len(Settings::Scope, 64))
|
||||
.col(text(Settings::Value))
|
||||
.col(timestamp(Settings::UpdatedAt).default(Expr::current_timestamp()))
|
||||
.col(integer_null(Settings::UpdatedBy))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_settings_scope")
|
||||
.table(Settings::Table)
|
||||
.col(Settings::Scope)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(Settings::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Settings {
|
||||
Table,
|
||||
Key,
|
||||
Scope,
|
||||
Value,
|
||||
UpdatedAt,
|
||||
UpdatedBy,
|
||||
}
|
||||
|
|
@ -1,295 +0,0 @@
|
|||
//! Registro global de secciones, páginas, tareas y acciones del panel de administración.
|
||||
//!
|
||||
//! Se construye una única vez durante `Extension::initialize()` y permanece
|
||||
//! inmutable durante la vida de la aplicación.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use pagetop::prelude::*;
|
||||
|
||||
use crate::action::{
|
||||
ActionBag, DeclareAdminActions, DeclareAdminPages, DeclareAdminSections, DeclareAdminTasks,
|
||||
PageBag, SectionBag, TaskBag,
|
||||
};
|
||||
use crate::settings::SettingsSchema;
|
||||
|
||||
// **< AdminPermission >*****************************************************************************
|
||||
|
||||
/// Permisos propios de `pagetop-admin`.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum AdminPermission {
|
||||
/// Acceso por defecto a una página de administración que no declara un permiso propio.
|
||||
Access,
|
||||
/// Acceso a la sección integrada "people".
|
||||
AccessPeople,
|
||||
/// Acceso a la sección integrada "structure".
|
||||
AccessStructure,
|
||||
/// Acceso a la sección integrada "config".
|
||||
AccessConfig,
|
||||
/// Acceso a la sección integrada "reports".
|
||||
AccessReports,
|
||||
}
|
||||
|
||||
impl Permission for AdminPermission {
|
||||
fn key(&self) -> CowStr {
|
||||
match self {
|
||||
Self::Access => "admin:access".into(),
|
||||
Self::AccessPeople => "admin.access_people".into(),
|
||||
Self::AccessStructure => "admin.access_structure".into(),
|
||||
Self::AccessConfig => "admin.access_config".into(),
|
||||
Self::AccessReports => "admin.access_reports".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **< Tipos del registro >**************************************************************************
|
||||
|
||||
/// Sección del panel de administración (agrupación en el sidebar).
|
||||
#[derive(Clone)]
|
||||
pub struct AdminSection {
|
||||
/// Identificador único de la sección (p. ej. `"config"`).
|
||||
pub key: String,
|
||||
/// Ruta base de la sección (p. ej. `"/admin/config"`).
|
||||
pub path: String,
|
||||
/// Título visible en el sidebar.
|
||||
pub title: Lc,
|
||||
/// Permiso requerido para ver la sección (`None` = siempre visible).
|
||||
pub permission: Option<PermissionRef>,
|
||||
/// Peso para ordenar en el sidebar (menor = antes).
|
||||
pub weight: i32,
|
||||
}
|
||||
|
||||
impl AdminSection {
|
||||
/// Devuelve `true` si el usuario actual puede ver esta sección.
|
||||
pub fn is_visible(&self, cx: &Context) -> bool {
|
||||
match self.permission {
|
||||
None => true,
|
||||
Some(permission) => cx
|
||||
.request()
|
||||
.is_some_and(|request| has_permission(request, permission)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Página del panel de administración.
|
||||
#[derive(Clone)]
|
||||
pub struct AdminPage {
|
||||
/// Ruta exacta de la página (p. ej. `"/admin/config/site"`).
|
||||
pub path: String,
|
||||
/// Clave de la sección a la que pertenece.
|
||||
pub section: String,
|
||||
/// Título visible en el sidebar y encabezado de página.
|
||||
pub title: Lc,
|
||||
/// Descripción breve (para el dashboard y listas de páginas), si tiene una.
|
||||
pub description: Option<Lc>,
|
||||
/// Peso dentro de la sección (menor = antes).
|
||||
pub weight: i32,
|
||||
/// Permiso requerido para acceder (`None` = requiere [`AdminPermission::Access`]).
|
||||
pub permission: Option<PermissionRef>,
|
||||
/// Tipo de página y datos asociados.
|
||||
pub kind: AdminPageKind,
|
||||
}
|
||||
|
||||
impl AdminPage {
|
||||
/// Permiso efectivo: el declarado, o [`AdminPermission::Access`] si no se especificó ninguno.
|
||||
pub fn permission_key(&self) -> PermissionRef {
|
||||
self.permission.unwrap_or(&AdminPermission::Access)
|
||||
}
|
||||
|
||||
/// Devuelve `true` si el usuario actual puede acceder a esta página.
|
||||
pub fn is_accessible(&self, cx: &Context) -> bool {
|
||||
cx.request()
|
||||
.is_some_and(|request| has_permission(request, self.permission_key()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Variantes de comportamiento de una [`AdminPage`].
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum AdminPageKind {
|
||||
/// Página genérica cuyo handler se registra externamente.
|
||||
View,
|
||||
/// Formulario de configuración gestionado automáticamente por `pagetop-admin`.
|
||||
ConfigForm(SettingsSchema),
|
||||
}
|
||||
|
||||
/// Tarea (pestaña) local dentro de una página de administración.
|
||||
#[derive(Clone)]
|
||||
pub struct AdminTask {
|
||||
/// Ruta de la tarea.
|
||||
pub path: String,
|
||||
/// Ruta de la página padre.
|
||||
pub parent_path: String,
|
||||
/// Etiqueta de la pestaña.
|
||||
pub title: Lc,
|
||||
/// Peso (menor = primera pestaña).
|
||||
pub weight: i32,
|
||||
/// Si es la tarea por defecto (pestaña activa al entrar a la página padre).
|
||||
pub is_default: bool,
|
||||
/// Permiso requerido (`None` = mismo que la página padre).
|
||||
pub permission: Option<PermissionRef>,
|
||||
}
|
||||
|
||||
/// Acción local (botón de acción) en una página de administración.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AdminAction {
|
||||
/// Ruta de destino de la acción.
|
||||
pub url: String,
|
||||
/// Página en la que aparece el botón.
|
||||
pub for_path: String,
|
||||
/// Etiqueta del botón.
|
||||
pub title: Lc,
|
||||
/// Peso (menor = primero).
|
||||
pub weight: i32,
|
||||
}
|
||||
|
||||
// **< AdminRegistry >*******************************************************************************
|
||||
|
||||
/// Registro global del panel de administración, construido una sola vez en `initialize()`.
|
||||
#[derive(Getters)]
|
||||
pub struct AdminRegistry {
|
||||
/// Devuelve las secciones indexadas por clave (`BTreeMap` para orden estable por clave).
|
||||
sections: BTreeMap<String, AdminSection>,
|
||||
/// Devuelve las páginas indexadas por ruta.
|
||||
pages: BTreeMap<String, AdminPage>,
|
||||
/// Devuelve las tareas indexadas por ruta de página padre.
|
||||
tasks: BTreeMap<String, Vec<AdminTask>>,
|
||||
/// Devuelve las acciones indexadas por ruta de página.
|
||||
actions: BTreeMap<String, Vec<AdminAction>>,
|
||||
}
|
||||
|
||||
impl AdminRegistry {
|
||||
fn new() -> Self {
|
||||
AdminRegistry {
|
||||
sections: BTreeMap::new(),
|
||||
pages: BTreeMap::new(),
|
||||
tasks: BTreeMap::new(),
|
||||
actions: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Devuelve las páginas de una sección ordenadas por peso.
|
||||
pub fn pages_for_section(&self, section_key: &str) -> Vec<&AdminPage> {
|
||||
let mut pages: Vec<&AdminPage> = self
|
||||
.pages
|
||||
.values()
|
||||
.filter(|p| p.section == section_key)
|
||||
.collect();
|
||||
pages.sort_by_key(|p| p.weight);
|
||||
pages
|
||||
}
|
||||
|
||||
/// Devuelve las secciones ordenadas por peso.
|
||||
pub fn ordered_sections(&self) -> Vec<&AdminSection> {
|
||||
let mut sections: Vec<&AdminSection> = self.sections.values().collect();
|
||||
sections.sort_by_key(|s| s.weight);
|
||||
sections
|
||||
}
|
||||
|
||||
/// Devuelve las tareas de una página ordenadas por peso.
|
||||
pub fn tasks_for(&self, path: &str) -> Vec<&AdminTask> {
|
||||
let Some(tasks) = self.tasks.get(path) else {
|
||||
return vec![];
|
||||
};
|
||||
let mut t: Vec<&AdminTask> = tasks.iter().collect();
|
||||
t.sort_by_key(|t| t.weight);
|
||||
t
|
||||
}
|
||||
|
||||
/// Devuelve las acciones de una página ordenadas por peso.
|
||||
pub fn actions_for(&self, path: &str) -> Vec<&AdminAction> {
|
||||
let Some(actions) = self.actions.get(path) else {
|
||||
return vec![];
|
||||
};
|
||||
let mut a: Vec<&AdminAction> = actions.iter().collect();
|
||||
a.sort_by_key(|a| a.weight);
|
||||
a
|
||||
}
|
||||
}
|
||||
|
||||
// **< Registro global >****************************************************************************
|
||||
|
||||
static REGISTRY: OnceLock<AdminRegistry> = OnceLock::new();
|
||||
|
||||
/// Construye el registro despachando todas las acciones de declaración.
|
||||
///
|
||||
/// Se llama una sola vez desde `Admin::initialize()`.
|
||||
pub(crate) fn build() {
|
||||
let mut registry = AdminRegistry::new();
|
||||
|
||||
// Secciones
|
||||
let mut section_bag = SectionBag {
|
||||
sections: Vec::new(),
|
||||
};
|
||||
DeclareAdminSections::dispatch(&mut section_bag);
|
||||
for s in section_bag.sections {
|
||||
registry.sections.insert(s.key.clone(), s);
|
||||
}
|
||||
|
||||
// Páginas
|
||||
let mut page_bag = PageBag { pages: Vec::new() };
|
||||
DeclareAdminPages::dispatch(&mut page_bag);
|
||||
for p in page_bag.pages {
|
||||
registry.pages.insert(p.path.clone(), p);
|
||||
}
|
||||
|
||||
// Tareas
|
||||
let mut task_bag = TaskBag { tasks: Vec::new() };
|
||||
DeclareAdminTasks::dispatch(&mut task_bag);
|
||||
for t in task_bag.tasks {
|
||||
registry
|
||||
.tasks
|
||||
.entry(t.parent_path.clone())
|
||||
.or_default()
|
||||
.push(t);
|
||||
}
|
||||
|
||||
// Acciones locales
|
||||
let mut action_bag = ActionBag {
|
||||
actions: Vec::new(),
|
||||
};
|
||||
DeclareAdminActions::dispatch(&mut action_bag);
|
||||
for a in action_bag.actions {
|
||||
registry
|
||||
.actions
|
||||
.entry(a.for_path.clone())
|
||||
.or_default()
|
||||
.push(a);
|
||||
}
|
||||
|
||||
REGISTRY.set(registry).ok();
|
||||
}
|
||||
|
||||
/// Accede al registro global del panel de administración.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Entra en pánico si se llama antes de que `Admin::initialize()` haya completado.
|
||||
pub fn global() -> &'static AdminRegistry {
|
||||
REGISTRY.get().expect("AdminRegistry not initialized")
|
||||
}
|
||||
|
||||
// **< Menú de administración >*********************************************************************
|
||||
|
||||
/// Construye el menú plano de secciones visibles para el usuario de la petición actual.
|
||||
///
|
||||
/// Pensado para que un tema lo use como navegación de `CoreTemplates::Admin` (p. ej. un sidebar) --
|
||||
/// ver [`crate::component::AdminMenu`]. `pagetop-admin` no impone ningún marcado propio: el
|
||||
/// [`Nav`] resultante se renderiza con su aspecto por defecto salvo que el tema lo intercepte en
|
||||
/// [`Theme::handle_component()`](pagetop::core::theme::Theme::handle_component).
|
||||
pub fn admin_menu(cx: &Context) -> Nav {
|
||||
let reg = global();
|
||||
let current_path = cx.request().map(|r| r.path()).unwrap_or("");
|
||||
|
||||
let mut result = Nav::new();
|
||||
for section in reg.ordered_sections() {
|
||||
if !section.is_visible(cx) {
|
||||
continue;
|
||||
}
|
||||
let active = current_path.starts_with(section.path.as_str());
|
||||
result = result.with_item(
|
||||
nav::Item::link(section.title.clone(), section.path.clone()).with_active(active),
|
||||
);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
use pagetop::prelude::*;
|
||||
|
||||
use crate::ADMIN_BASE_PATH;
|
||||
use crate::LOCALES_ADMIN;
|
||||
use crate::action::SectionBag;
|
||||
use crate::registry::{AdminPermission, AdminSection};
|
||||
|
||||
/// Declara las secciones incorporadas del panel de administración.
|
||||
///
|
||||
/// Se registra en `Admin::actions()` como `DeclareAdminSections::new(declare_default_sections)`
|
||||
/// con peso negativo para ejecutarse antes que las extensiones de terceros.
|
||||
pub(crate) fn declare_default_sections(bag: &mut SectionBag) {
|
||||
let base = ADMIN_BASE_PATH;
|
||||
|
||||
bag.add(AdminSection {
|
||||
key: "people".to_owned(),
|
||||
path: format!("{}/people", base),
|
||||
title: Lc::t("section-people", &LOCALES_ADMIN),
|
||||
permission: Some(&AdminPermission::AccessPeople),
|
||||
weight: 10,
|
||||
});
|
||||
bag.add(AdminSection {
|
||||
key: "structure".to_owned(),
|
||||
path: format!("{}/structure", base),
|
||||
title: Lc::t("section-structure", &LOCALES_ADMIN),
|
||||
permission: Some(&AdminPermission::AccessStructure),
|
||||
weight: 20,
|
||||
});
|
||||
bag.add(AdminSection {
|
||||
key: "config".to_owned(),
|
||||
path: format!("{}/config", base),
|
||||
title: Lc::t("section-config", &LOCALES_ADMIN),
|
||||
permission: Some(&AdminPermission::AccessConfig),
|
||||
weight: 30,
|
||||
});
|
||||
bag.add(AdminSection {
|
||||
key: "reports".to_owned(),
|
||||
path: format!("{}/reports", base),
|
||||
title: Lc::t("section-reports", &LOCALES_ADMIN),
|
||||
permission: Some(&AdminPermission::AccessReports),
|
||||
weight: 40,
|
||||
});
|
||||
bag.add(AdminSection {
|
||||
key: "help".to_owned(),
|
||||
path: format!("{}/help", base),
|
||||
title: Lc::t("section-help", &LOCALES_ADMIN),
|
||||
permission: None,
|
||||
weight: 50,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,238 +0,0 @@
|
|||
//! Almacén de configuración persistente basado en `settings`.
|
||||
//!
|
||||
//! Proporciona una API async para leer y escribir valores JSON en la tabla `settings`.
|
||||
|
||||
use pagetop::datetime::Utc;
|
||||
use pagetop::{Getters, builder_fn};
|
||||
use pagetop_seaorm::db::{
|
||||
ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter, dbconn,
|
||||
};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
|
||||
use crate::entity::setting::{ActiveModel, Column, Entity};
|
||||
use crate::error::AdminError;
|
||||
|
||||
// **< API pública >*********************************************************************************
|
||||
|
||||
/// Lee un valor persistido, devolviendo el `Default` del tipo si no existe.
|
||||
pub async fn get<T: DeserializeOwned + Default>(key: &str) -> T {
|
||||
get_async(key).await.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Lee un valor persistido, devolviendo `default` si no existe o hay error.
|
||||
pub async fn get_or<T: DeserializeOwned>(key: &str, default: T) -> T {
|
||||
get_async(key).await.unwrap_or(default)
|
||||
}
|
||||
|
||||
/// Escribe un valor en la tabla `settings`.
|
||||
///
|
||||
/// Si la clave no existe se inserta; si existe, se actualiza.
|
||||
pub async fn set<T: Serialize>(key: &str, value: &T, scope: &str, user_id: Option<i32>) {
|
||||
set_async(key, value, scope, user_id).await.ok();
|
||||
}
|
||||
|
||||
/// Elimina una entrada de `settings` por clave.
|
||||
pub async fn delete(key: &str) {
|
||||
delete_async(key).await.ok();
|
||||
}
|
||||
|
||||
/// Devuelve todos los pares `(key, value_json)` de un `scope` dado.
|
||||
pub async fn list_scope(scope: &str) -> Vec<(String, String)> {
|
||||
list_scope_async(scope).await.unwrap_or_default()
|
||||
}
|
||||
|
||||
// **< Implementación asíncrona >********************************************************************
|
||||
|
||||
async fn get_async<T: DeserializeOwned>(key: &str) -> Result<T, AdminError> {
|
||||
let model = Entity::find_by_id(key)
|
||||
.one(dbconn())
|
||||
.await?
|
||||
.ok_or_else(|| AdminError::NotFound(key.to_owned()))?;
|
||||
Ok(serde_json::from_str(&model.value)?)
|
||||
}
|
||||
|
||||
async fn set_async<T: Serialize>(
|
||||
key: &str,
|
||||
value: &T,
|
||||
scope: &str,
|
||||
user_id: Option<i32>,
|
||||
) -> Result<(), AdminError> {
|
||||
let value_json = serde_json::to_string(value)?;
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
let existing = Entity::find_by_id(key).one(dbconn()).await?;
|
||||
if existing.is_some() {
|
||||
let model = ActiveModel {
|
||||
key: ActiveValue::Unchanged(key.to_owned()),
|
||||
scope: ActiveValue::Set(scope.to_owned()),
|
||||
value: ActiveValue::Set(value_json),
|
||||
updated_at: ActiveValue::Set(now),
|
||||
updated_by: ActiveValue::Set(user_id),
|
||||
};
|
||||
model.update(dbconn()).await?;
|
||||
} else {
|
||||
let model = ActiveModel {
|
||||
key: ActiveValue::Set(key.to_owned()),
|
||||
scope: ActiveValue::Set(scope.to_owned()),
|
||||
value: ActiveValue::Set(value_json),
|
||||
updated_at: ActiveValue::Set(now),
|
||||
updated_by: ActiveValue::Set(user_id),
|
||||
};
|
||||
model.insert(dbconn()).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_async(key: &str) -> Result<(), AdminError> {
|
||||
Entity::delete_by_id(key).exec(dbconn()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_scope_async(scope: &str) -> Result<Vec<(String, String)>, AdminError> {
|
||||
let rows = Entity::find()
|
||||
.filter(Column::Scope.eq(scope))
|
||||
.all(dbconn())
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|m| (m.key, m.value)).collect())
|
||||
}
|
||||
|
||||
// **< Tipos de esquema >****************************************************************************
|
||||
|
||||
/// Tipo de campo de configuración para un [`SettingsSchema`].
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum SettingFieldType {
|
||||
/// Texto libre con longitud máxima opcional.
|
||||
Text { max_length: Option<usize> },
|
||||
/// Número (entero o decimal).
|
||||
Number { min: Option<f64>, max: Option<f64> },
|
||||
/// Casilla de verificación (booleano).
|
||||
Boolean,
|
||||
/// Lista de opciones `(valor, etiqueta)`.
|
||||
Select { options: Vec<(String, String)> },
|
||||
}
|
||||
|
||||
/// Definición de un campo dentro de un [`SettingsSchema`].
|
||||
#[derive(Clone, Debug, Getters)]
|
||||
pub struct SettingField {
|
||||
/// Devuelve el nombre del campo (clave dentro del scope, p. ej. `"site_name"`).
|
||||
name: String,
|
||||
/// Devuelve la etiqueta visible en el formulario.
|
||||
label: String,
|
||||
/// Devuelve el tipo de campo.
|
||||
field_type: SettingFieldType,
|
||||
/// Devuelve si el campo es obligatorio.
|
||||
required: bool,
|
||||
/// Devuelve el texto de ayuda opcional bajo el campo.
|
||||
help_text: Option<String>,
|
||||
/// Devuelve el valor por defecto como cadena JSON.
|
||||
default_value: Option<String>,
|
||||
}
|
||||
|
||||
impl SettingField {
|
||||
/// Crea un campo de texto con nombre y etiqueta.
|
||||
pub fn text(name: impl Into<String>, label: impl Into<String>) -> Self {
|
||||
SettingField {
|
||||
name: name.into(),
|
||||
label: label.into(),
|
||||
field_type: SettingFieldType::Text { max_length: None },
|
||||
required: false,
|
||||
help_text: None,
|
||||
default_value: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Crea un campo numérico con nombre y etiqueta.
|
||||
pub fn number(name: impl Into<String>, label: impl Into<String>) -> Self {
|
||||
SettingField {
|
||||
name: name.into(),
|
||||
label: label.into(),
|
||||
field_type: SettingFieldType::Number {
|
||||
min: None,
|
||||
max: None,
|
||||
},
|
||||
required: false,
|
||||
help_text: None,
|
||||
default_value: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Crea un campo booleano con nombre y etiqueta.
|
||||
pub fn boolean(name: impl Into<String>, label: impl Into<String>) -> Self {
|
||||
SettingField {
|
||||
name: name.into(),
|
||||
label: label.into(),
|
||||
field_type: SettingFieldType::Boolean,
|
||||
required: false,
|
||||
help_text: None,
|
||||
default_value: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Crea un campo de selección con nombre, etiqueta y opciones.
|
||||
pub fn select(
|
||||
name: impl Into<String>,
|
||||
label: impl Into<String>,
|
||||
options: Vec<(String, String)>,
|
||||
) -> Self {
|
||||
SettingField {
|
||||
name: name.into(),
|
||||
label: label.into(),
|
||||
field_type: SettingFieldType::Select { options },
|
||||
required: false,
|
||||
help_text: None,
|
||||
default_value: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Establece si el campo es obligatorio.
|
||||
#[builder_fn]
|
||||
pub fn with_required(mut self, required: bool) -> Self {
|
||||
self.required = required;
|
||||
self
|
||||
}
|
||||
|
||||
/// Añade texto de ayuda bajo el campo.
|
||||
#[builder_fn]
|
||||
pub fn with_help(mut self, text: impl Into<String>) -> Self {
|
||||
self.help_text = Some(text.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece el valor por defecto (como valor JSON serializado).
|
||||
#[builder_fn]
|
||||
pub fn with_default<T: Serialize>(mut self, value: &T) -> Self {
|
||||
self.default_value = serde_json::to_string(value).ok();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Esquema de formulario de configuración: describe un grupo de ajustes en un scope.
|
||||
#[derive(Clone, Debug, Getters)]
|
||||
pub struct SettingsSchema {
|
||||
/// Devuelve el identificador del grupo (prefijo de las claves en `settings`).
|
||||
scope: String,
|
||||
/// Devuelve los campos del formulario en orden de presentación.
|
||||
fields: Vec<SettingField>,
|
||||
}
|
||||
|
||||
impl SettingsSchema {
|
||||
/// Crea un nuevo esquema vacío para el `scope` dado.
|
||||
pub fn new(scope: impl Into<String>) -> Self {
|
||||
SettingsSchema {
|
||||
scope: scope.into(),
|
||||
fields: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Añade un campo al esquema.
|
||||
#[builder_fn]
|
||||
pub fn with_field(mut self, field: SettingField) -> Self {
|
||||
self.fields.push(field);
|
||||
self
|
||||
}
|
||||
|
||||
/// Devuelve la clave completa de un campo: `"{scope}.{field_name}"`.
|
||||
pub fn key_for(&self, field_name: &str) -> String {
|
||||
format!("{}.{}", self.scope, field_name)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +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
|
||||
chrono.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,198 +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 `handle_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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
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`]).
|
||||
#[builder_fn]
|
||||
pub fn with_max_depth(mut self, v: impl Into<Option<u8>>) -> Self {
|
||||
self.max_depth = v.into();
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub fn with_include_disabled(mut self, v: impl Into<Option<bool>>) -> Self {
|
||||
if let Some(v) = v.into() {
|
||||
self.include_disabled = v;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
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,103 +0,0 @@
|
|||
use pagetop::base::component::breadcrumb;
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
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,56 +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 {
|
||||
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,37 +0,0 @@
|
|||
use pagetop_seaorm::db::*;
|
||||
|
||||
use chrono::NaiveDateTime;
|
||||
|
||||
#[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,47 +0,0 @@
|
|||
use pagetop_seaorm::db::*;
|
||||
|
||||
use chrono::NaiveDateTime;
|
||||
|
||||
#[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,309 +0,0 @@
|
|||
//! Operaciones de base de datos para menús e ítems de menú.
|
||||
|
||||
use chrono::Utc;
|
||||
|
||||
use pagetop::locale::Locale;
|
||||
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,289 +0,0 @@
|
|||
//! Tipos en memoria del árbol de menús y función de construcción.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use pagetop::locale::{Locale, RequestLocale};
|
||||
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