From 88acc08b5ffa829a6e5f07ad8d62fdef9c32521c Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Fri, 28 Aug 2026 20:11:44 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20(admin):=20A=C3=B1ade=20la=20ex?= =?UTF-8?q?tensi=C3=B3n=20pagetop-admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Panel de administración extensible: registro de secciones/páginas/ tareas/acciones vía acciones `Declare*`, formularios de configuración automáticos (ConfigForm + SettingsSchema) y persistencia en la tabla `settings`. --- extensions/pagetop-admin/Cargo.toml | 19 ++ extensions/pagetop-admin/src/action.rs | 291 +++++++++++++++++ extensions/pagetop-admin/src/component.rs | 9 + .../src/component/admin_frame.rs | 150 +++++++++ .../pagetop-admin/src/component/admin_menu.rs | 37 +++ .../src/component/config_form.rs | 226 ++++++++++++++ extensions/pagetop-admin/src/entity.rs | 3 + .../pagetop-admin/src/entity/setting.rs | 20 ++ extensions/pagetop-admin/src/error.rs | 14 + extensions/pagetop-admin/src/handlers.rs | 207 ++++++++++++ extensions/pagetop-admin/src/lib.rs | 194 ++++++++++++ .../pagetop-admin/src/locale/en-US/common.ftl | 21 ++ .../pagetop-admin/src/locale/es-ES/common.ftl | 21 ++ extensions/pagetop-admin/src/migration.rs | 3 + .../m20260629_000001_create_settings.rs | 49 +++ extensions/pagetop-admin/src/registry.rs | 295 ++++++++++++++++++ extensions/pagetop-admin/src/seed.rs | 50 +++ extensions/pagetop-admin/src/settings.rs | 238 ++++++++++++++ 18 files changed, 1847 insertions(+) create mode 100644 extensions/pagetop-admin/Cargo.toml create mode 100644 extensions/pagetop-admin/src/action.rs create mode 100644 extensions/pagetop-admin/src/component.rs create mode 100644 extensions/pagetop-admin/src/component/admin_frame.rs create mode 100644 extensions/pagetop-admin/src/component/admin_menu.rs create mode 100644 extensions/pagetop-admin/src/component/config_form.rs create mode 100644 extensions/pagetop-admin/src/entity.rs create mode 100644 extensions/pagetop-admin/src/entity/setting.rs create mode 100644 extensions/pagetop-admin/src/error.rs create mode 100644 extensions/pagetop-admin/src/handlers.rs create mode 100644 extensions/pagetop-admin/src/lib.rs create mode 100644 extensions/pagetop-admin/src/locale/en-US/common.ftl create mode 100644 extensions/pagetop-admin/src/locale/es-ES/common.ftl create mode 100644 extensions/pagetop-admin/src/migration.rs create mode 100644 extensions/pagetop-admin/src/migration/m20260629_000001_create_settings.rs create mode 100644 extensions/pagetop-admin/src/registry.rs create mode 100644 extensions/pagetop-admin/src/seed.rs create mode 100644 extensions/pagetop-admin/src/settings.rs diff --git a/extensions/pagetop-admin/Cargo.toml b/extensions/pagetop-admin/Cargo.toml new file mode 100644 index 00000000..662c62b9 --- /dev/null +++ b/extensions/pagetop-admin/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "pagetop-admin" +description = "Panel de administración extensible para PageTop." +version = "0.1.0" +categories = ["web-programming", "development-tools"] +keywords = ["pagetop", "admin", "cms", "settings", "ssr"] + +repository.workspace = true +homepage.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +pagetop.workspace = true +pagetop-seaorm.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true diff --git a/extensions/pagetop-admin/src/action.rs b/extensions/pagetop-admin/src/action.rs new file mode 100644 index 00000000..019b839a --- /dev/null +++ b/extensions/pagetop-admin/src/action.rs @@ -0,0 +1,291 @@ +//! 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, +} + +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, +} + +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, +} + +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, +} + +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::(), 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::(), 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::(), 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::(), None, None), + |action: &Self| (action.f)(bag), + ); + } +} diff --git a/extensions/pagetop-admin/src/component.rs b/extensions/pagetop-admin/src/component.rs new file mode 100644 index 00000000..e6325daf --- /dev/null +++ b/extensions/pagetop-admin/src/component.rs @@ -0,0 +1,9 @@ +//! Componentes de renderizado del panel de administración. + +mod admin_frame; +mod admin_menu; +mod config_form; + +pub use admin_frame::AdminFrame; +pub use admin_menu::AdminMenu; +pub use config_form::ConfigForm; diff --git a/extensions/pagetop-admin/src/component/admin_frame.rs b/extensions/pagetop-admin/src/component/admin_frame.rs new file mode 100644 index 00000000..8a63ed23 --- /dev/null +++ b/extensions/pagetop-admin/src/component/admin_frame.rs @@ -0,0 +1,150 @@ +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 +/// +///
+///

