diff --git a/extensions/pagetop-admin/Cargo.toml b/extensions/pagetop-admin/Cargo.toml new file mode 100644 index 00000000..662c62b9 --- /dev/null +++ b/extensions/pagetop-admin/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "pagetop-admin" +description = "Panel de administración extensible para PageTop." +version = "0.1.0" +categories = ["web-programming", "development-tools"] +keywords = ["pagetop", "admin", "cms", "settings", "ssr"] + +repository.workspace = true +homepage.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +pagetop.workspace = true +pagetop-seaorm.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true diff --git a/extensions/pagetop-admin/src/action.rs b/extensions/pagetop-admin/src/action.rs new file mode 100644 index 00000000..019b839a --- /dev/null +++ b/extensions/pagetop-admin/src/action.rs @@ -0,0 +1,291 @@ +//! Tipos de acción que `pagetop-admin` expone para que otras extensiones registren +//! secciones, páginas, tareas y acciones en el panel de administración. +//! +//! El flujo de `registry::build()` despacha estas acciones en este orden: +//! +//! 1. `DeclareAdminSections` - secciones (agrupaciones del sidebar). +//! 2. `DeclareAdminPages` - páginas del panel. +//! 3. `DeclareAdminTasks` - tareas (pestañas) locales por página. +//! 4. `DeclareAdminActions` - acciones locales (botones) por página. + +use pagetop::prelude::*; + +use crate::registry::{AdminAction, AdminPage, AdminSection, AdminTask}; + +// **< Tipo de callbacks >************************************************************************** + +pub type FnSectionBag = fn(&mut SectionBag); +pub type FnPageBag = fn(&mut PageBag); +pub type FnTaskBag = fn(&mut TaskBag); +pub type FnActionBag = fn(&mut ActionBag); + +// **< Bolsas de declaración >********************************************************************** + +/// Bolsa de secciones para [`DeclareAdminSections`]. +pub struct SectionBag { + pub(crate) sections: Vec, +} + +impl SectionBag { + /// Añade una sección al registro. + pub fn add(&mut self, section: AdminSection) { + self.sections.push(section); + } +} + +/// Bolsa de páginas para [`DeclareAdminPages`]. +pub struct PageBag { + pub(crate) pages: Vec, +} + +impl PageBag { + /// Añade una página al registro. + pub fn add(&mut self, page: AdminPage) { + self.pages.push(page); + } +} + +/// Bolsa de tareas para [`DeclareAdminTasks`]. +pub struct TaskBag { + pub(crate) tasks: Vec, +} + +impl TaskBag { + /// Añade una tarea local al registro. + pub fn add(&mut self, task: AdminTask) { + self.tasks.push(task); + } +} + +/// Bolsa de acciones locales para [`DeclareAdminActions`]. +pub struct ActionBag { + pub(crate) actions: Vec, +} + +impl ActionBag { + /// Añade una acción local al registro. + pub fn add(&mut self, action: AdminAction) { + self.actions.push(action); + } +} + +// **< DeclareAdminSections >*********************************************************************** + +/// Acción para declarar secciones del panel de administración. +/// +/// Se despacha durante `registry::build()` (dentro de `initialize()`). El callback +/// recibe un [`SectionBag`] y puede llamar a `add()` para registrar secciones. +/// +/// # Ejemplo +/// +/// ```rust,no_run +/// use pagetop::locale::Lc; +/// use pagetop_admin::action::{DeclareAdminSections, SectionBag}; +/// use pagetop_admin::registry::AdminSection; +/// +/// fn declare_sections(bag: &mut SectionBag) { +/// bag.add(AdminSection { +/// key: "tools".to_owned(), +/// path: "/admin/tools".to_owned(), +/// title: Lc::n("Tools"), +/// permission: None, +/// weight: 60, +/// }); +/// } +/// // En Extension::actions(): +/// // DeclareAdminSections::new(declare_sections) +/// ``` +pub struct DeclareAdminSections { + f: FnSectionBag, + weight: Weight, +} + +impl ActionDispatcher for DeclareAdminSections { + fn weight(&self) -> Weight { + self.weight + } +} + +impl DeclareAdminSections { + pub fn new(f: FnSectionBag) -> Self { + DeclareAdminSections { f, weight: 0 } + } + + pub fn with_weight(mut self, w: Weight) -> Self { + self.weight = w; + self + } + + pub(crate) fn dispatch(bag: &mut SectionBag) { + dispatch_actions( + &ActionKey::new(UniqueId::of::(), None, None), + |action: &Self| (action.f)(bag), + ); + } +} + +// **< DeclareAdminPages >************************************************************************** + +/// Acción para declarar páginas del panel de administración. +/// +/// Se despacha durante `registry::build()`. El callback recibe un [`PageBag`]. +/// +/// # Ejemplo +/// +/// ```rust,no_run +/// use pagetop::locale::Lc; +/// use pagetop_admin::action::{DeclareAdminPages, PageBag}; +/// use pagetop_admin::registry::{AdminPage, AdminPageKind}; +/// +/// fn declare_pages(bag: &mut PageBag) { +/// bag.add(AdminPage { +/// path: "/admin/tools/export".to_owned(), +/// section: "tools".to_owned(), +/// title: Lc::n("Export"), +/// description: Some(Lc::n("Export site data.")), +/// weight: 0, +/// permission: None, +/// kind: AdminPageKind::View, +/// }); +/// } +/// // En Extension::actions(): +/// // DeclareAdminPages::new(declare_pages) +/// ``` +pub struct DeclareAdminPages { + f: FnPageBag, + weight: Weight, +} + +impl ActionDispatcher for DeclareAdminPages { + fn weight(&self) -> Weight { + self.weight + } +} + +impl DeclareAdminPages { + pub fn new(f: FnPageBag) -> Self { + DeclareAdminPages { f, weight: 0 } + } + + pub fn with_weight(mut self, w: Weight) -> Self { + self.weight = w; + self + } + + pub(crate) fn dispatch(bag: &mut PageBag) { + dispatch_actions( + &ActionKey::new(UniqueId::of::(), None, None), + |action: &Self| (action.f)(bag), + ); + } +} + +// **< DeclareAdminTasks >************************************************************************** + +/// Acción para declarar tareas (pestañas) locales en páginas del panel. +/// +/// Se despacha durante `registry::build()`. El callback recibe un [`TaskBag`]. +/// Cada [`AdminTask`] indica en `parent_path` la página a la que pertenece. +/// +/// # Ejemplo +/// +/// ```rust,no_run +/// use pagetop::locale::Lc; +/// use pagetop_admin::action::{DeclareAdminTasks, TaskBag}; +/// use pagetop_admin::registry::AdminTask; +/// +/// fn declare_tasks(bag: &mut TaskBag) { +/// bag.add(AdminTask { +/// path: "/admin/tools/export/csv".to_owned(), +/// parent_path: "/admin/tools/export".to_owned(), +/// title: Lc::n("CSV"), +/// weight: 0, +/// is_default: true, +/// permission: None, +/// }); +/// } +/// // En Extension::actions(): +/// // DeclareAdminTasks::new(declare_tasks) +/// ``` +pub struct DeclareAdminTasks { + f: FnTaskBag, + weight: Weight, +} + +impl ActionDispatcher for DeclareAdminTasks { + fn weight(&self) -> Weight { + self.weight + } +} + +impl DeclareAdminTasks { + pub fn new(f: FnTaskBag) -> Self { + DeclareAdminTasks { f, weight: 0 } + } + + pub fn with_weight(mut self, w: Weight) -> Self { + self.weight = w; + self + } + + pub(crate) fn dispatch(bag: &mut TaskBag) { + dispatch_actions( + &ActionKey::new(UniqueId::of::(), None, None), + |action: &Self| (action.f)(bag), + ); + } +} + +// **< DeclareAdminActions >************************************************************************ + +/// Acción para declarar acciones locales (botones de acción) en páginas del panel. +/// +/// Se despacha durante `registry::build()`. El callback recibe un [`ActionBag`]. +/// Cada [`AdminAction`] indica en `for_path` la página en la que aparece el botón. +/// +/// # Ejemplo +/// +/// ```rust,no_run +/// use pagetop::locale::Lc; +/// use pagetop_admin::action::{DeclareAdminActions, ActionBag}; +/// use pagetop_admin::registry::AdminAction; +/// +/// fn declare_actions(bag: &mut ActionBag) { +/// bag.add(AdminAction { +/// url: "/admin/tools/export/new".to_owned(), +/// for_path: "/admin/tools/export".to_owned(), +/// title: Lc::n("Add export"), +/// weight: 0, +/// }); +/// } +/// // En Extension::actions(): +/// // DeclareAdminActions::new(declare_actions) +/// ``` +pub struct DeclareAdminActions { + f: FnActionBag, + weight: Weight, +} + +impl ActionDispatcher for DeclareAdminActions { + fn weight(&self) -> Weight { + self.weight + } +} + +impl DeclareAdminActions { + pub fn new(f: FnActionBag) -> Self { + DeclareAdminActions { f, weight: 0 } + } + + pub fn with_weight(mut self, w: Weight) -> Self { + self.weight = w; + self + } + + pub(crate) fn dispatch(bag: &mut ActionBag) { + dispatch_actions( + &ActionKey::new(UniqueId::of::(), None, None), + |action: &Self| (action.f)(bag), + ); + } +} diff --git a/extensions/pagetop-admin/src/component.rs b/extensions/pagetop-admin/src/component.rs new file mode 100644 index 00000000..e6325daf --- /dev/null +++ b/extensions/pagetop-admin/src/component.rs @@ -0,0 +1,9 @@ +//! Componentes de renderizado del panel de administración. + +mod admin_frame; +mod admin_menu; +mod config_form; + +pub use admin_frame::AdminFrame; +pub use admin_menu::AdminMenu; +pub use config_form::ConfigForm; diff --git a/extensions/pagetop-admin/src/component/admin_frame.rs b/extensions/pagetop-admin/src/component/admin_frame.rs new file mode 100644 index 00000000..8a63ed23 --- /dev/null +++ b/extensions/pagetop-admin/src/component/admin_frame.rs @@ -0,0 +1,150 @@ +use pagetop::base::component::breadcrumb; +use pagetop::prelude::*; + +use crate::LOCALES_ADMIN; +use crate::registry; + +/// Componente de layout principal de una página del panel de administración. +/// +/// Renderiza breadcrumb, encabezado (título + acciones locales), tareas locales (pestañas) y el +/// contenido de la página. No incluye ningún menú de navegación: mostrar las secciones del panel +/// (el "sidebar" o el "menú superior", según el tema) es responsabilidad del tema que intercepte +/// [`CoreTemplates::Admin`](pagetop::core::theme::CoreTemplates::Admin) -- por ejemplo +/// `pagetop-bootsier` -- leyendo directamente [`crate::registry::global()`]. El tema básico de +/// PageTop no lo hace, y una página de administración sigue siendo completamente navegable sin él, +/// a través del *dashboard* (`/admin`), las páginas de sección y este mismo breadcrumb. +/// +/// ```html +/// +///
+///

