diff --git a/Cargo.lock b/Cargo.lock index 30f588d2..e00901ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1884,8 +1884,10 @@ dependencies = [ "chrono", "pagetop", "pagetop-seaorm", + "sea-orm", "serde", "thiserror", + "tokio", ] [[package]] diff --git a/extensions/pagetop-admin/Cargo.toml b/extensions/pagetop-admin/Cargo.toml deleted file mode 100644 index 662c62b9..00000000 --- a/extensions/pagetop-admin/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "pagetop-admin" -description = "Panel de administración extensible para PageTop." -version = "0.1.0" -categories = ["web-programming", "development-tools"] -keywords = ["pagetop", "admin", "cms", "settings", "ssr"] - -repository.workspace = true -homepage.workspace = true -edition.workspace = true -license.workspace = true -authors.workspace = true - -[dependencies] -pagetop.workspace = true -pagetop-seaorm.workspace = true -serde.workspace = true -serde_json.workspace = true -thiserror.workspace = true diff --git a/extensions/pagetop-admin/src/action.rs b/extensions/pagetop-admin/src/action.rs deleted file mode 100644 index 019b839a..00000000 --- a/extensions/pagetop-admin/src/action.rs +++ /dev/null @@ -1,291 +0,0 @@ -//! Tipos de acción que `pagetop-admin` expone para que otras extensiones registren -//! secciones, páginas, tareas y acciones en el panel de administración. -//! -//! El flujo de `registry::build()` despacha estas acciones en este orden: -//! -//! 1. `DeclareAdminSections` - secciones (agrupaciones del sidebar). -//! 2. `DeclareAdminPages` - páginas del panel. -//! 3. `DeclareAdminTasks` - tareas (pestañas) locales por página. -//! 4. `DeclareAdminActions` - acciones locales (botones) por página. - -use pagetop::prelude::*; - -use crate::registry::{AdminAction, AdminPage, AdminSection, AdminTask}; - -// **< Tipo de callbacks >************************************************************************** - -pub type FnSectionBag = fn(&mut SectionBag); -pub type FnPageBag = fn(&mut PageBag); -pub type FnTaskBag = fn(&mut TaskBag); -pub type FnActionBag = fn(&mut ActionBag); - -// **< Bolsas de declaración >********************************************************************** - -/// Bolsa de secciones para [`DeclareAdminSections`]. -pub struct SectionBag { - pub(crate) sections: Vec, -} - -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 deleted file mode 100644 index e6325daf..00000000 --- a/extensions/pagetop-admin/src/component.rs +++ /dev/null @@ -1,9 +0,0 @@ -//! Componentes de renderizado del panel de administración. - -mod admin_frame; -mod admin_menu; -mod config_form; - -pub use admin_frame::AdminFrame; -pub use admin_menu::AdminMenu; -pub use config_form::ConfigForm; diff --git a/extensions/pagetop-admin/src/component/admin_frame.rs b/extensions/pagetop-admin/src/component/admin_frame.rs deleted file mode 100644 index 8a63ed23..00000000 --- a/extensions/pagetop-admin/src/component/admin_frame.rs +++ /dev/null @@ -1,150 +0,0 @@ -use pagetop::base::component::breadcrumb; -use pagetop::prelude::*; - -use crate::LOCALES_ADMIN; -use crate::registry; - -/// Componente de layout principal de una página del panel de administración. -/// -/// Renderiza breadcrumb, encabezado (título + acciones locales), tareas locales (pestañas) y el -/// contenido de la página. No incluye ningún menú de navegación: mostrar las secciones del panel -/// (el "sidebar" o el "menú superior", según el tema) es responsabilidad del tema que intercepte -/// [`CoreTemplates::Admin`](pagetop::core::theme::CoreTemplates::Admin) -- por ejemplo -/// `pagetop-bootsier` -- leyendo directamente [`crate::registry::global()`]. El tema básico de -/// PageTop no lo hace, y una página de administración sigue siendo completamente navegable sin él, -/// a través del *dashboard* (`/admin`), las páginas de sección y este mismo breadcrumb. -/// -/// ```html -/// -///
-///

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 deleted file mode 100644 index ad3f1963..00000000 --- a/extensions/pagetop-admin/src/component/admin_menu.rs +++ /dev/null @@ -1,37 +0,0 @@ -use pagetop::prelude::*; - -use crate::registry; - -/// Componente que renderiza el menú de secciones visibles del panel de administración. -/// -/// Construye el [`Nav`] en cada petición a partir de [`registry::admin_menu()`], ya filtrado por -/// el usuario de la petición actual. Pensado para que un tema lo registre en su propia región de -/// navegación (p. ej. `pagetop-bootsier` lo añade a su sidebar) e intercepte `Nav`/`nav::Item` en -/// [`Theme::handle_component()`](pagetop::core::theme::Theme::handle_component) si quiere darle un -/// aspecto propio; sin intercepción, se renderiza con el marcado por defecto de [`Nav`]. -/// -/// Sólo se renderiza en páginas creadas con -/// [`Page::admin()`](pagetop::response::Page::admin) (plantilla -/// [`CoreTemplates::Admin`](pagetop::core::theme::CoreTemplates::Admin)). Es necesario comprobarlo -/// explícitamente porque, si se registra en una región de propósito general como -/// [`CoreRegions::Aside`](pagetop::core::theme::CoreRegions::Aside), se renderizaría también en -/// páginas `Standard` si no se autolimitara. -#[derive(AutoDefault, Clone, Debug)] -pub struct AdminMenu; - -#[async_trait] -impl Component for AdminMenu { - fn new() -> Self { - Self - } - - async fn prepare(&self, cx: &mut Context) -> Result { - 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 deleted file mode 100644 index 304fb621..00000000 --- a/extensions/pagetop-admin/src/component/config_form.rs +++ /dev/null @@ -1,226 +0,0 @@ -use pagetop::prelude::*; - -use crate::LOCALES_ADMIN; -use crate::settings::{SettingFieldType, SettingsSchema, get_or}; - -/// Componente que renderiza un formulario de configuración persistente. -/// -/// Genera campos HTML a partir de un [`SettingsSchema`] y carga los valores -/// actuales desde `settings`. El POST es procesado por el handler interno -/// `config_form_post`. -/// -/// # Ejemplo -/// -/// ```rust,no_run -/// use pagetop::prelude::*; -/// use pagetop_admin::component::{AdminFrame, ConfigForm}; -/// use pagetop_admin::settings::{SettingField, SettingsSchema}; -/// -/// async fn settings_handler(request: HttpRequest) -> Result { -/// 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 deleted file mode 100644 index 75e3a9d0..00000000 --- a/extensions/pagetop-admin/src/entity.rs +++ /dev/null @@ -1,3 +0,0 @@ -//! 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 deleted file mode 100644 index 37b0329a..00000000 --- a/extensions/pagetop-admin/src/entity/setting.rs +++ /dev/null @@ -1,20 +0,0 @@ -use pagetop_seaorm::db::*; - -use pagetop::datetime::NaiveDateTime; - -/// Entidad SeaORM para la tabla `settings`. -#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] -#[sea_orm(table_name = "settings")] -pub struct Model { - #[sea_orm(primary_key, auto_increment = false)] - pub key: String, - pub scope: String, - pub value: String, - pub updated_at: NaiveDateTime, - pub updated_by: Option, -} - -#[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 deleted file mode 100644 index 318e82f4..00000000 --- a/extensions/pagetop-admin/src/error.rs +++ /dev/null @@ -1,14 +0,0 @@ -use thiserror::Error; - -/// Errores que puede producir la extensión `pagetop-admin`. -#[derive(Debug, Error)] -pub enum AdminError { - #[error("settings DB error: {0}")] - DbError(#[from] pagetop_seaorm::db::DbErr), - - #[error("settings serialization error: {0}")] - SerializeError(#[from] serde_json::Error), - - #[error("setting key not found: {0}")] - NotFound(String), -} diff --git a/extensions/pagetop-admin/src/handlers.rs b/extensions/pagetop-admin/src/handlers.rs deleted file mode 100644 index 8a512d08..00000000 --- a/extensions/pagetop-admin/src/handlers.rs +++ /dev/null @@ -1,207 +0,0 @@ -use std::collections::HashMap; - -use pagetop::prelude::*; - -use crate::LOCALES_ADMIN; -use crate::component::{AdminFrame, ConfigForm}; -use crate::registry::{self, AdminPageKind, AdminPermission, AdminSection}; -use crate::settings; - -// **< Dashboard >********************************************************************************** - -/// GET /admin - Dashboard de administración: lista todas las secciones disponibles. -pub async fn dashboard(request: HttpRequest) -> Result { - 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 deleted file mode 100644 index dfa7eab5..00000000 --- a/extensions/pagetop-admin/src/lib.rs +++ /dev/null @@ -1,194 +0,0 @@ -/*! -
- -

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 deleted file mode 100644 index 31e38061..00000000 --- a/extensions/pagetop-admin/src/locale/en-US/common.ftl +++ /dev/null @@ -1,21 +0,0 @@ -extension_name = PageTop Admin -extension_description = Extensible administration panel for PageTop. - -# Dashboard -dashboard-title = Administration -dashboard-description = Manage your site. - -# Sections -section-people = People -section-structure = Structure -section-config = Configuration -section-reports = Reports -section-help = Help - -# Config form -config-save-label = Save configuration -config-saved-ok = Configuration saved. -config-saved-error = Could not save configuration. - -# Navigation -admin-breadcrumb-home = Administration diff --git a/extensions/pagetop-admin/src/locale/es-ES/common.ftl b/extensions/pagetop-admin/src/locale/es-ES/common.ftl deleted file mode 100644 index aa3b3eda..00000000 --- a/extensions/pagetop-admin/src/locale/es-ES/common.ftl +++ /dev/null @@ -1,21 +0,0 @@ -extension_name = PageTop Admin -extension_description = Panel de administración extensible para PageTop. - -# Dashboard -dashboard-title = Administración -dashboard-description = Gestiona tu sitio. - -# Sections -section-people = Personas -section-structure = Estructura -section-config = Configuración -section-reports = Informes -section-help = Ayuda - -# Config form -config-save-label = Guardar configuración -config-saved-ok = Configuración guardada. -config-saved-error = No se pudo guardar la configuración. - -# Navigation -admin-breadcrumb-home = Administración diff --git a/extensions/pagetop-admin/src/migration.rs b/extensions/pagetop-admin/src/migration.rs deleted file mode 100644 index f940a5d1..00000000 --- a/extensions/pagetop-admin/src/migration.rs +++ /dev/null @@ -1,3 +0,0 @@ -//! 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 deleted file mode 100644 index 598d72c3..00000000 --- a/extensions/pagetop-admin/src/migration/m20260629_000001_create_settings.rs +++ /dev/null @@ -1,49 +0,0 @@ -use pagetop_seaorm::migration::*; - -/// Tabla `settings`: almacén clave-valor JSON para configuración persistente. -pub struct Migration; - -#[pagetop::async_trait] -impl MigrationTrait for Migration { - async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .create_table( - Table::create() - .table(Settings::Table) - .if_not_exists() - .col(string_len(Settings::Key, 190).primary_key()) - .col(string_len(Settings::Scope, 64)) - .col(text(Settings::Value)) - .col(timestamp(Settings::UpdatedAt).default(Expr::current_timestamp())) - .col(integer_null(Settings::UpdatedBy)) - .to_owned(), - ) - .await?; - - manager - .create_index( - Index::create() - .name("idx_settings_scope") - .table(Settings::Table) - .col(Settings::Scope) - .to_owned(), - ) - .await - } - - async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .drop_table(Table::drop().table(Settings::Table).to_owned()) - .await - } -} - -#[derive(DeriveIden)] -enum Settings { - Table, - Key, - Scope, - Value, - UpdatedAt, - UpdatedBy, -} diff --git a/extensions/pagetop-admin/src/registry.rs b/extensions/pagetop-admin/src/registry.rs deleted file mode 100644 index 1144842c..00000000 --- a/extensions/pagetop-admin/src/registry.rs +++ /dev/null @@ -1,295 +0,0 @@ -//! Registro global de secciones, páginas, tareas y acciones del panel de administración. -//! -//! Se construye una única vez durante `Extension::initialize()` y permanece -//! inmutable durante la vida de la aplicación. - -use std::collections::BTreeMap; -use std::sync::OnceLock; - -use pagetop::prelude::*; - -use crate::action::{ - ActionBag, DeclareAdminActions, DeclareAdminPages, DeclareAdminSections, DeclareAdminTasks, - PageBag, SectionBag, TaskBag, -}; -use crate::settings::SettingsSchema; - -// **< AdminPermission >***************************************************************************** - -/// Permisos propios de `pagetop-admin`. -#[derive(Clone, Copy, Debug)] -pub enum AdminPermission { - /// Acceso por defecto a una página de administración que no declara un permiso propio. - Access, - /// Acceso a la sección integrada "people". - AccessPeople, - /// Acceso a la sección integrada "structure". - AccessStructure, - /// Acceso a la sección integrada "config". - AccessConfig, - /// Acceso a la sección integrada "reports". - AccessReports, -} - -impl Permission for AdminPermission { - fn key(&self) -> CowStr { - match self { - Self::Access => "admin:access".into(), - Self::AccessPeople => "admin.access_people".into(), - Self::AccessStructure => "admin.access_structure".into(), - Self::AccessConfig => "admin.access_config".into(), - Self::AccessReports => "admin.access_reports".into(), - } - } -} - -// **< Tipos del registro >************************************************************************** - -/// Sección del panel de administración (agrupación en el sidebar). -#[derive(Clone)] -pub struct AdminSection { - /// Identificador único de la sección (p. ej. `"config"`). - pub key: String, - /// Ruta base de la sección (p. ej. `"/admin/config"`). - pub path: String, - /// Título visible en el sidebar. - pub title: Lc, - /// Permiso requerido para ver la sección (`None` = siempre visible). - pub permission: Option, - /// 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 deleted file mode 100644 index cf4c6640..00000000 --- a/extensions/pagetop-admin/src/seed.rs +++ /dev/null @@ -1,50 +0,0 @@ -use pagetop::prelude::*; - -use crate::ADMIN_BASE_PATH; -use crate::LOCALES_ADMIN; -use crate::action::SectionBag; -use crate::registry::{AdminPermission, AdminSection}; - -/// Declara las secciones incorporadas del panel de administración. -/// -/// Se registra en `Admin::actions()` como `DeclareAdminSections::new(declare_default_sections)` -/// con peso negativo para ejecutarse antes que las extensiones de terceros. -pub(crate) fn declare_default_sections(bag: &mut SectionBag) { - let base = ADMIN_BASE_PATH; - - bag.add(AdminSection { - key: "people".to_owned(), - path: format!("{}/people", base), - title: Lc::t("section-people", &LOCALES_ADMIN), - permission: Some(&AdminPermission::AccessPeople), - weight: 10, - }); - bag.add(AdminSection { - key: "structure".to_owned(), - path: format!("{}/structure", base), - title: Lc::t("section-structure", &LOCALES_ADMIN), - permission: Some(&AdminPermission::AccessStructure), - weight: 20, - }); - bag.add(AdminSection { - key: "config".to_owned(), - path: format!("{}/config", base), - title: Lc::t("section-config", &LOCALES_ADMIN), - permission: Some(&AdminPermission::AccessConfig), - weight: 30, - }); - bag.add(AdminSection { - key: "reports".to_owned(), - path: format!("{}/reports", base), - title: Lc::t("section-reports", &LOCALES_ADMIN), - permission: Some(&AdminPermission::AccessReports), - weight: 40, - }); - bag.add(AdminSection { - key: "help".to_owned(), - path: format!("{}/help", base), - title: Lc::t("section-help", &LOCALES_ADMIN), - permission: None, - weight: 50, - }); -} diff --git a/extensions/pagetop-admin/src/settings.rs b/extensions/pagetop-admin/src/settings.rs deleted file mode 100644 index 0eb3028a..00000000 --- a/extensions/pagetop-admin/src/settings.rs +++ /dev/null @@ -1,238 +0,0 @@ -//! Almacén de configuración persistente basado en `settings`. -//! -//! Proporciona una API async para leer y escribir valores JSON en la tabla `settings`. - -use pagetop::datetime::Utc; -use pagetop::{Getters, builder_fn}; -use pagetop_seaorm::db::{ - ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter, dbconn, -}; -use serde::{Serialize, de::DeserializeOwned}; - -use crate::entity::setting::{ActiveModel, Column, Entity}; -use crate::error::AdminError; - -// **< API pública >********************************************************************************* - -/// Lee un valor persistido, devolviendo el `Default` del tipo si no existe. -pub async fn get(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) - } -} diff --git a/extensions/pagetop-menu/Cargo.toml b/extensions/pagetop-menu/Cargo.toml deleted file mode 100644 index bd8d676d..00000000 --- a/extensions/pagetop-menu/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "pagetop-menu" -description = "Gestión centralizada y persistente de menús para PageTop." -version = "0.1.0" -categories = ["web-programming", "development-tools"] -keywords = ["pagetop", "menu", "navigation", "cms", "ssr"] - -repository.workspace = true -homepage.workspace = true -edition.workspace = true -license.workspace = true -authors.workspace = true - -[dependencies] -pagetop.workspace = true -pagetop-seaorm.workspace = true -chrono.workspace = true -serde.workspace = true -thiserror.workspace = true diff --git a/extensions/pagetop-menu/src/action.rs b/extensions/pagetop-menu/src/action.rs deleted file mode 100644 index 3dce5434..00000000 --- a/extensions/pagetop-menu/src/action.rs +++ /dev/null @@ -1,312 +0,0 @@ -//! Tipos de acción que `pagetop-menu` expone para que otras extensiones extiendan el sistema. -//! -//! El flujo de `build_tree()` despacha estas acciones en este orden: -//! -//! 1. `AlterMenuTree` - filtros, reordenamientos, inyección de nodos en caliente. -//! 2. Cálculo del active trail por URL. -//! 3. `ResolveActiveTrail` - matching personalizado para rutas paramétricas. -//! 4. `DecorateMenuItem` - añadir atributos HTML a nodos individuales. -//! -//! El sembrado inicial usa `DeclareDefaultMenus` y `DeclareDefaultMenuItems`. - -use pagetop::prelude::*; - -use crate::repo::NewMenuItem; -use crate::tree::{MenuNode, MenuTree}; - -// **< Tipo de callbacks >************************************************************************** - -pub type FnMenuDefs = fn(&mut MenuDefs); -pub type FnItemBag = fn(&mut ItemBag); -pub type FnAlterTree = fn(&mut MenuTree, &Context); -pub type FnDecorate = fn(&mut MenuNode, &Context); - -// **< MenuDefs >*********************************************************************************** - -/// Bolsa de declaraciones de menú para `DeclareDefaultMenus`. -pub struct MenuDefs { - pub(crate) entries: Vec<(String, String)>, -} - -impl MenuDefs { - /// Declara que el menú `machine_name` debe existir con el título dado. - /// Si ya existe en la lista, no se añade de nuevo. - pub fn ensure(&mut self, machine_name: impl Into, 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 deleted file mode 100644 index 599c9c5f..00000000 --- a/extensions/pagetop-menu/src/cache.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! Caché in-process de los ítems de menú por `machine_name`. -//! -//! Almacena la lista plana de modelos y sus traducciones para cada menú. El árbol se -//! reconstruye en memoria en cada petición a partir de esta estructura. Hoy no existe ningún -//! camino que escriba ítems tras el sembrado inicial (`seed::run()`, antes de la primera -//! petición), así que no hace falta invalidación: cuando exista una vía de escritura en caliente -//! (p. ej. una UI de administración), deberá invalidar la entrada correspondiente aquí. - -use std::collections::HashMap; -use std::sync::LazyLock; -use std::sync::{Arc, RwLock}; - -use crate::entity::{menu_item, menu_item_translation}; - -/// Contenido plano de un menú listo para construir el árbol. -pub struct FlatMenu { - pub items: Vec, - /// 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 deleted file mode 100644 index 01804c2d..00000000 --- a/extensions/pagetop-menu/src/component.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! Componentes de renderizado de menús. - -mod menu_block; -mod menu_breadcrumb; - -pub use menu_block::MenuBlock; -pub use menu_breadcrumb::MenuBreadcrumb; diff --git a/extensions/pagetop-menu/src/component/menu_block.rs b/extensions/pagetop-menu/src/component/menu_block.rs deleted file mode 100644 index afcd79b4..00000000 --- a/extensions/pagetop-menu/src/component/menu_block.rs +++ /dev/null @@ -1,198 +0,0 @@ -use pagetop::prelude::*; - -use crate::tree::{MenuKey, MenuNode, TreeOptions, build_tree, try_resolve_menu_url}; - -/// Renderiza un menú completo como bloque de navegación HTML. -/// -/// Se construye componiendo [`Nav`] y [`nav::Item`] -- con [`Dropdown`]/[`dropdown::Item`] para -/// los nodos con hijos -- a partir del árbol del menú, así que produce el mismo marcado accesible -/// que cualquier `Nav`/`Dropdown` y se beneficia igual del CSS/JavaScript que aporta el tema -/// activo: -/// -/// ```html -/// -/// ``` -/// -/// 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 deleted file mode 100644 index 9d9d95e2..00000000 --- a/extensions/pagetop-menu/src/component/menu_breadcrumb.rs +++ /dev/null @@ -1,103 +0,0 @@ -use pagetop::base::component::breadcrumb; -use pagetop::prelude::*; - -use crate::tree::{MenuKey, MenuNode, TreeOptions, build_tree, try_resolve_menu_url}; - -/// Migas de pan del menú dado, basándose en el active trail. -/// -/// Resuelve el árbol del menú y su active trail (de forma asíncrona, en su propio -/// [`prepare()`](Component::prepare)) y delega el renderizado en -/// [`Breadcrumb`](pagetop::base::component::Breadcrumb): esta extensión sólo aporta los datos, la -/// estructura HTML y las clases CSS son responsabilidad del componente base. -/// -/// # Ejemplo -/// -/// ```rust,no_run -/// use pagetop::prelude::*; -/// use pagetop_menu::component::MenuBreadcrumb; -/// -/// async fn handler(request: HttpRequest) -> Result { -/// 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 deleted file mode 100644 index 27cae08e..00000000 --- a/extensions/pagetop-menu/src/config.rs +++ /dev/null @@ -1,56 +0,0 @@ -//! Configuración de `pagetop-menu`. -//! -//! Todos los valores pueden sobreescribirse en los ficheros TOML de la aplicación: -//! -//! ```toml -//! [menu] -//! default_menus = ["main", "footer", "user"] -//! ``` - -use pagetop::prelude::*; -use serde::Deserialize; - -use std::sync::LazyLock; - -// **< CONFIG_MENU >******************************************************************************** - -include_config!(CONFIG_MENU: MenuTopConfig => [ - // Menús que se crean automáticamente si no los declara ninguna extensión. - // (Nota: los valores de lista no son soportados por config-rs vía set_default; se leen del - // TOML.) -]); - -// **< MenuTopConfig >****************************************************************************** - -/// Estructura raíz para la sección `[menu]` del fichero de configuración. -#[derive(Clone, Debug, Deserialize)] -pub struct MenuTopConfig { - pub menu: Settings, -} - -// **< SETTINGS >*********************************************************************************** - -/// Acceso directo a los ajustes de `pagetop-menu` (alias de `CONFIG_MENU.menu`). -pub static SETTINGS: LazyLock = 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 deleted file mode 100644 index 3b01e834..00000000 --- a/extensions/pagetop-menu/src/entity.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! Entidades SeaORM de `pagetop-menu`. - -pub mod menu; -pub mod menu_item; -pub mod menu_item_translation; -pub mod menu_translation; diff --git a/extensions/pagetop-menu/src/entity/menu.rs b/extensions/pagetop-menu/src/entity/menu.rs deleted file mode 100644 index 3206e900..00000000 --- a/extensions/pagetop-menu/src/entity/menu.rs +++ /dev/null @@ -1,37 +0,0 @@ -use pagetop_seaorm::db::*; - -use chrono::NaiveDateTime; - -#[derive(Clone, Debug, DeriveEntityModel, PartialEq)] -#[sea_orm(table_name = "menus")] -pub struct Model { - #[sea_orm(primary_key)] - pub id: i32, - #[sea_orm(unique)] - pub machine_name: String, - pub locked: bool, - pub created_at: NaiveDateTime, - pub updated_at: NaiveDateTime, -} - -#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)] -pub enum Relation { - #[sea_orm(has_many = "super::menu_translation::Entity")] - MenuTranslations, - #[sea_orm(has_many = "super::menu_item::Entity")] - MenuItems, -} - -impl Related 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 deleted file mode 100644 index bcdc0238..00000000 --- a/extensions/pagetop-menu/src/entity/menu_item.rs +++ /dev/null @@ -1,47 +0,0 @@ -use pagetop_seaorm::db::*; - -use chrono::NaiveDateTime; - -#[derive(Clone, Debug, DeriveEntityModel, PartialEq)] -#[sea_orm(table_name = "menu_items")] -pub struct Model { - #[sea_orm(primary_key)] - pub id: i32, - pub menu_id: i32, - pub parent_id: Option, - 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 deleted file mode 100644 index e8cae28c..00000000 --- a/extensions/pagetop-menu/src/entity/menu_item_translation.rs +++ /dev/null @@ -1,30 +0,0 @@ -use pagetop_seaorm::db::*; - -#[derive(Clone, Debug, DeriveEntityModel, PartialEq)] -#[sea_orm(table_name = "menu_item_translations")] -pub struct Model { - #[sea_orm(primary_key, auto_increment = false)] - pub item_id: i32, - #[sea_orm(primary_key, auto_increment = false)] - pub lang: String, - pub title: String, -} - -#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)] -pub enum Relation { - #[sea_orm( - belongs_to = "super::menu_item::Entity", - from = "Column::ItemId", - to = "super::menu_item::Column::Id", - on_delete = "Cascade" - )] - MenuItem, -} - -impl Related 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 deleted file mode 100644 index 92b55e78..00000000 --- a/extensions/pagetop-menu/src/entity/menu_translation.rs +++ /dev/null @@ -1,31 +0,0 @@ -use pagetop_seaorm::db::*; - -#[derive(Clone, Debug, DeriveEntityModel, PartialEq)] -#[sea_orm(table_name = "menu_translations")] -pub struct Model { - #[sea_orm(primary_key, auto_increment = false)] - pub menu_id: i32, - #[sea_orm(primary_key, auto_increment = false)] - pub lang: String, - pub title: String, - pub description: Option, -} - -#[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 deleted file mode 100644 index 5d32b086..00000000 --- a/extensions/pagetop-menu/src/error.rs +++ /dev/null @@ -1,13 +0,0 @@ -//! Tipos de error de `pagetop-menu`. - -use thiserror::Error; - -/// Errores que puede producir `pagetop-menu`. -#[derive(Debug, Error)] -pub enum MenuError { - #[error("invalid machine name: {0}")] - InvalidName(String), - - #[error("database error: {0}")] - Database(#[from] pagetop_seaorm::db::DbErr), -} diff --git a/extensions/pagetop-menu/src/lib.rs b/extensions/pagetop-menu/src/lib.rs deleted file mode 100644 index 9f88ee44..00000000 --- a/extensions/pagetop-menu/src/lib.rs +++ /dev/null @@ -1,130 +0,0 @@ -/*! -
- -

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 deleted file mode 100644 index b03c8282..00000000 --- a/extensions/pagetop-menu/src/locale/en-US/common.ftl +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index 81e96fa9..00000000 --- a/extensions/pagetop-menu/src/locale/es-ES/common.ftl +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index fc0caa81..00000000 --- a/extensions/pagetop-menu/src/migration.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! Migraciones de `pagetop-menu`. - -pub mod m20260629_000001_create_menus; -pub mod m20260629_000002_create_menu_translations; -pub mod m20260629_000003_create_menu_items; -pub mod m20260629_000004_create_menu_item_translations; diff --git a/extensions/pagetop-menu/src/migration/m20260629_000001_create_menus.rs b/extensions/pagetop-menu/src/migration/m20260629_000001_create_menus.rs deleted file mode 100644 index 4d16a703..00000000 --- a/extensions/pagetop-menu/src/migration/m20260629_000001_create_menus.rs +++ /dev/null @@ -1,32 +0,0 @@ -use pagetop_seaorm::migration::*; - -pub struct Migration; - -#[pagetop::async_trait] -impl MigrationTrait for Migration { - async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .create_table( - table_auto(Menus::Table) - .col(pk_auto(Menus::Id)) - .col(string_len_uniq(Menus::MachineName, 64)) - .col(boolean(Menus::Locked).default(false)) - .to_owned(), - ) - .await - } - - async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .drop_table(Table::drop().table(Menus::Table).to_owned()) - .await - } -} - -#[derive(DeriveIden)] -pub enum Menus { - Table, - Id, - MachineName, - Locked, -} 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 deleted file mode 100644 index 9fe5f045..00000000 --- a/extensions/pagetop-menu/src/migration/m20260629_000002_create_menu_translations.rs +++ /dev/null @@ -1,58 +0,0 @@ -use pagetop_seaorm::migration::*; - -pub struct Migration; - -#[pagetop::async_trait] -impl MigrationTrait for Migration { - async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .create_table( - Table::create() - .table(MenuTranslations::Table) - .if_not_exists() - .col(integer(MenuTranslations::MenuId)) - .col(string_len(MenuTranslations::Lang, 35)) - .col(string_len(MenuTranslations::Title, 128)) - .col(text_null(MenuTranslations::Description)) - .primary_key( - Index::create() - .col(MenuTranslations::MenuId) - .col(MenuTranslations::Lang), - ) - .to_owned(), - ) - .await?; - - manager - .create_foreign_key( - ForeignKey::create() - .name("fk_menu_translations_menu_id") - .from(MenuTranslations::Table, MenuTranslations::MenuId) - .to(Menus::Table, Menus::Id) - .on_delete(ForeignKeyAction::Cascade) - .to_owned(), - ) - .await - } - - async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .drop_table(Table::drop().table(MenuTranslations::Table).to_owned()) - .await - } -} - -#[derive(DeriveIden)] -enum MenuTranslations { - Table, - MenuId, - Lang, - Title, - Description, -} - -#[derive(DeriveIden)] -enum Menus { - Table, - Id, -} 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 deleted file mode 100644 index 52656a44..00000000 --- a/extensions/pagetop-menu/src/migration/m20260629_000003_create_menu_items.rs +++ /dev/null @@ -1,87 +0,0 @@ -use pagetop_seaorm::migration::*; - -pub struct Migration; - -#[pagetop::async_trait] -impl MigrationTrait for Migration { - async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .create_table( - table_auto(MenuItems::Table) - .col(pk_auto(MenuItems::Id)) - .col(integer(MenuItems::MenuId)) - .col(integer_null(MenuItems::ParentId)) - .col(string_len(MenuItems::Url, 2048)) - .col(integer(MenuItems::Weight).default(0)) - .col(boolean(MenuItems::Enabled).default(true)) - .col(boolean(MenuItems::Expanded).default(false)) - .col(string_len(MenuItems::Provider, 64).default("user")) - .col(string_len_null(MenuItems::ExternalKey, 128)) - .to_owned(), - ) - .await?; - - manager - .create_foreign_key( - ForeignKey::create() - .name("fk_menu_items_menu_id") - .from(MenuItems::Table, MenuItems::MenuId) - .to(Menus::Table, Menus::Id) - .on_delete(ForeignKeyAction::Cascade) - .to_owned(), - ) - .await?; - - // Índice para listar hijos ordenados. - manager - .create_index( - Index::create() - .name("idx_menu_items_menu_parent_weight") - .table(MenuItems::Table) - .col(MenuItems::MenuId) - .col(MenuItems::ParentId) - .col(MenuItems::Weight) - .to_owned(), - ) - .await?; - - // Índice de unicidad para upserts por extensión. - manager - .create_index( - Index::create() - .name("idx_menu_items_provider_key") - .table(MenuItems::Table) - .col(MenuItems::Provider) - .col(MenuItems::ExternalKey) - .unique() - .to_owned(), - ) - .await - } - - async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .drop_table(Table::drop().table(MenuItems::Table).to_owned()) - .await - } -} - -#[derive(DeriveIden)] -pub enum MenuItems { - Table, - Id, - MenuId, - ParentId, - Url, - Weight, - Enabled, - Expanded, - Provider, - ExternalKey, -} - -#[derive(DeriveIden)] -enum Menus { - Table, - Id, -} 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 deleted file mode 100644 index 2d883ead..00000000 --- a/extensions/pagetop-menu/src/migration/m20260629_000004_create_menu_item_translations.rs +++ /dev/null @@ -1,56 +0,0 @@ -use pagetop_seaorm::migration::*; - -pub struct Migration; - -#[pagetop::async_trait] -impl MigrationTrait for Migration { - async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .create_table( - Table::create() - .table(MenuItemTranslations::Table) - .if_not_exists() - .col(integer(MenuItemTranslations::ItemId)) - .col(string_len(MenuItemTranslations::Lang, 35)) - .col(string_len(MenuItemTranslations::Title, 255)) - .primary_key( - Index::create() - .col(MenuItemTranslations::ItemId) - .col(MenuItemTranslations::Lang), - ) - .to_owned(), - ) - .await?; - - manager - .create_foreign_key( - ForeignKey::create() - .name("fk_menu_item_translations_item_id") - .from(MenuItemTranslations::Table, MenuItemTranslations::ItemId) - .to(MenuItems::Table, MenuItems::Id) - .on_delete(ForeignKeyAction::Cascade) - .to_owned(), - ) - .await - } - - async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .drop_table(Table::drop().table(MenuItemTranslations::Table).to_owned()) - .await - } -} - -#[derive(DeriveIden)] -enum MenuItemTranslations { - Table, - ItemId, - Lang, - Title, -} - -#[derive(DeriveIden)] -enum MenuItems { - Table, - Id, -} diff --git a/extensions/pagetop-menu/src/repo.rs b/extensions/pagetop-menu/src/repo.rs deleted file mode 100644 index 4db2cea5..00000000 --- a/extensions/pagetop-menu/src/repo.rs +++ /dev/null @@ -1,309 +0,0 @@ -//! Operaciones de base de datos para menús e ítems de menú. - -use chrono::Utc; - -use pagetop::locale::Locale; -use pagetop_seaorm::db::{ - ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, EntityTrait, QueryFilter, Set, dbconn, -}; - -use crate::entity::{menu, menu_item, menu_item_translation, menu_translation}; -use crate::error::MenuError; -use crate::tree::MenuKey; - -// **< Tipos de entrada >*************************************************************************** - -/// Datos necesarios para crear un nuevo menú. -pub struct NewMenu { - pub machine_name: String, - /// Títulos por idioma: `(lang, title)`. Al menos uno es obligatorio. - pub titles: Vec<(String, String)>, - pub locked: bool, -} - -/// Datos de un ítem nuevo o para hacer upsert. -pub struct NewMenuItem { - pub parent_key: Option, - /// 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 deleted file mode 100644 index 0ee30b5e..00000000 --- a/extensions/pagetop-menu/src/seed.rs +++ /dev/null @@ -1,60 +0,0 @@ -//! Sembrado inicial de menús e ítems declarados por extensiones. -//! -//! Se invoca desde `Extension::initialize()` después de aplicar las migraciones. -//! Es idempotente: puede ejecutarse en cada arranque sin duplicar datos. - -use crate::action::{DeclareDefaultMenuItems, DeclareDefaultMenus, ItemBag, MenuDefs}; -use crate::config::SETTINGS; -use crate::repo; - -/// Ejecuta el sembrado completo: -/// -/// 1. Recoge los menús declarados por extensiones vía `DeclareDefaultMenus`. -/// 2. Asegura que los menús configurados en `menu.default_menus` también existen. -/// 3. Para cada menú conocido, recoge y aplica los ítems declarados por extensiones. -pub(crate) async fn run() { - // Paso 1: recoger declaraciones de menú. - let mut defs = MenuDefs { - entries: Vec::new(), - }; - DeclareDefaultMenus::dispatch(&mut defs); - - // Paso 2: añadir los menús por defecto de configuración si no se declararon ya. - for name in &SETTINGS.default_menus { - if !defs.entries.iter().any(|(n, _)| n == name) { - let title = capitalize(name); - defs.entries.push((name.clone(), title)); - } - } - - // Paso 3: crear cada menú que no exista y sembrar sus ítems. - for (machine_name, title) in &defs.entries { - repo::ensure_menu(machine_name, title).await.ok(); - - let Some(menu_id) = - repo::find_menu_model(&crate::tree::MenuKey::Name(machine_name.to_owned())) - .await - .map(|m| m.id) - else { - continue; - }; - - let mut bag = ItemBag { items: Vec::new() }; - DeclareDefaultMenuItems::dispatch(machine_name, &mut bag); - - for item in bag.items { - if let Some(key) = item.external_key.clone() { - let provider = item.provider.clone(); - repo::upsert_item(menu_id, &provider, &key, item).await.ok(); - } - } - } -} - -fn capitalize(s: &str) -> String { - let mut c = s.chars(); - match c.next() { - None => String::new(), - Some(f) => f.to_uppercase().to_string() + c.as_str(), - } -} diff --git a/extensions/pagetop-menu/src/tree.rs b/extensions/pagetop-menu/src/tree.rs deleted file mode 100644 index 0df23a53..00000000 --- a/extensions/pagetop-menu/src/tree.rs +++ /dev/null @@ -1,289 +0,0 @@ -//! Tipos en memoria del árbol de menús y función de construcción. - -use std::collections::HashMap; - -use pagetop::locale::{Locale, RequestLocale}; -use pagetop::prelude::*; - -use crate::cache::FlatMenu; -use crate::entity::menu_item_translation; -use crate::{action, cache, repo}; - -// **< MenuKey >************************************************************************************ - -/// Selector para localizar un menú por `id` o por `machine_name`. -pub enum MenuKey { - Id(i32), - Name(String), -} - -impl From 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() -}