Title

+///
    ...
+///
+/// +///
...
+/// ``` +/// +/// 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 { + 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) -> 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)) } + } + } + } + } +} diff --git a/extensions/pagetop-admin/src/component/admin_menu.rs b/extensions/pagetop-admin/src/component/admin_menu.rs new file mode 100644 index 00000000..ad3f1963 --- /dev/null +++ b/extensions/pagetop-admin/src/component/admin_menu.rs @@ -0,0 +1,37 @@ +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 { + if !matches!( + cx.template().downcast_ref::(), + Some(CoreTemplates::Admin) + ) { + return Ok(html! {}); + } + Ok(registry::admin_menu(cx).render(cx).await) + } +} diff --git a/extensions/pagetop-admin/src/component/config_form.rs b/extensions/pagetop-admin/src/component/config_form.rs new file mode 100644 index 00000000..304fb621 --- /dev/null +++ b/extensions/pagetop-admin/src/component/config_form.rs @@ -0,0 +1,226 @@ +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 { +/// 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, + /// Devuelve la ruta de destino del formulario, si se ha personalizado. + action_path: Option, + /// 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 { + 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 = Vec::with_capacity(schema.fields().len()); + for field in schema.fields() { + let key = schema.key_for(field.name()); + let raw_val = + get_or::(&key, field.default_value().cloned().unwrap_or_default()).await; + let num_val = get_or::(&key, 0.0).await.to_string(); + let checked = get_or::(&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>) -> 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 + } +} diff --git a/extensions/pagetop-admin/src/entity.rs b/extensions/pagetop-admin/src/entity.rs new file mode 100644 index 00000000..75e3a9d0 --- /dev/null +++ b/extensions/pagetop-admin/src/entity.rs @@ -0,0 +1,3 @@ +//! Entidades SeaORM de `pagetop-admin`. + +pub(crate) mod setting; diff --git a/extensions/pagetop-admin/src/entity/setting.rs b/extensions/pagetop-admin/src/entity/setting.rs new file mode 100644 index 00000000..37b0329a --- /dev/null +++ b/extensions/pagetop-admin/src/entity/setting.rs @@ -0,0 +1,20 @@ +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, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/extensions/pagetop-admin/src/error.rs b/extensions/pagetop-admin/src/error.rs new file mode 100644 index 00000000..318e82f4 --- /dev/null +++ b/extensions/pagetop-admin/src/error.rs @@ -0,0 +1,14 @@ +use thiserror::Error; + +/// Errores que puede producir la extensión `pagetop-admin`. +#[derive(Debug, Error)] +pub enum AdminError { + #[error("settings DB error: {0}")] + DbError(#[from] pagetop_seaorm::db::DbErr), + + #[error("settings serialization error: {0}")] + SerializeError(#[from] serde_json::Error), + + #[error("setting key not found: {0}")] + NotFound(String), +} diff --git a/extensions/pagetop-admin/src/handlers.rs b/extensions/pagetop-admin/src/handlers.rs new file mode 100644 index 00000000..8a512d08 --- /dev/null +++ b/extensions/pagetop-admin/src/handlers.rs @@ -0,0 +1,207 @@ +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 { + 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 { + 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 { + 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>, +) -> Result { + 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::(&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 +} diff --git a/extensions/pagetop-admin/src/lib.rs b/extensions/pagetop-admin/src/lib.rs new file mode 100644 index 00000000..dfa7eab5 --- /dev/null +++ b/extensions/pagetop-admin/src/lib.rs @@ -0,0 +1,194 @@ +/*! +
+ +