Title

+///
    ...
+///
+/// +///
...
+/// ``` +/// +/// El contenido son sus propios hijos ([`Children`]), igual que en cualquier otro componente +/// contenedor (p. ej. [`Block`](pagetop::base::component::Block)): admite tantos `with_child(...)` +/// como haga falta, cada uno renderizado en el ciclo normal de componentes. +#[derive(AutoDefault, Clone, Debug, Getters)] +pub struct AdminFrame { + /// Devuelve el título de la página. + title: Lc, + /// Devuelve la lista de componentes hijo de la página. + children: Children, +} + +#[async_trait] +impl Component for AdminFrame { + fn new() -> Self { + Self::default() + } + + async fn prepare(&self, cx: &mut Context) -> Result { + let path = cx + .request() + .map(|r| r.path().to_string()) + .unwrap_or_else(|| "/".to_string()); + + let breadcrumbs = render_breadcrumbs(cx, &path).await; + let local_tasks = render_local_tasks(cx, &path); + let local_acts = render_local_actions(cx, &path); + let body = self.children().render(cx).await; + + Ok(html! { + (breadcrumbs) + header.admin-header { + h1.admin-page-title { (self.title().using(cx)) } + (local_acts) + } + @if !local_tasks.0.is_empty() { + nav.admin-local-tasks { (local_tasks) } + } + div.admin-content { + (body) + } + }) + } +} + +impl AdminFrame { + /// Establece el título de la página. + #[builder_fn] + pub fn with_title(mut self, title: Lc) -> Self { + self.title = title; + self + } + + /// Añade un componente hijo al contenido de la página. + #[builder_fn] + pub fn with_child(mut self, op: impl Into) -> Self { + self.children.alter_child(op.into()); + self + } +} + +// **< Breadcrumbs >******************************************************************************** + +async fn render_breadcrumbs(cx: &mut Context, current_path: &str) -> Markup { + let reg = registry::global(); + let base = crate::ADMIN_BASE_PATH; + + let home_label = Lc::t("admin-breadcrumb-home", &LOCALES_ADMIN); + let mut bc = Breadcrumb::new().with_crumb(breadcrumb::Crumb::new(home_label, base)); + + if let Some(page) = reg.pages().get(current_path) { + if let Some(section) = reg.sections().get(&page.section) { + bc = bc.with_crumb(breadcrumb::Crumb::new( + section.title.clone(), + section.path.as_str(), + )); + } + bc = bc.with_crumb(breadcrumb::Crumb::current(page.title.clone())); + } else if let Some(section) = reg.sections().values().find(|s| s.path == current_path) { + // La ruta actual es la propia sección (su página de aterrizaje), no una `AdminPage` + // registrada dentro de ella. + bc = bc.with_crumb(breadcrumb::Crumb::current(section.title.clone())); + } + + bc.render(cx).await +} + +// **< Tareas locales >***************************************************************************** + +fn render_local_tasks(cx: &Context, current_path: &str) -> Markup { + let reg = registry::global(); + let tasks = reg.tasks_for(current_path); + if tasks.is_empty() { + return html! {}; + } + html! { + ul.admin-tasks-list { + @for task in tasks { + @let is_active = current_path == task.path; + li class=(if is_active { "admin-task admin-task-active" } else { "admin-task" }) { + a href=(cx.route(task.path.as_str())) { (task.title.using(cx)) } + } + } + } + } +} + +// **< Acciones locales >*************************************************************************** + +fn render_local_actions(cx: &Context, current_path: &str) -> Markup { + let reg = registry::global(); + let actions = reg.actions_for(current_path); + if actions.is_empty() { + return html! {}; + } + html! { + ul.admin-actions-list { + @for action in actions { + li.admin-action { + a.admin-action-link href=(cx.route(action.url.as_str())) { (action.title.using(cx)) } + } + } + } + } +} diff --git a/extensions/pagetop-admin/src/component/admin_menu.rs b/extensions/pagetop-admin/src/component/admin_menu.rs new file mode 100644 index 00000000..ad3f1963 --- /dev/null +++ b/extensions/pagetop-admin/src/component/admin_menu.rs @@ -0,0 +1,37 @@ +use pagetop::prelude::*; + +use crate::registry; + +/// Componente que renderiza el menú de secciones visibles del panel de administración. +/// +/// Construye el [`Nav`] en cada petición a partir de [`registry::admin_menu()`], ya filtrado por +/// el usuario de la petición actual. Pensado para que un tema lo registre en su propia región de +/// navegación (p. ej. `pagetop-bootsier` lo añade a su sidebar) e intercepte `Nav`/`nav::Item` en +/// [`Theme::handle_component()`](pagetop::core::theme::Theme::handle_component) si quiere darle un +/// aspecto propio; sin intercepción, se renderiza con el marcado por defecto de [`Nav`]. +/// +/// Sólo se renderiza en páginas creadas con +/// [`Page::admin()`](pagetop::response::Page::admin) (plantilla +/// [`CoreTemplates::Admin`](pagetop::core::theme::CoreTemplates::Admin)). Es necesario comprobarlo +/// explícitamente porque, si se registra en una región de propósito general como +/// [`CoreRegions::Aside`](pagetop::core::theme::CoreRegions::Aside), se renderizaría también en +/// páginas `Standard` si no se autolimitara. +#[derive(AutoDefault, Clone, Debug)] +pub struct AdminMenu; + +#[async_trait] +impl Component for AdminMenu { + fn new() -> Self { + Self + } + + async fn prepare(&self, cx: &mut Context) -> Result { + if !matches!( + cx.template().downcast_ref::(), + Some(CoreTemplates::Admin) + ) { + return Ok(html! {}); + } + Ok(registry::admin_menu(cx).render(cx).await) + } +} diff --git a/extensions/pagetop-admin/src/component/config_form.rs b/extensions/pagetop-admin/src/component/config_form.rs new file mode 100644 index 00000000..304fb621 --- /dev/null +++ b/extensions/pagetop-admin/src/component/config_form.rs @@ -0,0 +1,226 @@ +use pagetop::prelude::*; + +use crate::LOCALES_ADMIN; +use crate::settings::{SettingFieldType, SettingsSchema, get_or}; + +/// Componente que renderiza un formulario de configuración persistente. +/// +/// Genera campos HTML a partir de un [`SettingsSchema`] y carga los valores +/// actuales desde `settings`. El POST es procesado por el handler interno +/// `config_form_post`. +/// +/// # Ejemplo +/// +/// ```rust,no_run +/// use pagetop::prelude::*; +/// use pagetop_admin::component::{AdminFrame, ConfigForm}; +/// use pagetop_admin::settings::{SettingField, SettingsSchema}; +/// +/// async fn settings_handler(request: HttpRequest) -> Result { +/// let schema = SettingsSchema::new("myapp.general") +/// .with_field(SettingField::text("site_name", "Site name").with_required(true)); +/// +/// let title = "General settings"; +/// let mut form = ConfigForm::with_schema(schema); +/// let mut cx = Context::new(request.clone()); +/// let content = form.render(&mut cx).await; +/// +/// Page::admin(request) +/// .with_title(Lc::n(title)) +/// .with_child( +/// AdminFrame::new() +/// .with_title(Lc::n(title)) +/// .with_child(Html::with(move |_| content.clone())), +/// ) +/// .render() +/// .await +/// } +/// ``` +#[derive(AutoDefault, Clone, Debug, Getters)] +pub struct ConfigForm { + /// Devuelve el esquema del formulario, si se ha establecido uno. + schema: Option, + /// Devuelve la ruta de destino del formulario, si se ha personalizado. + action_path: Option, + /// Devuelve si el último envío se guardó correctamente. + saved: bool, + /// Devuelve si el último envío produjo un error al guardar. + error: bool, +} + +#[async_trait] +impl Component for ConfigForm { + fn new() -> Self { + Self::default() + } + + async fn prepare(&self, cx: &mut Context) -> Result { + let Some(schema) = self.schema() else { + return Ok(html! {}); + }; + + let action = match self.action_path() { + Some(route) => route.resolve(cx), + None => cx.route(cx.request().map(|r| r.path()).unwrap_or("/")), + }; + + let save_label = Lc::t("config-save-label", &LOCALES_ADMIN) + .lookup(cx) + .unwrap_or_else(|| "Save configuration".into()); + + let saved_msg = Lc::t("config-saved-ok", &LOCALES_ADMIN) + .lookup(cx) + .unwrap_or_else(|| "Configuration saved.".into()); + + let error_msg = Lc::t("config-saved-error", &LOCALES_ADMIN) + .lookup(cx) + .unwrap_or_else(|| "Could not save configuration.".into()); + + // Pre-computa los valores de BD para cada campo antes del macro html! (que es síncrono). + struct FieldVals { + raw_val: String, + num_val: String, + checked: bool, + } + let mut prepared: Vec = Vec::with_capacity(schema.fields().len()); + for field in schema.fields() { + let key = schema.key_for(field.name()); + let raw_val = + get_or::(&key, field.default_value().cloned().unwrap_or_default()).await; + let num_val = get_or::(&key, 0.0).await.to_string(); + let checked = get_or::(&key, false).await; + prepared.push(FieldVals { + raw_val, + num_val, + checked, + }); + } + + Ok(html! { + @if *self.saved() { + div.admin-message."admin-message-ok" { (&saved_msg) } + } + @if *self.error() { + div.admin-message."admin-message-error" { (&error_msg) } + } + form.admin-config-form method="post" action=(action) { + @for (i, field) in schema.fields().iter().enumerate() { + @let pf = &prepared[i]; + div.admin-form-field { + label.admin-form-label for=(field.name()) { (field.label()) } + @match field.field_type() { + SettingFieldType::Text { max_length } => { + @if let Some(max) = max_length { + input.admin-form-input + type="text" + id=(field.name()) + name=(field.name()) + value=(&pf.raw_val) + maxlength=(max) + required[*field.required()]; + } @else { + input.admin-form-input + type="text" + id=(field.name()) + name=(field.name()) + value=(&pf.raw_val) + required[*field.required()]; + } + } + SettingFieldType::Number { min, max } => { + @if let (Some(lo), Some(hi)) = (min, max) { + input.admin-form-input + type="number" + id=(field.name()) + name=(field.name()) + value=(&pf.num_val) + min=(lo) + max=(hi) + required[*field.required()]; + } @else if let Some(lo) = min { + input.admin-form-input + type="number" + id=(field.name()) + name=(field.name()) + value=(&pf.num_val) + min=(lo) + required[*field.required()]; + } @else if let Some(hi) = max { + input.admin-form-input + type="number" + id=(field.name()) + name=(field.name()) + value=(&pf.num_val) + max=(hi) + required[*field.required()]; + } @else { + input.admin-form-input + type="number" + id=(field.name()) + name=(field.name()) + value=(&pf.num_val) + required[*field.required()]; + } + } + SettingFieldType::Boolean => { + input.admin-form-checkbox + type="checkbox" + id=(field.name()) + name=(field.name()) + value="true" + checked[pf.checked]; + } + SettingFieldType::Select { options } => { + select.admin-form-select + id=(field.name()) + name=(field.name()) + required[*field.required()] + { + @for (val, label) in options { + @if &pf.raw_val == val { + option value=(val) selected { (label) } + } @else { + option value=(val) { (label) } + } + } + } + } + } + @if let Some(help) = field.help_text() { + small.admin-form-help { (help) } + } + } + } + div.admin-form-actions { + button.admin-btn."admin-btn-primary" type="submit" { + (&save_label) + } + } + } + }) + } +} + +impl ConfigForm { + /// Crea el componente con el [`SettingsSchema`] dado. + pub fn with_schema(schema: SettingsSchema) -> Self { + ConfigForm { + schema: Some(schema), + ..Self::default() + } + } + + #[builder_fn] + pub fn with_action_path(mut self, v: impl Into>) -> Self { + if let Some(v) = v.into() { + self.action_path = Some(v); + } + self + } + + pub(crate) fn with_saved(mut self, saved: bool, error: bool) -> Self { + self.saved = saved; + self.error = error; + self + } +} diff --git a/extensions/pagetop-admin/src/entity.rs b/extensions/pagetop-admin/src/entity.rs new file mode 100644 index 00000000..75e3a9d0 --- /dev/null +++ b/extensions/pagetop-admin/src/entity.rs @@ -0,0 +1,3 @@ +//! Entidades SeaORM de `pagetop-admin`. + +pub(crate) mod setting; diff --git a/extensions/pagetop-admin/src/entity/setting.rs b/extensions/pagetop-admin/src/entity/setting.rs new file mode 100644 index 00000000..37b0329a --- /dev/null +++ b/extensions/pagetop-admin/src/entity/setting.rs @@ -0,0 +1,20 @@ +use pagetop_seaorm::db::*; + +use pagetop::datetime::NaiveDateTime; + +/// Entidad SeaORM para la tabla `settings`. +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "settings")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub key: String, + pub scope: String, + pub value: String, + pub updated_at: NaiveDateTime, + pub updated_by: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/extensions/pagetop-admin/src/error.rs b/extensions/pagetop-admin/src/error.rs new file mode 100644 index 00000000..318e82f4 --- /dev/null +++ b/extensions/pagetop-admin/src/error.rs @@ -0,0 +1,14 @@ +use thiserror::Error; + +/// Errores que puede producir la extensión `pagetop-admin`. +#[derive(Debug, Error)] +pub enum AdminError { + #[error("settings DB error: {0}")] + DbError(#[from] pagetop_seaorm::db::DbErr), + + #[error("settings serialization error: {0}")] + SerializeError(#[from] serde_json::Error), + + #[error("setting key not found: {0}")] + NotFound(String), +} diff --git a/extensions/pagetop-admin/src/handlers.rs b/extensions/pagetop-admin/src/handlers.rs new file mode 100644 index 00000000..8a512d08 --- /dev/null +++ b/extensions/pagetop-admin/src/handlers.rs @@ -0,0 +1,207 @@ +use std::collections::HashMap; + +use pagetop::prelude::*; + +use crate::LOCALES_ADMIN; +use crate::component::{AdminFrame, ConfigForm}; +use crate::registry::{self, AdminPageKind, AdminPermission, AdminSection}; +use crate::settings; + +// **< Dashboard >********************************************************************************** + +/// GET /admin - Dashboard de administración: lista todas las secciones disponibles. +pub async fn dashboard(request: HttpRequest) -> Result { + let reg = registry::global(); + let cx = Context::new(request.clone()); + let sections = reg.ordered_sections(); + + let title = Lc::t("dashboard-title", &LOCALES_ADMIN); + let content = render_sections(&cx, §ions); + + Page::admin(request) + .with_title(title.clone()) + .with_child( + AdminFrame::new() + .with_title(title) + .with_child(Html::with(move |_| content.clone())), + ) + .render() + .await +} + +// **< Sección >************************************************************************************ + +/// GET /admin/{section} - Página de aterrizaje de una sección: lista sus páginas. +/// +/// Sólo se monta (ver `configure_router()` en `lib.rs`) para las secciones cuyo `path` no coincida +/// con ninguna [`AdminPage`](crate::registry::AdminPage) registrada explícitamente por una +/// extensión; si una extensión reclama esa ruta con su propia página, esa página gana y este +/// handler nunca llega a montarse para ella. +pub async fn section_page(request: HttpRequest) -> Result { + let path = request.path().to_owned(); + let reg = registry::global(); + let section = reg + .sections() + .values() + .find(|s| s.path == path) + .ok_or_else(|| ErrorPage::NotFound(Some(request.clone())))?; + + require_permission( + &request, + section.permission.unwrap_or(&AdminPermission::Access), + )?; + + let cx = Context::new(request.clone()); + let title = section.title.clone(); + let content = render_sections(&cx, &[section]); + + Page::admin(request) + .with_title(title.clone()) + .with_child( + AdminFrame::new() + .with_title(title) + .with_child(Html::with(move |_| content.clone())), + ) + .render() + .await +} + +// **< Render compartido >************************************************************************** + +// Rejilla de secciones con sus páginas (título + descripción). La usan tanto `dashboard()` (todas +// las secciones) como `section_page()` (una sola), filtrando siempre por permiso del usuario actual. +fn render_sections(cx: &Context, sections: &[&AdminSection]) -> Markup { + let reg = registry::global(); + html! { + div.admin-dashboard-sections { + @for section in sections { + @if section.is_visible(cx) { + @let pages: Vec<_> = reg + .pages_for_section(§ion.key) + .into_iter() + .filter(|p| p.is_accessible(cx)) + .collect(); + @if !pages.is_empty() { + div.admin-dashboard-section { + h2.admin-dashboard-section-title { + a href=(cx.route(section.path.as_str())) { (section.title.using(cx)) } + } + ul.admin-dashboard-section-links { + @for page in pages { + li.admin-dashboard-link { + a href=(cx.route(page.path.as_str())) { (page.title.using(cx)) } + @if let Some(description) = &page.description { + span.admin-dashboard-link-desc { + " - " (description.using(cx)) + } + } + } + } + } + } + } + } + } + } + } +} + +// **< Config form - GET >************************************************************************** + +/// GET de una página [`AdminPageKind::ConfigForm`]. +/// +/// Lee el schema del registro y renderiza el formulario con los valores actuales. +pub async fn config_form_get(request: HttpRequest) -> Result { + let path = request.path().to_owned(); + let reg = registry::global(); + let page = reg + .pages() + .get(&path) + .ok_or_else(|| ErrorPage::NotFound(Some(request.clone())))?; + + let AdminPageKind::ConfigForm(ref schema) = page.kind else { + return Err(ErrorPage::NotFound(Some(request.clone()))); + }; + + require_permission(&request, page.permission_key())?; + + let title = page.title.clone(); + let mut form = ConfigForm::with_schema(schema.clone()); + let mut cx = Context::new(request.clone()); + let content = form.render(&mut cx).await; + + Page::admin(request) + .with_title(title.clone()) + .with_child( + AdminFrame::new() + .with_title(title) + .with_child(Html::with(move |_| content.clone())), + ) + .render() + .await +} + +// **< Config form - POST >************************************************************************* + +/// POST de una página [`AdminPageKind::ConfigForm`]. +/// +/// Valida y persiste los valores en `settings`, luego redirige al GET con +/// estado `saved=true`. +pub async fn config_form_post( + request: HttpRequest, + web::Form(data): web::Form>, +) -> Result { + let path = request.path().to_owned(); + let reg = registry::global(); + let page = reg + .pages() + .get(&path) + .ok_or_else(|| ErrorPage::NotFound(Some(request.clone())))?; + + let AdminPageKind::ConfigForm(ref schema) = page.kind else { + return Err(ErrorPage::NotFound(Some(request.clone()))); + }; + + require_permission(&request, page.permission_key())?; + + let mut save_error = false; + + for field in schema.fields() { + let key = schema.key_for(field.name()); + + // Los checkbox HTML sólo envían el campo si están marcados. + let raw = match field.field_type() { + crate::settings::SettingFieldType::Boolean => data + .get(field.name()) + .map(|s| s.as_str()) + .unwrap_or("false") + .to_owned(), + _ => { + let Some(raw) = data.get(field.name()) else { + if *field.required() { + save_error = true; + } + continue; + }; + raw.clone() + } + }; + + settings::set::(&key, &raw, schema.scope(), None).await; + } + + let title = page.title.clone(); + let mut form = ConfigForm::with_schema(schema.clone()).with_saved(!save_error, save_error); + let mut cx = Context::new(request.clone()); + let content = form.render(&mut cx).await; + + Page::admin(request) + .with_title(title.clone()) + .with_child( + AdminFrame::new() + .with_title(title) + .with_child(Html::with(move |_| content.clone())), + ) + .render() + .await +} diff --git a/extensions/pagetop-admin/src/lib.rs b/extensions/pagetop-admin/src/lib.rs new file mode 100644 index 00000000..dfa7eab5 --- /dev/null +++ b/extensions/pagetop-admin/src/lib.rs @@ -0,0 +1,194 @@ +/*! +
+ +