PageTop Admin

+ +

Panel de administración extensible para PageTop.

+ +
+ +## 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 { + vec![&pagetop_admin::Admin] + } + + fn actions(&self) -> Vec { + 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 { + 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 { + vec![&pagetop_seaorm::SeaORM] + } + + fn actions(&self) -> Vec { + 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 + } +} diff --git a/extensions/pagetop-admin/src/locale/en-US/common.ftl b/extensions/pagetop-admin/src/locale/en-US/common.ftl new file mode 100644 index 00000000..31e38061 --- /dev/null +++ b/extensions/pagetop-admin/src/locale/en-US/common.ftl @@ -0,0 +1,21 @@ +extension_name = PageTop Admin +extension_description = Extensible administration panel for PageTop. + +# Dashboard +dashboard-title = Administration +dashboard-description = Manage your site. + +# Sections +section-people = People +section-structure = Structure +section-config = Configuration +section-reports = Reports +section-help = Help + +# Config form +config-save-label = Save configuration +config-saved-ok = Configuration saved. +config-saved-error = Could not save configuration. + +# Navigation +admin-breadcrumb-home = Administration diff --git a/extensions/pagetop-admin/src/locale/es-ES/common.ftl b/extensions/pagetop-admin/src/locale/es-ES/common.ftl new file mode 100644 index 00000000..aa3b3eda --- /dev/null +++ b/extensions/pagetop-admin/src/locale/es-ES/common.ftl @@ -0,0 +1,21 @@ +extension_name = PageTop Admin +extension_description = Panel de administración extensible para PageTop. + +# Dashboard +dashboard-title = Administración +dashboard-description = Gestiona tu sitio. + +# Sections +section-people = Personas +section-structure = Estructura +section-config = Configuración +section-reports = Informes +section-help = Ayuda + +# Config form +config-save-label = Guardar configuración +config-saved-ok = Configuración guardada. +config-saved-error = No se pudo guardar la configuración. + +# Navigation +admin-breadcrumb-home = Administración diff --git a/extensions/pagetop-admin/src/migration.rs b/extensions/pagetop-admin/src/migration.rs new file mode 100644 index 00000000..f940a5d1 --- /dev/null +++ b/extensions/pagetop-admin/src/migration.rs @@ -0,0 +1,3 @@ +//! Migraciones de base de datos de `pagetop-admin`. + +pub(crate) mod m20260629_000001_create_settings; diff --git a/extensions/pagetop-admin/src/migration/m20260629_000001_create_settings.rs b/extensions/pagetop-admin/src/migration/m20260629_000001_create_settings.rs new file mode 100644 index 00000000..598d72c3 --- /dev/null +++ b/extensions/pagetop-admin/src/migration/m20260629_000001_create_settings.rs @@ -0,0 +1,49 @@ +use pagetop_seaorm::migration::*; + +/// Tabla `settings`: almacén clave-valor JSON para configuración persistente. +pub struct Migration; + +#[pagetop::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Settings::Table) + .if_not_exists() + .col(string_len(Settings::Key, 190).primary_key()) + .col(string_len(Settings::Scope, 64)) + .col(text(Settings::Value)) + .col(timestamp(Settings::UpdatedAt).default(Expr::current_timestamp())) + .col(integer_null(Settings::UpdatedBy)) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_settings_scope") + .table(Settings::Table) + .col(Settings::Scope) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Settings::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Settings { + Table, + Key, + Scope, + Value, + UpdatedAt, + UpdatedBy, +} diff --git a/extensions/pagetop-admin/src/registry.rs b/extensions/pagetop-admin/src/registry.rs new file mode 100644 index 00000000..1144842c --- /dev/null +++ b/extensions/pagetop-admin/src/registry.rs @@ -0,0 +1,295 @@ +//! 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, + /// 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, + /// Peso dentro de la sección (menor = antes). + pub weight: i32, + /// Permiso requerido para acceder (`None` = requiere [`AdminPermission::Access`]). + pub permission: Option, + /// 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, +} + +/// 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, + /// Devuelve las páginas indexadas por ruta. + pages: BTreeMap, + /// Devuelve las tareas indexadas por ruta de página padre. + tasks: BTreeMap>, + /// Devuelve las acciones indexadas por ruta de página. + actions: BTreeMap>, +} + +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 = 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 +} diff --git a/extensions/pagetop-admin/src/seed.rs b/extensions/pagetop-admin/src/seed.rs new file mode 100644 index 00000000..cf4c6640 --- /dev/null +++ b/extensions/pagetop-admin/src/seed.rs @@ -0,0 +1,50 @@ +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, + }); +} diff --git a/extensions/pagetop-admin/src/settings.rs b/extensions/pagetop-admin/src/settings.rs new file mode 100644 index 00000000..0eb3028a --- /dev/null +++ b/extensions/pagetop-admin/src/settings.rs @@ -0,0 +1,238 @@ +//! 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(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(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(key: &str, value: &T, scope: &str, user_id: Option) { + 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(key: &str) -> Result { + 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( + key: &str, + value: &T, + scope: &str, + user_id: Option, +) -> 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, 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 }, + /// Número (entero o decimal). + Number { min: Option, max: Option }, + /// 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, + /// Devuelve el valor por defecto como cadena JSON. + default_value: Option, +} + +impl SettingField { + /// Crea un campo de texto con nombre y etiqueta. + pub fn text(name: impl Into, label: impl Into) -> 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, label: impl Into) -> 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, label: impl Into) -> 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, + label: impl Into, + 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) -> Self { + self.help_text = Some(text.into()); + self + } + + /// Establece el valor por defecto (como valor JSON serializado). + #[builder_fn] + pub fn with_default(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, +} + +impl SettingsSchema { + /// Crea un nuevo esquema vacío para el `scope` dado. + pub fn new(scope: impl Into) -> 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) + } +} From 043873a9541319636ebfd98ba5f646acd6fade26 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Fri, 28 Aug 2026 20:33:57 +0200 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=A8=20(menu):=20A=C3=B1ade=20extensi?= =?UTF-8?q?=C3=B3n=20pagetop-menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gestión centralizada y persistente de menús: entidades y migraciones SeaORM, sistema de acciones para declarar menús/ítems por código, caché in-process del árbol y los componentes `MenuBlock`/`MenuBreadcrumb`. --- Cargo.lock | 2 - extensions/pagetop-menu/Cargo.toml | 19 ++ extensions/pagetop-menu/src/action.rs | 312 ++++++++++++++++++ extensions/pagetop-menu/src/cache.rs | 73 ++++ extensions/pagetop-menu/src/component.rs | 7 + .../pagetop-menu/src/component/menu_block.rs | 198 +++++++++++ .../src/component/menu_breadcrumb.rs | 103 ++++++ extensions/pagetop-menu/src/config.rs | 56 ++++ extensions/pagetop-menu/src/entity.rs | 6 + extensions/pagetop-menu/src/entity/menu.rs | 37 +++ .../pagetop-menu/src/entity/menu_item.rs | 47 +++ .../src/entity/menu_item_translation.rs | 30 ++ .../src/entity/menu_translation.rs | 31 ++ extensions/pagetop-menu/src/error.rs | 13 + extensions/pagetop-menu/src/lib.rs | 130 ++++++++ .../pagetop-menu/src/locale/en-US/common.ftl | 2 + .../pagetop-menu/src/locale/es-ES/common.ftl | 2 + extensions/pagetop-menu/src/migration.rs | 6 + .../m20260629_000001_create_menus.rs | 32 ++ ...0260629_000002_create_menu_translations.rs | 58 ++++ .../m20260629_000003_create_menu_items.rs | 87 +++++ ...29_000004_create_menu_item_translations.rs | 56 ++++ extensions/pagetop-menu/src/repo.rs | 309 +++++++++++++++++ extensions/pagetop-menu/src/seed.rs | 60 ++++ extensions/pagetop-menu/src/tree.rs | 289 ++++++++++++++++ 25 files changed, 1963 insertions(+), 2 deletions(-) create mode 100644 extensions/pagetop-menu/Cargo.toml create mode 100644 extensions/pagetop-menu/src/action.rs create mode 100644 extensions/pagetop-menu/src/cache.rs create mode 100644 extensions/pagetop-menu/src/component.rs create mode 100644 extensions/pagetop-menu/src/component/menu_block.rs create mode 100644 extensions/pagetop-menu/src/component/menu_breadcrumb.rs create mode 100644 extensions/pagetop-menu/src/config.rs create mode 100644 extensions/pagetop-menu/src/entity.rs create mode 100644 extensions/pagetop-menu/src/entity/menu.rs create mode 100644 extensions/pagetop-menu/src/entity/menu_item.rs create mode 100644 extensions/pagetop-menu/src/entity/menu_item_translation.rs create mode 100644 extensions/pagetop-menu/src/entity/menu_translation.rs create mode 100644 extensions/pagetop-menu/src/error.rs create mode 100644 extensions/pagetop-menu/src/lib.rs create mode 100644 extensions/pagetop-menu/src/locale/en-US/common.ftl create mode 100644 extensions/pagetop-menu/src/locale/es-ES/common.ftl create mode 100644 extensions/pagetop-menu/src/migration.rs create mode 100644 extensions/pagetop-menu/src/migration/m20260629_000001_create_menus.rs create mode 100644 extensions/pagetop-menu/src/migration/m20260629_000002_create_menu_translations.rs create mode 100644 extensions/pagetop-menu/src/migration/m20260629_000003_create_menu_items.rs create mode 100644 extensions/pagetop-menu/src/migration/m20260629_000004_create_menu_item_translations.rs create mode 100644 extensions/pagetop-menu/src/repo.rs create mode 100644 extensions/pagetop-menu/src/seed.rs create mode 100644 extensions/pagetop-menu/src/tree.rs diff --git a/Cargo.lock b/Cargo.lock index e00901ac..30f588d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1884,10 +1884,8 @@ dependencies = [ "chrono", "pagetop", "pagetop-seaorm", - "sea-orm", "serde", "thiserror", - "tokio", ] [[package]] diff --git a/extensions/pagetop-menu/Cargo.toml b/extensions/pagetop-menu/Cargo.toml new file mode 100644 index 00000000..bd8d676d --- /dev/null +++ b/extensions/pagetop-menu/Cargo.toml @@ -0,0 +1,19 @@ +[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 diff --git a/extensions/pagetop-menu/src/action.rs b/extensions/pagetop-menu/src/action.rs new file mode 100644 index 00000000..3dce5434 --- /dev/null +++ b/extensions/pagetop-menu/src/action.rs @@ -0,0 +1,312 @@ +//! 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, title: impl Into) { + 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, +} + +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::(), 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 { + Some(self.menu_name.clone()) + } + + fn weight(&self) -> Weight { + self.weight + } +} + +impl DeclareDefaultMenuItems { + pub fn new(menu_name: impl Into, 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::(), 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 { + Some(self.menu_name.clone()) + } + + fn weight(&self) -> Weight { + self.weight + } +} + +impl AlterMenuTree { + pub fn new(menu_name: impl Into, 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::(), 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 { + Some(self.menu_name.clone()) + } + + fn weight(&self) -> Weight { + self.weight + } +} + +impl ResolveActiveTrail { + pub fn new(menu_name: impl Into, 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::(), 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 { + Some(self.menu_name.clone()) + } + + fn weight(&self) -> Weight { + self.weight + } +} + +impl DecorateMenuItem { + pub fn new(menu_name: impl Into, 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::(), None, Some(menu_name.to_owned())), + |action: &Self| (action.f)(node, cx), + ); + } +} diff --git a/extensions/pagetop-menu/src/cache.rs b/extensions/pagetop-menu/src/cache.rs new file mode 100644 index 00000000..599c9c5f --- /dev/null +++ b/extensions/pagetop-menu/src/cache.rs @@ -0,0 +1,73 @@ +//! 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, + /// Traducciones indexadas por `item_id`. + pub translations: HashMap>, +} + +static CACHE: LazyLock>>> = + 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 { + { + 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 = 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> = HashMap::new(); + for t in all_translations { + translations.entry(t.item_id).or_default().push(t); + } + + FlatMenu { + items, + translations, + } +} diff --git a/extensions/pagetop-menu/src/component.rs b/extensions/pagetop-menu/src/component.rs new file mode 100644 index 00000000..01804c2d --- /dev/null +++ b/extensions/pagetop-menu/src/component.rs @@ -0,0 +1,7 @@ +//! Componentes de renderizado de menús. + +mod menu_block; +mod menu_breadcrumb; + +pub use menu_block::MenuBlock; +pub use menu_breadcrumb::MenuBreadcrumb; diff --git a/extensions/pagetop-menu/src/component/menu_block.rs b/extensions/pagetop-menu/src/component/menu_block.rs new file mode 100644 index 00000000..afcd79b4 --- /dev/null +++ b/extensions/pagetop-menu/src/component/menu_block.rs @@ -0,0 +1,198 @@ +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 +/// +/// ``` +/// +/// 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 { +/// Page::new(request) +/// .with_child(MenuBlock::with("main")) +/// .render().await +/// } +/// ``` +#[derive(AutoDefault, Clone, Debug)] +pub struct MenuBlock { + menu_name: Option, + show_title: bool, + max_depth: Option, + 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 { + 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) -> 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>) -> 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>) -> Self { + self.max_depth = v.into(); + self + } + + #[builder_fn] + pub fn with_include_disabled(mut self, v: impl Into>) -> 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>) -> 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), + } +} diff --git a/extensions/pagetop-menu/src/component/menu_breadcrumb.rs b/extensions/pagetop-menu/src/component/menu_breadcrumb.rs new file mode 100644 index 00000000..9d9d95e2 --- /dev/null +++ b/extensions/pagetop-menu/src/component/menu_breadcrumb.rs @@ -0,0 +1,103 @@ +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 { +/// Page::new(request) +/// .with_child(MenuBreadcrumb::with("main")) +/// .render().await +/// } +/// ``` +#[derive(AutoDefault, Clone, Debug)] +pub struct MenuBreadcrumb { + menu_name: Option, + 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 { + 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) -> 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>) -> 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![] +} diff --git a/extensions/pagetop-menu/src/config.rs b/extensions/pagetop-menu/src/config.rs new file mode 100644 index 00000000..27cae08e --- /dev/null +++ b/extensions/pagetop-menu/src/config.rs @@ -0,0 +1,56 @@ +//! 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 = 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, +} + +impl Default for Settings { + fn default() -> Self { + Settings { + default_menus: default_menus(), + } + } +} + +fn default_menus() -> Vec { + vec!["main".into(), "footer".into(), "user".into()] +} diff --git a/extensions/pagetop-menu/src/entity.rs b/extensions/pagetop-menu/src/entity.rs new file mode 100644 index 00000000..3b01e834 --- /dev/null +++ b/extensions/pagetop-menu/src/entity.rs @@ -0,0 +1,6 @@ +//! Entidades SeaORM de `pagetop-menu`. + +pub mod menu; +pub mod menu_item; +pub mod menu_item_translation; +pub mod menu_translation; diff --git a/extensions/pagetop-menu/src/entity/menu.rs b/extensions/pagetop-menu/src/entity/menu.rs new file mode 100644 index 00000000..3206e900 --- /dev/null +++ b/extensions/pagetop-menu/src/entity/menu.rs @@ -0,0 +1,37 @@ +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 for Entity { + fn to() -> RelationDef { + Relation::MenuTranslations.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::MenuItems.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/extensions/pagetop-menu/src/entity/menu_item.rs b/extensions/pagetop-menu/src/entity/menu_item.rs new file mode 100644 index 00000000..bcdc0238 --- /dev/null +++ b/extensions/pagetop-menu/src/entity/menu_item.rs @@ -0,0 +1,47 @@ +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, + pub url: String, + pub weight: i32, + pub enabled: bool, + pub expanded: bool, + pub provider: String, + pub external_key: Option, + 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 for Entity { + fn to() -> RelationDef { + Relation::Menu.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::MenuItemTranslations.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/extensions/pagetop-menu/src/entity/menu_item_translation.rs b/extensions/pagetop-menu/src/entity/menu_item_translation.rs new file mode 100644 index 00000000..e8cae28c --- /dev/null +++ b/extensions/pagetop-menu/src/entity/menu_item_translation.rs @@ -0,0 +1,30 @@ +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 for Entity { + fn to() -> RelationDef { + Relation::MenuItem.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/extensions/pagetop-menu/src/entity/menu_translation.rs b/extensions/pagetop-menu/src/entity/menu_translation.rs new file mode 100644 index 00000000..92b55e78 --- /dev/null +++ b/extensions/pagetop-menu/src/entity/menu_translation.rs @@ -0,0 +1,31 @@ +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, +} + +#[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 for Entity { + fn to() -> RelationDef { + Relation::Menu.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/extensions/pagetop-menu/src/error.rs b/extensions/pagetop-menu/src/error.rs new file mode 100644 index 00000000..5d32b086 --- /dev/null +++ b/extensions/pagetop-menu/src/error.rs @@ -0,0 +1,13 @@ +//! 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), +} diff --git a/extensions/pagetop-menu/src/lib.rs b/extensions/pagetop-menu/src/lib.rs new file mode 100644 index 00000000..9f88ee44 --- /dev/null +++ b/extensions/pagetop-menu/src/lib.rs @@ -0,0 +1,130 @@ +/*! +
+ +

PageTop Menu

+ +

Gestión centralizada y persistente de menús para PageTop.

+ +
+ +## 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 { + vec![&pagetop_menu::Menu] + } + + fn actions(&self) -> Vec { + 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 { + 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 { + 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; + } +} diff --git a/extensions/pagetop-menu/src/locale/en-US/common.ftl b/extensions/pagetop-menu/src/locale/en-US/common.ftl new file mode 100644 index 00000000..b03c8282 --- /dev/null +++ b/extensions/pagetop-menu/src/locale/en-US/common.ftl @@ -0,0 +1,2 @@ +extension_name = PageTop Menu +extension_description = Centralized and persistent menu management for PageTop. diff --git a/extensions/pagetop-menu/src/locale/es-ES/common.ftl b/extensions/pagetop-menu/src/locale/es-ES/common.ftl new file mode 100644 index 00000000..81e96fa9 --- /dev/null +++ b/extensions/pagetop-menu/src/locale/es-ES/common.ftl @@ -0,0 +1,2 @@ +extension_name = PageTop Menú +extension_description = Gestión centralizada y persistente de menús para PageTop. diff --git a/extensions/pagetop-menu/src/migration.rs b/extensions/pagetop-menu/src/migration.rs new file mode 100644 index 00000000..fc0caa81 --- /dev/null +++ b/extensions/pagetop-menu/src/migration.rs @@ -0,0 +1,6 @@ +//! 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; diff --git a/extensions/pagetop-menu/src/migration/m20260629_000001_create_menus.rs b/extensions/pagetop-menu/src/migration/m20260629_000001_create_menus.rs new file mode 100644 index 00000000..4d16a703 --- /dev/null +++ b/extensions/pagetop-menu/src/migration/m20260629_000001_create_menus.rs @@ -0,0 +1,32 @@ +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, +} diff --git a/extensions/pagetop-menu/src/migration/m20260629_000002_create_menu_translations.rs b/extensions/pagetop-menu/src/migration/m20260629_000002_create_menu_translations.rs new file mode 100644 index 00000000..9fe5f045 --- /dev/null +++ b/extensions/pagetop-menu/src/migration/m20260629_000002_create_menu_translations.rs @@ -0,0 +1,58 @@ +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, +} diff --git a/extensions/pagetop-menu/src/migration/m20260629_000003_create_menu_items.rs b/extensions/pagetop-menu/src/migration/m20260629_000003_create_menu_items.rs new file mode 100644 index 00000000..52656a44 --- /dev/null +++ b/extensions/pagetop-menu/src/migration/m20260629_000003_create_menu_items.rs @@ -0,0 +1,87 @@ +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, +} diff --git a/extensions/pagetop-menu/src/migration/m20260629_000004_create_menu_item_translations.rs b/extensions/pagetop-menu/src/migration/m20260629_000004_create_menu_item_translations.rs new file mode 100644 index 00000000..2d883ead --- /dev/null +++ b/extensions/pagetop-menu/src/migration/m20260629_000004_create_menu_item_translations.rs @@ -0,0 +1,56 @@ +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, +} diff --git a/extensions/pagetop-menu/src/repo.rs b/extensions/pagetop-menu/src/repo.rs new file mode 100644 index 00000000..4db2cea5 --- /dev/null +++ b/extensions/pagetop-menu/src/repo.rs @@ -0,0 +1,309 @@ +//! 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, + /// 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, +} + +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) -> 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, title: impl Into) -> Self { + self.titles.push((lang.into(), title.into())); + self + } + + pub fn with_url(mut self, u: impl Into) -> 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) -> Self { + self.provider = p.into(); + self + } + + pub fn with_external_key(mut self, k: impl Into) -> Self { + self.external_key = Some(k.into()); + self + } + + pub fn with_parent_key(mut self, k: impl Into) -> 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 { + 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 { + 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) -> Option { + 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(()) +} diff --git a/extensions/pagetop-menu/src/seed.rs b/extensions/pagetop-menu/src/seed.rs new file mode 100644 index 00000000..0ee30b5e --- /dev/null +++ b/extensions/pagetop-menu/src/seed.rs @@ -0,0 +1,60 @@ +//! 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(), + } +} diff --git a/extensions/pagetop-menu/src/tree.rs b/extensions/pagetop-menu/src/tree.rs new file mode 100644 index 00000000..0df23a53 --- /dev/null +++ b/extensions/pagetop-menu/src/tree.rs @@ -0,0 +1,289 @@ +//! 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 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 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 `` 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 { + (!url.is_empty() && url != "").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 { + 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, + /// 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 (`` o URL vacía): título de sección. + pub url: Option, + pub weight: i32, + pub depth: u8, + pub enabled: bool, + pub expanded: bool, + pub provider: String, + pub external_key: Option, + /// Atributos HTML adicionales inyectados por `DecorateMenuItem` en tiempo de render. + pub attrs: HashMap, + pub children: Vec, + /// `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, +} + +impl MenuTree { + /// Aplica `f` recursivamente a todos los nodos del árbol (post-orden). + pub fn walk_mut(&mut self, f: &mut F) { + walk_nodes_mut(&mut self.roots, f); + } +} + +fn walk_nodes_mut(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 { + 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, + depth: u8, + opts: &TreeOptions, + lang: &str, +) -> Vec { + if opts.max_depth.map(|d| depth > d).unwrap_or(false) { + return vec![]; + } + + let mut nodes: Vec = 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 + 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() +}