PageTop Admin

+ +

Panel de administración extensible para PageTop.

+ +
+ +## Guía rápida + +Declara la dependencia en tu `Cargo.toml` activando el motor de base de datos: + +```toml +[dependencies] +pagetop-admin = { version = "...", features = ["sqlite"] } +``` + +Añade `&pagetop_admin::Admin` a las dependencias de tu extensión y declara tus +secciones, páginas o configuración: + +```rust,no_run +use pagetop::prelude::*; +use pagetop_admin::prelude::*; +use pagetop_admin::settings::{SettingField, SettingsSchema}; + +pub struct MyApp; + +#[async_trait] +impl Extension for MyApp { + fn dependencies(&self) -> Vec { + vec![&pagetop_admin::Admin] + } + + fn actions(&self) -> Vec { + actions![ + DeclareAdminPages::new(declare_pages), + ] + } + + fn configure_router(&self, router: Router) -> Router { + router.route("/admin/config/myapp", web::get(config_handler)) + } +} + +fn declare_pages(bag: &mut PageBag) { + bag.add(AdminPage { + path: "/admin/config/myapp".to_owned(), + section: "config".to_owned(), + title: Lc::n("My App"), + description: Some(Lc::n("Configure My App.")), + weight: 0, + permission: Some(&MyPermission::Config), + kind: AdminPageKind::View, + }); +} + +#[derive(Clone, Copy, Debug)] +enum MyPermission { + Config, +} + +impl Permission for MyPermission { + fn key(&self) -> CowStr { + match self { + Self::Config => "myapp.config".into(), + } + } +} + +async fn config_handler(request: HttpRequest) -> Result { + let schema = SettingsSchema::new("myapp.config") + .with_field(SettingField::text("site_name", "Site name").with_required(true)); + + let title = "My App configuration"; + let mut form = ConfigForm::with_schema(schema); + let mut cx = Context::new(request.clone()); + let content = form.render(&mut cx).await; + + Page::admin(request) + .with_title(Lc::n(title)) + .with_child( + AdminFrame::new() + .with_title(Lc::n(title)) + .with_child(Html::with(move |_| content.clone())), + ) + .render() + .await +} +``` + +Para formularios de configuración enteramente automáticos (GET y POST gestionados +por `pagetop-admin`), usa `AdminPageKind::ConfigForm(schema)` en lugar de `View` y +no registres la ruta tú mismo. +*/ + +use pagetop::prelude::*; +use pagetop_seaorm::install_migrations; + +include_locales!(LOCALES_ADMIN); + +/// Ruta raíz del panel de administración. No es configurable: otras piezas del ecosistema (p. ej. +/// las rutas de `pagetop-user`, `/admin/user/...`) ya asumen este valor de forma literal. +pub(crate) const ADMIN_BASE_PATH: &str = "/admin"; + +pub mod action; +pub mod component; +pub mod error; +pub mod registry; +pub mod settings; + +pub(crate) mod entity; +pub(crate) mod handlers; +pub(crate) mod migration; +pub(crate) mod seed; + +pub use action::{ + ActionBag, DeclareAdminActions, DeclareAdminPages, DeclareAdminSections, DeclareAdminTasks, + PageBag, SectionBag, TaskBag, +}; +pub use component::{AdminFrame, AdminMenu, ConfigForm}; +pub use registry::{AdminAction, AdminPage, AdminPageKind, AdminSection, AdminTask}; +pub use settings::{SettingField, SettingFieldType, SettingsSchema}; + +/// Prelude de `pagetop-admin`. +pub mod prelude { + pub use crate::action::{ + ActionBag, DeclareAdminActions, DeclareAdminPages, DeclareAdminSections, DeclareAdminTasks, + PageBag, SectionBag, TaskBag, + }; + pub use crate::component::{AdminFrame, AdminMenu, ConfigForm}; + pub use crate::error::AdminError; + pub use crate::registry::{AdminAction, AdminPage, AdminPageKind, AdminSection, AdminTask}; + pub use crate::settings::{SettingField, SettingFieldType, SettingsSchema}; +} + +// **< Extension >********************************************************************************** + +/// Implementa la extensión `pagetop-admin`. +pub struct Admin; + +#[async_trait] +impl Extension for Admin { + fn name(&self) -> Lc { + Lc::t("extension_name", &LOCALES_ADMIN) + } + + fn description(&self) -> Lc { + Lc::t("extension_description", &LOCALES_ADMIN) + } + + fn dependencies(&self) -> Vec { + vec![&pagetop_seaorm::SeaORM] + } + + fn actions(&self) -> Vec { + actions![action::DeclareAdminSections::new(seed::declare_default_sections).with_weight(-99),] + } + + async fn initialize(&self) { + install_migrations!(m20260629_000001_create_settings); + registry::build(); + + // Región neutra del core: cualquier tema puede decidir renderizarla (o no) sin que + // `pagetop-admin` dependa de ninguno en concreto -- ver `CoreRegions::Aside`. + InRegion::Global(&CoreRegions::Aside).add(component::AdminMenu::new()); + } + + fn configure_router(&self, router: Router) -> Router { + let base = ADMIN_BASE_PATH; + let reg = registry::global(); + + let mut r = router.route(base, web::get(handlers::dashboard)); + + // Página de aterrizaje de cada sección, salvo que una extensión ya haya reclamado esa + // misma ruta con su propia `AdminPage` (esa página gana y se monta más abajo). + for section in reg.sections().values() { + if !reg.pages().contains_key(§ion.path) { + r = r.route(section.path.as_str(), web::get(handlers::section_page)); + } + } + + for (path, page) in reg.pages() { + if let AdminPageKind::ConfigForm(_) = &page.kind { + r = r.route( + path.as_str(), + web::get(handlers::config_form_get).post(handlers::config_form_post), + ); + } + } + + r + } +} diff --git a/extensions/pagetop-admin/src/locale/en-US/common.ftl b/extensions/pagetop-admin/src/locale/en-US/common.ftl new file mode 100644 index 00000000..31e38061 --- /dev/null +++ b/extensions/pagetop-admin/src/locale/en-US/common.ftl @@ -0,0 +1,21 @@ +extension_name = PageTop Admin +extension_description = Extensible administration panel for PageTop. + +# Dashboard +dashboard-title = Administration +dashboard-description = Manage your site. + +# Sections +section-people = People +section-structure = Structure +section-config = Configuration +section-reports = Reports +section-help = Help + +# Config form +config-save-label = Save configuration +config-saved-ok = Configuration saved. +config-saved-error = Could not save configuration. + +# Navigation +admin-breadcrumb-home = Administration diff --git a/extensions/pagetop-admin/src/locale/es-ES/common.ftl b/extensions/pagetop-admin/src/locale/es-ES/common.ftl new file mode 100644 index 00000000..aa3b3eda --- /dev/null +++ b/extensions/pagetop-admin/src/locale/es-ES/common.ftl @@ -0,0 +1,21 @@ +extension_name = PageTop Admin +extension_description = Panel de administración extensible para PageTop. + +# Dashboard +dashboard-title = Administración +dashboard-description = Gestiona tu sitio. + +# Sections +section-people = Personas +section-structure = Estructura +section-config = Configuración +section-reports = Informes +section-help = Ayuda + +# Config form +config-save-label = Guardar configuración +config-saved-ok = Configuración guardada. +config-saved-error = No se pudo guardar la configuración. + +# Navigation +admin-breadcrumb-home = Administración diff --git a/extensions/pagetop-admin/src/migration.rs b/extensions/pagetop-admin/src/migration.rs new file mode 100644 index 00000000..f940a5d1 --- /dev/null +++ b/extensions/pagetop-admin/src/migration.rs @@ -0,0 +1,3 @@ +//! Migraciones de base de datos de `pagetop-admin`. + +pub(crate) mod m20260629_000001_create_settings; diff --git a/extensions/pagetop-admin/src/migration/m20260629_000001_create_settings.rs b/extensions/pagetop-admin/src/migration/m20260629_000001_create_settings.rs new file mode 100644 index 00000000..598d72c3 --- /dev/null +++ b/extensions/pagetop-admin/src/migration/m20260629_000001_create_settings.rs @@ -0,0 +1,49 @@ +use pagetop_seaorm::migration::*; + +/// Tabla `settings`: almacén clave-valor JSON para configuración persistente. +pub struct Migration; + +#[pagetop::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Settings::Table) + .if_not_exists() + .col(string_len(Settings::Key, 190).primary_key()) + .col(string_len(Settings::Scope, 64)) + .col(text(Settings::Value)) + .col(timestamp(Settings::UpdatedAt).default(Expr::current_timestamp())) + .col(integer_null(Settings::UpdatedBy)) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_settings_scope") + .table(Settings::Table) + .col(Settings::Scope) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Settings::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Settings { + Table, + Key, + Scope, + Value, + UpdatedAt, + UpdatedBy, +} diff --git a/extensions/pagetop-admin/src/registry.rs b/extensions/pagetop-admin/src/registry.rs new file mode 100644 index 00000000..1144842c --- /dev/null +++ b/extensions/pagetop-admin/src/registry.rs @@ -0,0 +1,295 @@ +//! Registro global de secciones, páginas, tareas y acciones del panel de administración. +//! +//! Se construye una única vez durante `Extension::initialize()` y permanece +//! inmutable durante la vida de la aplicación. + +use std::collections::BTreeMap; +use std::sync::OnceLock; + +use pagetop::prelude::*; + +use crate::action::{ + ActionBag, DeclareAdminActions, DeclareAdminPages, DeclareAdminSections, DeclareAdminTasks, + PageBag, SectionBag, TaskBag, +}; +use crate::settings::SettingsSchema; + +// **< AdminPermission >***************************************************************************** + +/// Permisos propios de `pagetop-admin`. +#[derive(Clone, Copy, Debug)] +pub enum AdminPermission { + /// Acceso por defecto a una página de administración que no declara un permiso propio. + Access, + /// Acceso a la sección integrada "people". + AccessPeople, + /// Acceso a la sección integrada "structure". + AccessStructure, + /// Acceso a la sección integrada "config". + AccessConfig, + /// Acceso a la sección integrada "reports". + AccessReports, +} + +impl Permission for AdminPermission { + fn key(&self) -> CowStr { + match self { + Self::Access => "admin:access".into(), + Self::AccessPeople => "admin.access_people".into(), + Self::AccessStructure => "admin.access_structure".into(), + Self::AccessConfig => "admin.access_config".into(), + Self::AccessReports => "admin.access_reports".into(), + } + } +} + +// **< Tipos del registro >************************************************************************** + +/// Sección del panel de administración (agrupación en el sidebar). +#[derive(Clone)] +pub struct AdminSection { + /// Identificador único de la sección (p. ej. `"config"`). + pub key: String, + /// Ruta base de la sección (p. ej. `"/admin/config"`). + pub path: String, + /// Título visible en el sidebar. + pub title: Lc, + /// Permiso requerido para ver la sección (`None` = siempre visible). + pub permission: Option, + /// Peso para ordenar en el sidebar (menor = antes). + pub weight: i32, +} + +impl AdminSection { + /// Devuelve `true` si el usuario actual puede ver esta sección. + pub fn is_visible(&self, cx: &Context) -> bool { + match self.permission { + None => true, + Some(permission) => cx + .request() + .is_some_and(|request| has_permission(request, permission)), + } + } +} + +/// Página del panel de administración. +#[derive(Clone)] +pub struct AdminPage { + /// Ruta exacta de la página (p. ej. `"/admin/config/site"`). + pub path: String, + /// Clave de la sección a la que pertenece. + pub section: String, + /// Título visible en el sidebar y encabezado de página. + pub title: Lc, + /// Descripción breve (para el dashboard y listas de páginas), si tiene una. + pub description: Option, + /// Peso dentro de la sección (menor = antes). + pub weight: i32, + /// Permiso requerido para acceder (`None` = requiere [`AdminPermission::Access`]). + pub permission: Option, + /// Tipo de página y datos asociados. + pub kind: AdminPageKind, +} + +impl AdminPage { + /// Permiso efectivo: el declarado, o [`AdminPermission::Access`] si no se especificó ninguno. + pub fn permission_key(&self) -> PermissionRef { + self.permission.unwrap_or(&AdminPermission::Access) + } + + /// Devuelve `true` si el usuario actual puede acceder a esta página. + pub fn is_accessible(&self, cx: &Context) -> bool { + cx.request() + .is_some_and(|request| has_permission(request, self.permission_key())) + } +} + +/// Variantes de comportamiento de una [`AdminPage`]. +#[derive(Clone, Debug)] +pub enum AdminPageKind { + /// Página genérica cuyo handler se registra externamente. + View, + /// Formulario de configuración gestionado automáticamente por `pagetop-admin`. + ConfigForm(SettingsSchema), +} + +/// Tarea (pestaña) local dentro de una página de administración. +#[derive(Clone)] +pub struct AdminTask { + /// Ruta de la tarea. + pub path: String, + /// Ruta de la página padre. + pub parent_path: String, + /// Etiqueta de la pestaña. + pub title: Lc, + /// Peso (menor = primera pestaña). + pub weight: i32, + /// Si es la tarea por defecto (pestaña activa al entrar a la página padre). + pub is_default: bool, + /// Permiso requerido (`None` = mismo que la página padre). + pub permission: Option, +} + +/// Acción local (botón de acción) en una página de administración. +#[derive(Clone, Debug)] +pub struct AdminAction { + /// Ruta de destino de la acción. + pub url: String, + /// Página en la que aparece el botón. + pub for_path: String, + /// Etiqueta del botón. + pub title: Lc, + /// Peso (menor = primero). + pub weight: i32, +} + +// **< AdminRegistry >******************************************************************************* + +/// Registro global del panel de administración, construido una sola vez en `initialize()`. +#[derive(Getters)] +pub struct AdminRegistry { + /// Devuelve las secciones indexadas por clave (`BTreeMap` para orden estable por clave). + sections: BTreeMap, + /// Devuelve las páginas indexadas por ruta. + pages: BTreeMap, + /// Devuelve las tareas indexadas por ruta de página padre. + tasks: BTreeMap>, + /// Devuelve las acciones indexadas por ruta de página. + actions: BTreeMap>, +} + +impl AdminRegistry { + fn new() -> Self { + AdminRegistry { + sections: BTreeMap::new(), + pages: BTreeMap::new(), + tasks: BTreeMap::new(), + actions: BTreeMap::new(), + } + } + + /// Devuelve las páginas de una sección ordenadas por peso. + pub fn pages_for_section(&self, section_key: &str) -> Vec<&AdminPage> { + let mut pages: Vec<&AdminPage> = self + .pages + .values() + .filter(|p| p.section == section_key) + .collect(); + pages.sort_by_key(|p| p.weight); + pages + } + + /// Devuelve las secciones ordenadas por peso. + pub fn ordered_sections(&self) -> Vec<&AdminSection> { + let mut sections: Vec<&AdminSection> = self.sections.values().collect(); + sections.sort_by_key(|s| s.weight); + sections + } + + /// Devuelve las tareas de una página ordenadas por peso. + pub fn tasks_for(&self, path: &str) -> Vec<&AdminTask> { + let Some(tasks) = self.tasks.get(path) else { + return vec![]; + }; + let mut t: Vec<&AdminTask> = tasks.iter().collect(); + t.sort_by_key(|t| t.weight); + t + } + + /// Devuelve las acciones de una página ordenadas por peso. + pub fn actions_for(&self, path: &str) -> Vec<&AdminAction> { + let Some(actions) = self.actions.get(path) else { + return vec![]; + }; + let mut a: Vec<&AdminAction> = actions.iter().collect(); + a.sort_by_key(|a| a.weight); + a + } +} + +// **< Registro global >**************************************************************************** + +static REGISTRY: OnceLock = OnceLock::new(); + +/// Construye el registro despachando todas las acciones de declaración. +/// +/// Se llama una sola vez desde `Admin::initialize()`. +pub(crate) fn build() { + let mut registry = AdminRegistry::new(); + + // Secciones + let mut section_bag = SectionBag { + sections: Vec::new(), + }; + DeclareAdminSections::dispatch(&mut section_bag); + for s in section_bag.sections { + registry.sections.insert(s.key.clone(), s); + } + + // Páginas + let mut page_bag = PageBag { pages: Vec::new() }; + DeclareAdminPages::dispatch(&mut page_bag); + for p in page_bag.pages { + registry.pages.insert(p.path.clone(), p); + } + + // Tareas + let mut task_bag = TaskBag { tasks: Vec::new() }; + DeclareAdminTasks::dispatch(&mut task_bag); + for t in task_bag.tasks { + registry + .tasks + .entry(t.parent_path.clone()) + .or_default() + .push(t); + } + + // Acciones locales + let mut action_bag = ActionBag { + actions: Vec::new(), + }; + DeclareAdminActions::dispatch(&mut action_bag); + for a in action_bag.actions { + registry + .actions + .entry(a.for_path.clone()) + .or_default() + .push(a); + } + + REGISTRY.set(registry).ok(); +} + +/// Accede al registro global del panel de administración. +/// +/// # Panics +/// +/// Entra en pánico si se llama antes de que `Admin::initialize()` haya completado. +pub fn global() -> &'static AdminRegistry { + REGISTRY.get().expect("AdminRegistry not initialized") +} + +// **< Menú de administración >********************************************************************* + +/// Construye el menú plano de secciones visibles para el usuario de la petición actual. +/// +/// Pensado para que un tema lo use como navegación de `CoreTemplates::Admin` (p. ej. un sidebar) -- +/// ver [`crate::component::AdminMenu`]. `pagetop-admin` no impone ningún marcado propio: el +/// [`Nav`] resultante se renderiza con su aspecto por defecto salvo que el tema lo intercepte en +/// [`Theme::handle_component()`](pagetop::core::theme::Theme::handle_component). +pub fn admin_menu(cx: &Context) -> Nav { + let reg = global(); + let current_path = cx.request().map(|r| r.path()).unwrap_or(""); + + let mut result = Nav::new(); + for section in reg.ordered_sections() { + if !section.is_visible(cx) { + continue; + } + let active = current_path.starts_with(section.path.as_str()); + result = result.with_item( + nav::Item::link(section.title.clone(), section.path.clone()).with_active(active), + ); + } + result +} diff --git a/extensions/pagetop-admin/src/seed.rs b/extensions/pagetop-admin/src/seed.rs new file mode 100644 index 00000000..cf4c6640 --- /dev/null +++ b/extensions/pagetop-admin/src/seed.rs @@ -0,0 +1,50 @@ +use pagetop::prelude::*; + +use crate::ADMIN_BASE_PATH; +use crate::LOCALES_ADMIN; +use crate::action::SectionBag; +use crate::registry::{AdminPermission, AdminSection}; + +/// Declara las secciones incorporadas del panel de administración. +/// +/// Se registra en `Admin::actions()` como `DeclareAdminSections::new(declare_default_sections)` +/// con peso negativo para ejecutarse antes que las extensiones de terceros. +pub(crate) fn declare_default_sections(bag: &mut SectionBag) { + let base = ADMIN_BASE_PATH; + + bag.add(AdminSection { + key: "people".to_owned(), + path: format!("{}/people", base), + title: Lc::t("section-people", &LOCALES_ADMIN), + permission: Some(&AdminPermission::AccessPeople), + weight: 10, + }); + bag.add(AdminSection { + key: "structure".to_owned(), + path: format!("{}/structure", base), + title: Lc::t("section-structure", &LOCALES_ADMIN), + permission: Some(&AdminPermission::AccessStructure), + weight: 20, + }); + bag.add(AdminSection { + key: "config".to_owned(), + path: format!("{}/config", base), + title: Lc::t("section-config", &LOCALES_ADMIN), + permission: Some(&AdminPermission::AccessConfig), + weight: 30, + }); + bag.add(AdminSection { + key: "reports".to_owned(), + path: format!("{}/reports", base), + title: Lc::t("section-reports", &LOCALES_ADMIN), + permission: Some(&AdminPermission::AccessReports), + weight: 40, + }); + bag.add(AdminSection { + key: "help".to_owned(), + path: format!("{}/help", base), + title: Lc::t("section-help", &LOCALES_ADMIN), + permission: None, + weight: 50, + }); +} diff --git a/extensions/pagetop-admin/src/settings.rs b/extensions/pagetop-admin/src/settings.rs new file mode 100644 index 00000000..0eb3028a --- /dev/null +++ b/extensions/pagetop-admin/src/settings.rs @@ -0,0 +1,238 @@ +//! Almacén de configuración persistente basado en `settings`. +//! +//! Proporciona una API async para leer y escribir valores JSON en la tabla `settings`. + +use pagetop::datetime::Utc; +use pagetop::{Getters, builder_fn}; +use pagetop_seaorm::db::{ + ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter, dbconn, +}; +use serde::{Serialize, de::DeserializeOwned}; + +use crate::entity::setting::{ActiveModel, Column, Entity}; +use crate::error::AdminError; + +// **< API pública >********************************************************************************* + +/// Lee un valor persistido, devolviendo el `Default` del tipo si no existe. +pub async fn get(key: &str) -> T { + get_async(key).await.unwrap_or_default() +} + +/// Lee un valor persistido, devolviendo `default` si no existe o hay error. +pub async fn get_or(key: &str, default: T) -> T { + get_async(key).await.unwrap_or(default) +} + +/// Escribe un valor en la tabla `settings`. +/// +/// Si la clave no existe se inserta; si existe, se actualiza. +pub async fn set(key: &str, value: &T, scope: &str, user_id: Option) { + set_async(key, value, scope, user_id).await.ok(); +} + +/// Elimina una entrada de `settings` por clave. +pub async fn delete(key: &str) { + delete_async(key).await.ok(); +} + +/// Devuelve todos los pares `(key, value_json)` de un `scope` dado. +pub async fn list_scope(scope: &str) -> Vec<(String, String)> { + list_scope_async(scope).await.unwrap_or_default() +} + +// **< Implementación asíncrona >******************************************************************** + +async fn get_async(key: &str) -> Result { + let model = Entity::find_by_id(key) + .one(dbconn()) + .await? + .ok_or_else(|| AdminError::NotFound(key.to_owned()))?; + Ok(serde_json::from_str(&model.value)?) +} + +async fn set_async( + key: &str, + value: &T, + scope: &str, + user_id: Option, +) -> Result<(), AdminError> { + let value_json = serde_json::to_string(value)?; + let now = Utc::now().naive_utc(); + + let existing = Entity::find_by_id(key).one(dbconn()).await?; + if existing.is_some() { + let model = ActiveModel { + key: ActiveValue::Unchanged(key.to_owned()), + scope: ActiveValue::Set(scope.to_owned()), + value: ActiveValue::Set(value_json), + updated_at: ActiveValue::Set(now), + updated_by: ActiveValue::Set(user_id), + }; + model.update(dbconn()).await?; + } else { + let model = ActiveModel { + key: ActiveValue::Set(key.to_owned()), + scope: ActiveValue::Set(scope.to_owned()), + value: ActiveValue::Set(value_json), + updated_at: ActiveValue::Set(now), + updated_by: ActiveValue::Set(user_id), + }; + model.insert(dbconn()).await?; + } + Ok(()) +} + +async fn delete_async(key: &str) -> Result<(), AdminError> { + Entity::delete_by_id(key).exec(dbconn()).await?; + Ok(()) +} + +async fn list_scope_async(scope: &str) -> Result, AdminError> { + let rows = Entity::find() + .filter(Column::Scope.eq(scope)) + .all(dbconn()) + .await?; + Ok(rows.into_iter().map(|m| (m.key, m.value)).collect()) +} + +// **< Tipos de esquema >**************************************************************************** + +/// Tipo de campo de configuración para un [`SettingsSchema`]. +#[derive(Clone, Debug)] +pub enum SettingFieldType { + /// Texto libre con longitud máxima opcional. + Text { max_length: Option }, + /// Número (entero o decimal). + Number { min: Option, max: Option }, + /// Casilla de verificación (booleano). + Boolean, + /// Lista de opciones `(valor, etiqueta)`. + Select { options: Vec<(String, String)> }, +} + +/// Definición de un campo dentro de un [`SettingsSchema`]. +#[derive(Clone, Debug, Getters)] +pub struct SettingField { + /// Devuelve el nombre del campo (clave dentro del scope, p. ej. `"site_name"`). + name: String, + /// Devuelve la etiqueta visible en el formulario. + label: String, + /// Devuelve el tipo de campo. + field_type: SettingFieldType, + /// Devuelve si el campo es obligatorio. + required: bool, + /// Devuelve el texto de ayuda opcional bajo el campo. + help_text: Option, + /// Devuelve el valor por defecto como cadena JSON. + default_value: Option, +} + +impl SettingField { + /// Crea un campo de texto con nombre y etiqueta. + pub fn text(name: impl Into, label: impl Into) -> Self { + SettingField { + name: name.into(), + label: label.into(), + field_type: SettingFieldType::Text { max_length: None }, + required: false, + help_text: None, + default_value: None, + } + } + + /// Crea un campo numérico con nombre y etiqueta. + pub fn number(name: impl Into, label: impl Into) -> Self { + SettingField { + name: name.into(), + label: label.into(), + field_type: SettingFieldType::Number { + min: None, + max: None, + }, + required: false, + help_text: None, + default_value: None, + } + } + + /// Crea un campo booleano con nombre y etiqueta. + pub fn boolean(name: impl Into, label: impl Into) -> Self { + SettingField { + name: name.into(), + label: label.into(), + field_type: SettingFieldType::Boolean, + required: false, + help_text: None, + default_value: None, + } + } + + /// Crea un campo de selección con nombre, etiqueta y opciones. + pub fn select( + name: impl Into, + label: impl Into, + options: Vec<(String, String)>, + ) -> Self { + SettingField { + name: name.into(), + label: label.into(), + field_type: SettingFieldType::Select { options }, + required: false, + help_text: None, + default_value: None, + } + } + + /// Establece si el campo es obligatorio. + #[builder_fn] + pub fn with_required(mut self, required: bool) -> Self { + self.required = required; + self + } + + /// Añade texto de ayuda bajo el campo. + #[builder_fn] + pub fn with_help(mut self, text: impl Into) -> Self { + self.help_text = Some(text.into()); + self + } + + /// Establece el valor por defecto (como valor JSON serializado). + #[builder_fn] + pub fn with_default(mut self, value: &T) -> Self { + self.default_value = serde_json::to_string(value).ok(); + self + } +} + +/// Esquema de formulario de configuración: describe un grupo de ajustes en un scope. +#[derive(Clone, Debug, Getters)] +pub struct SettingsSchema { + /// Devuelve el identificador del grupo (prefijo de las claves en `settings`). + scope: String, + /// Devuelve los campos del formulario en orden de presentación. + fields: Vec, +} + +impl SettingsSchema { + /// Crea un nuevo esquema vacío para el `scope` dado. + pub fn new(scope: impl Into) -> Self { + SettingsSchema { + scope: scope.into(), + fields: Vec::new(), + } + } + + /// Añade un campo al esquema. + #[builder_fn] + pub fn with_field(mut self, field: SettingField) -> Self { + self.fields.push(field); + self + } + + /// Devuelve la clave completa de un campo: `"{scope}.{field_name}"`. + pub fn key_for(&self, field_name: &str) -> String { + format!("{}.{}", self.scope, field_name) + } +}