✨ (admin): Añade la extensión pagetop-admin
Panel de administración extensible: registro de secciones/páginas/ tareas/acciones vía acciones `Declare*`, formularios de configuración automáticos (ConfigForm + SettingsSchema) y persistencia en la tabla `settings`.
This commit is contained in:
parent
c0a5a8c3ab
commit
88acc08b5f
18 changed files with 1847 additions and 0 deletions
19
extensions/pagetop-admin/Cargo.toml
Normal file
19
extensions/pagetop-admin/Cargo.toml
Normal file
|
|
@ -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
|
||||||
291
extensions/pagetop-admin/src/action.rs
Normal file
291
extensions/pagetop-admin/src/action.rs
Normal file
|
|
@ -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<AdminSection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SectionBag {
|
||||||
|
/// Añade una sección al registro.
|
||||||
|
pub fn add(&mut self, section: AdminSection) {
|
||||||
|
self.sections.push(section);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bolsa de páginas para [`DeclareAdminPages`].
|
||||||
|
pub struct PageBag {
|
||||||
|
pub(crate) pages: Vec<AdminPage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PageBag {
|
||||||
|
/// Añade una página al registro.
|
||||||
|
pub fn add(&mut self, page: AdminPage) {
|
||||||
|
self.pages.push(page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bolsa de tareas para [`DeclareAdminTasks`].
|
||||||
|
pub struct TaskBag {
|
||||||
|
pub(crate) tasks: Vec<AdminTask>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TaskBag {
|
||||||
|
/// Añade una tarea local al registro.
|
||||||
|
pub fn add(&mut self, task: AdminTask) {
|
||||||
|
self.tasks.push(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bolsa de acciones locales para [`DeclareAdminActions`].
|
||||||
|
pub struct ActionBag {
|
||||||
|
pub(crate) actions: Vec<AdminAction>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActionBag {
|
||||||
|
/// Añade una acción local al registro.
|
||||||
|
pub fn add(&mut self, action: AdminAction) {
|
||||||
|
self.actions.push(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< DeclareAdminSections >***********************************************************************
|
||||||
|
|
||||||
|
/// Acción para declarar secciones del panel de administración.
|
||||||
|
///
|
||||||
|
/// Se despacha durante `registry::build()` (dentro de `initialize()`). El callback
|
||||||
|
/// recibe un [`SectionBag`] y puede llamar a `add()` para registrar secciones.
|
||||||
|
///
|
||||||
|
/// # Ejemplo
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// use pagetop::locale::Lc;
|
||||||
|
/// use pagetop_admin::action::{DeclareAdminSections, SectionBag};
|
||||||
|
/// use pagetop_admin::registry::AdminSection;
|
||||||
|
///
|
||||||
|
/// fn declare_sections(bag: &mut SectionBag) {
|
||||||
|
/// bag.add(AdminSection {
|
||||||
|
/// key: "tools".to_owned(),
|
||||||
|
/// path: "/admin/tools".to_owned(),
|
||||||
|
/// title: Lc::n("Tools"),
|
||||||
|
/// permission: None,
|
||||||
|
/// weight: 60,
|
||||||
|
/// });
|
||||||
|
/// }
|
||||||
|
/// // En Extension::actions():
|
||||||
|
/// // DeclareAdminSections::new(declare_sections)
|
||||||
|
/// ```
|
||||||
|
pub struct DeclareAdminSections {
|
||||||
|
f: FnSectionBag,
|
||||||
|
weight: Weight,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActionDispatcher for DeclareAdminSections {
|
||||||
|
fn weight(&self) -> Weight {
|
||||||
|
self.weight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeclareAdminSections {
|
||||||
|
pub fn new(f: FnSectionBag) -> Self {
|
||||||
|
DeclareAdminSections { f, weight: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_weight(mut self, w: Weight) -> Self {
|
||||||
|
self.weight = w;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn dispatch(bag: &mut SectionBag) {
|
||||||
|
dispatch_actions(
|
||||||
|
&ActionKey::new(UniqueId::of::<Self>(), None, None),
|
||||||
|
|action: &Self| (action.f)(bag),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< DeclareAdminPages >**************************************************************************
|
||||||
|
|
||||||
|
/// Acción para declarar páginas del panel de administración.
|
||||||
|
///
|
||||||
|
/// Se despacha durante `registry::build()`. El callback recibe un [`PageBag`].
|
||||||
|
///
|
||||||
|
/// # Ejemplo
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// use pagetop::locale::Lc;
|
||||||
|
/// use pagetop_admin::action::{DeclareAdminPages, PageBag};
|
||||||
|
/// use pagetop_admin::registry::{AdminPage, AdminPageKind};
|
||||||
|
///
|
||||||
|
/// fn declare_pages(bag: &mut PageBag) {
|
||||||
|
/// bag.add(AdminPage {
|
||||||
|
/// path: "/admin/tools/export".to_owned(),
|
||||||
|
/// section: "tools".to_owned(),
|
||||||
|
/// title: Lc::n("Export"),
|
||||||
|
/// description: Some(Lc::n("Export site data.")),
|
||||||
|
/// weight: 0,
|
||||||
|
/// permission: None,
|
||||||
|
/// kind: AdminPageKind::View,
|
||||||
|
/// });
|
||||||
|
/// }
|
||||||
|
/// // En Extension::actions():
|
||||||
|
/// // DeclareAdminPages::new(declare_pages)
|
||||||
|
/// ```
|
||||||
|
pub struct DeclareAdminPages {
|
||||||
|
f: FnPageBag,
|
||||||
|
weight: Weight,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActionDispatcher for DeclareAdminPages {
|
||||||
|
fn weight(&self) -> Weight {
|
||||||
|
self.weight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeclareAdminPages {
|
||||||
|
pub fn new(f: FnPageBag) -> Self {
|
||||||
|
DeclareAdminPages { f, weight: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_weight(mut self, w: Weight) -> Self {
|
||||||
|
self.weight = w;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn dispatch(bag: &mut PageBag) {
|
||||||
|
dispatch_actions(
|
||||||
|
&ActionKey::new(UniqueId::of::<Self>(), None, None),
|
||||||
|
|action: &Self| (action.f)(bag),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< DeclareAdminTasks >**************************************************************************
|
||||||
|
|
||||||
|
/// Acción para declarar tareas (pestañas) locales en páginas del panel.
|
||||||
|
///
|
||||||
|
/// Se despacha durante `registry::build()`. El callback recibe un [`TaskBag`].
|
||||||
|
/// Cada [`AdminTask`] indica en `parent_path` la página a la que pertenece.
|
||||||
|
///
|
||||||
|
/// # Ejemplo
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// use pagetop::locale::Lc;
|
||||||
|
/// use pagetop_admin::action::{DeclareAdminTasks, TaskBag};
|
||||||
|
/// use pagetop_admin::registry::AdminTask;
|
||||||
|
///
|
||||||
|
/// fn declare_tasks(bag: &mut TaskBag) {
|
||||||
|
/// bag.add(AdminTask {
|
||||||
|
/// path: "/admin/tools/export/csv".to_owned(),
|
||||||
|
/// parent_path: "/admin/tools/export".to_owned(),
|
||||||
|
/// title: Lc::n("CSV"),
|
||||||
|
/// weight: 0,
|
||||||
|
/// is_default: true,
|
||||||
|
/// permission: None,
|
||||||
|
/// });
|
||||||
|
/// }
|
||||||
|
/// // En Extension::actions():
|
||||||
|
/// // DeclareAdminTasks::new(declare_tasks)
|
||||||
|
/// ```
|
||||||
|
pub struct DeclareAdminTasks {
|
||||||
|
f: FnTaskBag,
|
||||||
|
weight: Weight,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActionDispatcher for DeclareAdminTasks {
|
||||||
|
fn weight(&self) -> Weight {
|
||||||
|
self.weight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeclareAdminTasks {
|
||||||
|
pub fn new(f: FnTaskBag) -> Self {
|
||||||
|
DeclareAdminTasks { f, weight: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_weight(mut self, w: Weight) -> Self {
|
||||||
|
self.weight = w;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn dispatch(bag: &mut TaskBag) {
|
||||||
|
dispatch_actions(
|
||||||
|
&ActionKey::new(UniqueId::of::<Self>(), None, None),
|
||||||
|
|action: &Self| (action.f)(bag),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< DeclareAdminActions >************************************************************************
|
||||||
|
|
||||||
|
/// Acción para declarar acciones locales (botones de acción) en páginas del panel.
|
||||||
|
///
|
||||||
|
/// Se despacha durante `registry::build()`. El callback recibe un [`ActionBag`].
|
||||||
|
/// Cada [`AdminAction`] indica en `for_path` la página en la que aparece el botón.
|
||||||
|
///
|
||||||
|
/// # Ejemplo
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// use pagetop::locale::Lc;
|
||||||
|
/// use pagetop_admin::action::{DeclareAdminActions, ActionBag};
|
||||||
|
/// use pagetop_admin::registry::AdminAction;
|
||||||
|
///
|
||||||
|
/// fn declare_actions(bag: &mut ActionBag) {
|
||||||
|
/// bag.add(AdminAction {
|
||||||
|
/// url: "/admin/tools/export/new".to_owned(),
|
||||||
|
/// for_path: "/admin/tools/export".to_owned(),
|
||||||
|
/// title: Lc::n("Add export"),
|
||||||
|
/// weight: 0,
|
||||||
|
/// });
|
||||||
|
/// }
|
||||||
|
/// // En Extension::actions():
|
||||||
|
/// // DeclareAdminActions::new(declare_actions)
|
||||||
|
/// ```
|
||||||
|
pub struct DeclareAdminActions {
|
||||||
|
f: FnActionBag,
|
||||||
|
weight: Weight,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActionDispatcher for DeclareAdminActions {
|
||||||
|
fn weight(&self) -> Weight {
|
||||||
|
self.weight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeclareAdminActions {
|
||||||
|
pub fn new(f: FnActionBag) -> Self {
|
||||||
|
DeclareAdminActions { f, weight: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_weight(mut self, w: Weight) -> Self {
|
||||||
|
self.weight = w;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn dispatch(bag: &mut ActionBag) {
|
||||||
|
dispatch_actions(
|
||||||
|
&ActionKey::new(UniqueId::of::<Self>(), None, None),
|
||||||
|
|action: &Self| (action.f)(bag),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
extensions/pagetop-admin/src/component.rs
Normal file
9
extensions/pagetop-admin/src/component.rs
Normal file
|
|
@ -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;
|
||||||
150
extensions/pagetop-admin/src/component/admin_frame.rs
Normal file
150
extensions/pagetop-admin/src/component/admin_frame.rs
Normal file
|
|
@ -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
|
||||||
|
/// <nav class="admin-breadcrumb">...</nav>
|
||||||
|
/// <header class="admin-header">
|
||||||
|
/// <h1 class="admin-page-title">Title</h1>
|
||||||
|
/// <ul class="admin-actions-list">...</ul>
|
||||||
|
/// </header>
|
||||||
|
/// <nav class="admin-local-tasks">...</nav>
|
||||||
|
/// <div class="admin-content">...</div>
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// El contenido son sus propios hijos ([`Children`]), igual que en cualquier otro componente
|
||||||
|
/// contenedor (p. ej. [`Block`](pagetop::base::component::Block)): admite tantos `with_child(...)`
|
||||||
|
/// como haga falta, cada uno renderizado en el ciclo normal de componentes.
|
||||||
|
#[derive(AutoDefault, Clone, Debug, Getters)]
|
||||||
|
pub struct AdminFrame {
|
||||||
|
/// Devuelve el título de la página.
|
||||||
|
title: Lc,
|
||||||
|
/// Devuelve la lista de componentes hijo de la página.
|
||||||
|
children: Children,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Component for AdminFrame {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||||
|
let path = cx
|
||||||
|
.request()
|
||||||
|
.map(|r| r.path().to_string())
|
||||||
|
.unwrap_or_else(|| "/".to_string());
|
||||||
|
|
||||||
|
let breadcrumbs = render_breadcrumbs(cx, &path).await;
|
||||||
|
let local_tasks = render_local_tasks(cx, &path);
|
||||||
|
let local_acts = render_local_actions(cx, &path);
|
||||||
|
let body = self.children().render(cx).await;
|
||||||
|
|
||||||
|
Ok(html! {
|
||||||
|
(breadcrumbs)
|
||||||
|
header.admin-header {
|
||||||
|
h1.admin-page-title { (self.title().using(cx)) }
|
||||||
|
(local_acts)
|
||||||
|
}
|
||||||
|
@if !local_tasks.0.is_empty() {
|
||||||
|
nav.admin-local-tasks { (local_tasks) }
|
||||||
|
}
|
||||||
|
div.admin-content {
|
||||||
|
(body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AdminFrame {
|
||||||
|
/// Establece el título de la página.
|
||||||
|
#[builder_fn]
|
||||||
|
pub fn with_title(mut self, title: Lc) -> Self {
|
||||||
|
self.title = title;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Añade un componente hijo al contenido de la página.
|
||||||
|
#[builder_fn]
|
||||||
|
pub fn with_child(mut self, op: impl Into<ChildOp>) -> Self {
|
||||||
|
self.children.alter_child(op.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< Breadcrumbs >********************************************************************************
|
||||||
|
|
||||||
|
async fn render_breadcrumbs(cx: &mut Context, current_path: &str) -> Markup {
|
||||||
|
let reg = registry::global();
|
||||||
|
let base = crate::ADMIN_BASE_PATH;
|
||||||
|
|
||||||
|
let home_label = Lc::t("admin-breadcrumb-home", &LOCALES_ADMIN);
|
||||||
|
let mut bc = Breadcrumb::new().with_crumb(breadcrumb::Crumb::new(home_label, base));
|
||||||
|
|
||||||
|
if let Some(page) = reg.pages().get(current_path) {
|
||||||
|
if let Some(section) = reg.sections().get(&page.section) {
|
||||||
|
bc = bc.with_crumb(breadcrumb::Crumb::new(
|
||||||
|
section.title.clone(),
|
||||||
|
section.path.as_str(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
bc = bc.with_crumb(breadcrumb::Crumb::current(page.title.clone()));
|
||||||
|
} else if let Some(section) = reg.sections().values().find(|s| s.path == current_path) {
|
||||||
|
// La ruta actual es la propia sección (su página de aterrizaje), no una `AdminPage`
|
||||||
|
// registrada dentro de ella.
|
||||||
|
bc = bc.with_crumb(breadcrumb::Crumb::current(section.title.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
bc.render(cx).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< Tareas locales >*****************************************************************************
|
||||||
|
|
||||||
|
fn render_local_tasks(cx: &Context, current_path: &str) -> Markup {
|
||||||
|
let reg = registry::global();
|
||||||
|
let tasks = reg.tasks_for(current_path);
|
||||||
|
if tasks.is_empty() {
|
||||||
|
return html! {};
|
||||||
|
}
|
||||||
|
html! {
|
||||||
|
ul.admin-tasks-list {
|
||||||
|
@for task in tasks {
|
||||||
|
@let is_active = current_path == task.path;
|
||||||
|
li class=(if is_active { "admin-task admin-task-active" } else { "admin-task" }) {
|
||||||
|
a href=(cx.route(task.path.as_str())) { (task.title.using(cx)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< Acciones locales >***************************************************************************
|
||||||
|
|
||||||
|
fn render_local_actions(cx: &Context, current_path: &str) -> Markup {
|
||||||
|
let reg = registry::global();
|
||||||
|
let actions = reg.actions_for(current_path);
|
||||||
|
if actions.is_empty() {
|
||||||
|
return html! {};
|
||||||
|
}
|
||||||
|
html! {
|
||||||
|
ul.admin-actions-list {
|
||||||
|
@for action in actions {
|
||||||
|
li.admin-action {
|
||||||
|
a.admin-action-link href=(cx.route(action.url.as_str())) { (action.title.using(cx)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
extensions/pagetop-admin/src/component/admin_menu.rs
Normal file
37
extensions/pagetop-admin/src/component/admin_menu.rs
Normal file
|
|
@ -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<Markup, ComponentError> {
|
||||||
|
if !matches!(
|
||||||
|
cx.template().downcast_ref::<CoreTemplates>(),
|
||||||
|
Some(CoreTemplates::Admin)
|
||||||
|
) {
|
||||||
|
return Ok(html! {});
|
||||||
|
}
|
||||||
|
Ok(registry::admin_menu(cx).render(cx).await)
|
||||||
|
}
|
||||||
|
}
|
||||||
226
extensions/pagetop-admin/src/component/config_form.rs
Normal file
226
extensions/pagetop-admin/src/component/config_form.rs
Normal file
|
|
@ -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<Markup, ErrorPage> {
|
||||||
|
/// let schema = SettingsSchema::new("myapp.general")
|
||||||
|
/// .with_field(SettingField::text("site_name", "Site name").with_required(true));
|
||||||
|
///
|
||||||
|
/// let title = "General settings";
|
||||||
|
/// let mut form = ConfigForm::with_schema(schema);
|
||||||
|
/// let mut cx = Context::new(request.clone());
|
||||||
|
/// let content = form.render(&mut cx).await;
|
||||||
|
///
|
||||||
|
/// Page::admin(request)
|
||||||
|
/// .with_title(Lc::n(title))
|
||||||
|
/// .with_child(
|
||||||
|
/// AdminFrame::new()
|
||||||
|
/// .with_title(Lc::n(title))
|
||||||
|
/// .with_child(Html::with(move |_| content.clone())),
|
||||||
|
/// )
|
||||||
|
/// .render()
|
||||||
|
/// .await
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
#[derive(AutoDefault, Clone, Debug, Getters)]
|
||||||
|
pub struct ConfigForm {
|
||||||
|
/// Devuelve el esquema del formulario, si se ha establecido uno.
|
||||||
|
schema: Option<SettingsSchema>,
|
||||||
|
/// Devuelve la ruta de destino del formulario, si se ha personalizado.
|
||||||
|
action_path: Option<Route>,
|
||||||
|
/// Devuelve si el último envío se guardó correctamente.
|
||||||
|
saved: bool,
|
||||||
|
/// Devuelve si el último envío produjo un error al guardar.
|
||||||
|
error: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Component for ConfigForm {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||||
|
let Some(schema) = self.schema() else {
|
||||||
|
return Ok(html! {});
|
||||||
|
};
|
||||||
|
|
||||||
|
let action = match self.action_path() {
|
||||||
|
Some(route) => route.resolve(cx),
|
||||||
|
None => cx.route(cx.request().map(|r| r.path()).unwrap_or("/")),
|
||||||
|
};
|
||||||
|
|
||||||
|
let save_label = Lc::t("config-save-label", &LOCALES_ADMIN)
|
||||||
|
.lookup(cx)
|
||||||
|
.unwrap_or_else(|| "Save configuration".into());
|
||||||
|
|
||||||
|
let saved_msg = Lc::t("config-saved-ok", &LOCALES_ADMIN)
|
||||||
|
.lookup(cx)
|
||||||
|
.unwrap_or_else(|| "Configuration saved.".into());
|
||||||
|
|
||||||
|
let error_msg = Lc::t("config-saved-error", &LOCALES_ADMIN)
|
||||||
|
.lookup(cx)
|
||||||
|
.unwrap_or_else(|| "Could not save configuration.".into());
|
||||||
|
|
||||||
|
// Pre-computa los valores de BD para cada campo antes del macro html! (que es síncrono).
|
||||||
|
struct FieldVals {
|
||||||
|
raw_val: String,
|
||||||
|
num_val: String,
|
||||||
|
checked: bool,
|
||||||
|
}
|
||||||
|
let mut prepared: Vec<FieldVals> = Vec::with_capacity(schema.fields().len());
|
||||||
|
for field in schema.fields() {
|
||||||
|
let key = schema.key_for(field.name());
|
||||||
|
let raw_val =
|
||||||
|
get_or::<String>(&key, field.default_value().cloned().unwrap_or_default()).await;
|
||||||
|
let num_val = get_or::<f64>(&key, 0.0).await.to_string();
|
||||||
|
let checked = get_or::<bool>(&key, false).await;
|
||||||
|
prepared.push(FieldVals {
|
||||||
|
raw_val,
|
||||||
|
num_val,
|
||||||
|
checked,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(html! {
|
||||||
|
@if *self.saved() {
|
||||||
|
div.admin-message."admin-message-ok" { (&saved_msg) }
|
||||||
|
}
|
||||||
|
@if *self.error() {
|
||||||
|
div.admin-message."admin-message-error" { (&error_msg) }
|
||||||
|
}
|
||||||
|
form.admin-config-form method="post" action=(action) {
|
||||||
|
@for (i, field) in schema.fields().iter().enumerate() {
|
||||||
|
@let pf = &prepared[i];
|
||||||
|
div.admin-form-field {
|
||||||
|
label.admin-form-label for=(field.name()) { (field.label()) }
|
||||||
|
@match field.field_type() {
|
||||||
|
SettingFieldType::Text { max_length } => {
|
||||||
|
@if let Some(max) = max_length {
|
||||||
|
input.admin-form-input
|
||||||
|
type="text"
|
||||||
|
id=(field.name())
|
||||||
|
name=(field.name())
|
||||||
|
value=(&pf.raw_val)
|
||||||
|
maxlength=(max)
|
||||||
|
required[*field.required()];
|
||||||
|
} @else {
|
||||||
|
input.admin-form-input
|
||||||
|
type="text"
|
||||||
|
id=(field.name())
|
||||||
|
name=(field.name())
|
||||||
|
value=(&pf.raw_val)
|
||||||
|
required[*field.required()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingFieldType::Number { min, max } => {
|
||||||
|
@if let (Some(lo), Some(hi)) = (min, max) {
|
||||||
|
input.admin-form-input
|
||||||
|
type="number"
|
||||||
|
id=(field.name())
|
||||||
|
name=(field.name())
|
||||||
|
value=(&pf.num_val)
|
||||||
|
min=(lo)
|
||||||
|
max=(hi)
|
||||||
|
required[*field.required()];
|
||||||
|
} @else if let Some(lo) = min {
|
||||||
|
input.admin-form-input
|
||||||
|
type="number"
|
||||||
|
id=(field.name())
|
||||||
|
name=(field.name())
|
||||||
|
value=(&pf.num_val)
|
||||||
|
min=(lo)
|
||||||
|
required[*field.required()];
|
||||||
|
} @else if let Some(hi) = max {
|
||||||
|
input.admin-form-input
|
||||||
|
type="number"
|
||||||
|
id=(field.name())
|
||||||
|
name=(field.name())
|
||||||
|
value=(&pf.num_val)
|
||||||
|
max=(hi)
|
||||||
|
required[*field.required()];
|
||||||
|
} @else {
|
||||||
|
input.admin-form-input
|
||||||
|
type="number"
|
||||||
|
id=(field.name())
|
||||||
|
name=(field.name())
|
||||||
|
value=(&pf.num_val)
|
||||||
|
required[*field.required()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingFieldType::Boolean => {
|
||||||
|
input.admin-form-checkbox
|
||||||
|
type="checkbox"
|
||||||
|
id=(field.name())
|
||||||
|
name=(field.name())
|
||||||
|
value="true"
|
||||||
|
checked[pf.checked];
|
||||||
|
}
|
||||||
|
SettingFieldType::Select { options } => {
|
||||||
|
select.admin-form-select
|
||||||
|
id=(field.name())
|
||||||
|
name=(field.name())
|
||||||
|
required[*field.required()]
|
||||||
|
{
|
||||||
|
@for (val, label) in options {
|
||||||
|
@if &pf.raw_val == val {
|
||||||
|
option value=(val) selected { (label) }
|
||||||
|
} @else {
|
||||||
|
option value=(val) { (label) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if let Some(help) = field.help_text() {
|
||||||
|
small.admin-form-help { (help) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div.admin-form-actions {
|
||||||
|
button.admin-btn."admin-btn-primary" type="submit" {
|
||||||
|
(&save_label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigForm {
|
||||||
|
/// Crea el componente con el [`SettingsSchema`] dado.
|
||||||
|
pub fn with_schema(schema: SettingsSchema) -> Self {
|
||||||
|
ConfigForm {
|
||||||
|
schema: Some(schema),
|
||||||
|
..Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[builder_fn]
|
||||||
|
pub fn with_action_path(mut self, v: impl Into<Option<Route>>) -> Self {
|
||||||
|
if let Some(v) = v.into() {
|
||||||
|
self.action_path = Some(v);
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn with_saved(mut self, saved: bool, error: bool) -> Self {
|
||||||
|
self.saved = saved;
|
||||||
|
self.error = error;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
3
extensions/pagetop-admin/src/entity.rs
Normal file
3
extensions/pagetop-admin/src/entity.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
//! Entidades SeaORM de `pagetop-admin`.
|
||||||
|
|
||||||
|
pub(crate) mod setting;
|
||||||
20
extensions/pagetop-admin/src/entity/setting.rs
Normal file
20
extensions/pagetop-admin/src/entity/setting.rs
Normal file
|
|
@ -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<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
14
extensions/pagetop-admin/src/error.rs
Normal file
14
extensions/pagetop-admin/src/error.rs
Normal file
|
|
@ -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),
|
||||||
|
}
|
||||||
207
extensions/pagetop-admin/src/handlers.rs
Normal file
207
extensions/pagetop-admin/src/handlers.rs
Normal file
|
|
@ -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<Markup, ErrorPage> {
|
||||||
|
let reg = registry::global();
|
||||||
|
let cx = Context::new(request.clone());
|
||||||
|
let sections = reg.ordered_sections();
|
||||||
|
|
||||||
|
let title = Lc::t("dashboard-title", &LOCALES_ADMIN);
|
||||||
|
let content = render_sections(&cx, §ions);
|
||||||
|
|
||||||
|
Page::admin(request)
|
||||||
|
.with_title(title.clone())
|
||||||
|
.with_child(
|
||||||
|
AdminFrame::new()
|
||||||
|
.with_title(title)
|
||||||
|
.with_child(Html::with(move |_| content.clone())),
|
||||||
|
)
|
||||||
|
.render()
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< Sección >************************************************************************************
|
||||||
|
|
||||||
|
/// GET /admin/{section} - Página de aterrizaje de una sección: lista sus páginas.
|
||||||
|
///
|
||||||
|
/// Sólo se monta (ver `configure_router()` en `lib.rs`) para las secciones cuyo `path` no coincida
|
||||||
|
/// con ninguna [`AdminPage`](crate::registry::AdminPage) registrada explícitamente por una
|
||||||
|
/// extensión; si una extensión reclama esa ruta con su propia página, esa página gana y este
|
||||||
|
/// handler nunca llega a montarse para ella.
|
||||||
|
pub async fn section_page(request: HttpRequest) -> Result<Markup, ErrorPage> {
|
||||||
|
let path = request.path().to_owned();
|
||||||
|
let reg = registry::global();
|
||||||
|
let section = reg
|
||||||
|
.sections()
|
||||||
|
.values()
|
||||||
|
.find(|s| s.path == path)
|
||||||
|
.ok_or_else(|| ErrorPage::NotFound(Some(request.clone())))?;
|
||||||
|
|
||||||
|
require_permission(
|
||||||
|
&request,
|
||||||
|
section.permission.unwrap_or(&AdminPermission::Access),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let cx = Context::new(request.clone());
|
||||||
|
let title = section.title.clone();
|
||||||
|
let content = render_sections(&cx, &[section]);
|
||||||
|
|
||||||
|
Page::admin(request)
|
||||||
|
.with_title(title.clone())
|
||||||
|
.with_child(
|
||||||
|
AdminFrame::new()
|
||||||
|
.with_title(title)
|
||||||
|
.with_child(Html::with(move |_| content.clone())),
|
||||||
|
)
|
||||||
|
.render()
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< Render compartido >**************************************************************************
|
||||||
|
|
||||||
|
// Rejilla de secciones con sus páginas (título + descripción). La usan tanto `dashboard()` (todas
|
||||||
|
// las secciones) como `section_page()` (una sola), filtrando siempre por permiso del usuario actual.
|
||||||
|
fn render_sections(cx: &Context, sections: &[&AdminSection]) -> Markup {
|
||||||
|
let reg = registry::global();
|
||||||
|
html! {
|
||||||
|
div.admin-dashboard-sections {
|
||||||
|
@for section in sections {
|
||||||
|
@if section.is_visible(cx) {
|
||||||
|
@let pages: Vec<_> = reg
|
||||||
|
.pages_for_section(§ion.key)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|p| p.is_accessible(cx))
|
||||||
|
.collect();
|
||||||
|
@if !pages.is_empty() {
|
||||||
|
div.admin-dashboard-section {
|
||||||
|
h2.admin-dashboard-section-title {
|
||||||
|
a href=(cx.route(section.path.as_str())) { (section.title.using(cx)) }
|
||||||
|
}
|
||||||
|
ul.admin-dashboard-section-links {
|
||||||
|
@for page in pages {
|
||||||
|
li.admin-dashboard-link {
|
||||||
|
a href=(cx.route(page.path.as_str())) { (page.title.using(cx)) }
|
||||||
|
@if let Some(description) = &page.description {
|
||||||
|
span.admin-dashboard-link-desc {
|
||||||
|
" - " (description.using(cx))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< Config form - GET >**************************************************************************
|
||||||
|
|
||||||
|
/// GET de una página [`AdminPageKind::ConfigForm`].
|
||||||
|
///
|
||||||
|
/// Lee el schema del registro y renderiza el formulario con los valores actuales.
|
||||||
|
pub async fn config_form_get(request: HttpRequest) -> Result<Markup, ErrorPage> {
|
||||||
|
let path = request.path().to_owned();
|
||||||
|
let reg = registry::global();
|
||||||
|
let page = reg
|
||||||
|
.pages()
|
||||||
|
.get(&path)
|
||||||
|
.ok_or_else(|| ErrorPage::NotFound(Some(request.clone())))?;
|
||||||
|
|
||||||
|
let AdminPageKind::ConfigForm(ref schema) = page.kind else {
|
||||||
|
return Err(ErrorPage::NotFound(Some(request.clone())));
|
||||||
|
};
|
||||||
|
|
||||||
|
require_permission(&request, page.permission_key())?;
|
||||||
|
|
||||||
|
let title = page.title.clone();
|
||||||
|
let mut form = ConfigForm::with_schema(schema.clone());
|
||||||
|
let mut cx = Context::new(request.clone());
|
||||||
|
let content = form.render(&mut cx).await;
|
||||||
|
|
||||||
|
Page::admin(request)
|
||||||
|
.with_title(title.clone())
|
||||||
|
.with_child(
|
||||||
|
AdminFrame::new()
|
||||||
|
.with_title(title)
|
||||||
|
.with_child(Html::with(move |_| content.clone())),
|
||||||
|
)
|
||||||
|
.render()
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< Config form - POST >*************************************************************************
|
||||||
|
|
||||||
|
/// POST de una página [`AdminPageKind::ConfigForm`].
|
||||||
|
///
|
||||||
|
/// Valida y persiste los valores en `settings`, luego redirige al GET con
|
||||||
|
/// estado `saved=true`.
|
||||||
|
pub async fn config_form_post(
|
||||||
|
request: HttpRequest,
|
||||||
|
web::Form(data): web::Form<HashMap<String, String>>,
|
||||||
|
) -> Result<Markup, ErrorPage> {
|
||||||
|
let path = request.path().to_owned();
|
||||||
|
let reg = registry::global();
|
||||||
|
let page = reg
|
||||||
|
.pages()
|
||||||
|
.get(&path)
|
||||||
|
.ok_or_else(|| ErrorPage::NotFound(Some(request.clone())))?;
|
||||||
|
|
||||||
|
let AdminPageKind::ConfigForm(ref schema) = page.kind else {
|
||||||
|
return Err(ErrorPage::NotFound(Some(request.clone())));
|
||||||
|
};
|
||||||
|
|
||||||
|
require_permission(&request, page.permission_key())?;
|
||||||
|
|
||||||
|
let mut save_error = false;
|
||||||
|
|
||||||
|
for field in schema.fields() {
|
||||||
|
let key = schema.key_for(field.name());
|
||||||
|
|
||||||
|
// Los checkbox HTML sólo envían el campo si están marcados.
|
||||||
|
let raw = match field.field_type() {
|
||||||
|
crate::settings::SettingFieldType::Boolean => data
|
||||||
|
.get(field.name())
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.unwrap_or("false")
|
||||||
|
.to_owned(),
|
||||||
|
_ => {
|
||||||
|
let Some(raw) = data.get(field.name()) else {
|
||||||
|
if *field.required() {
|
||||||
|
save_error = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
raw.clone()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
settings::set::<String>(&key, &raw, schema.scope(), None).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let title = page.title.clone();
|
||||||
|
let mut form = ConfigForm::with_schema(schema.clone()).with_saved(!save_error, save_error);
|
||||||
|
let mut cx = Context::new(request.clone());
|
||||||
|
let content = form.render(&mut cx).await;
|
||||||
|
|
||||||
|
Page::admin(request)
|
||||||
|
.with_title(title.clone())
|
||||||
|
.with_child(
|
||||||
|
AdminFrame::new()
|
||||||
|
.with_title(title)
|
||||||
|
.with_child(Html::with(move |_| content.clone())),
|
||||||
|
)
|
||||||
|
.render()
|
||||||
|
.await
|
||||||
|
}
|
||||||
194
extensions/pagetop-admin/src/lib.rs
Normal file
194
extensions/pagetop-admin/src/lib.rs
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
/*!
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<h1>PageTop Admin</h1>
|
||||||
|
|
||||||
|
<p>Panel de administración extensible para <strong>PageTop</strong>.</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## Guía rápida
|
||||||
|
|
||||||
|
Declara la dependencia en tu `Cargo.toml` activando el motor de base de datos:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
pagetop-admin = { version = "...", features = ["sqlite"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Añade `&pagetop_admin::Admin` a las dependencias de tu extensión y declara tus
|
||||||
|
secciones, páginas o configuración:
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
use pagetop::prelude::*;
|
||||||
|
use pagetop_admin::prelude::*;
|
||||||
|
use pagetop_admin::settings::{SettingField, SettingsSchema};
|
||||||
|
|
||||||
|
pub struct MyApp;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Extension for MyApp {
|
||||||
|
fn dependencies(&self) -> Vec<ExtensionRef> {
|
||||||
|
vec![&pagetop_admin::Admin]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn actions(&self) -> Vec<ActionBox> {
|
||||||
|
actions![
|
||||||
|
DeclareAdminPages::new(declare_pages),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn configure_router(&self, router: Router) -> Router {
|
||||||
|
router.route("/admin/config/myapp", web::get(config_handler))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn declare_pages(bag: &mut PageBag) {
|
||||||
|
bag.add(AdminPage {
|
||||||
|
path: "/admin/config/myapp".to_owned(),
|
||||||
|
section: "config".to_owned(),
|
||||||
|
title: Lc::n("My App"),
|
||||||
|
description: Some(Lc::n("Configure My App.")),
|
||||||
|
weight: 0,
|
||||||
|
permission: Some(&MyPermission::Config),
|
||||||
|
kind: AdminPageKind::View,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
enum MyPermission {
|
||||||
|
Config,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Permission for MyPermission {
|
||||||
|
fn key(&self) -> CowStr {
|
||||||
|
match self {
|
||||||
|
Self::Config => "myapp.config".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn config_handler(request: HttpRequest) -> Result<Markup, ErrorPage> {
|
||||||
|
let schema = SettingsSchema::new("myapp.config")
|
||||||
|
.with_field(SettingField::text("site_name", "Site name").with_required(true));
|
||||||
|
|
||||||
|
let title = "My App configuration";
|
||||||
|
let mut form = ConfigForm::with_schema(schema);
|
||||||
|
let mut cx = Context::new(request.clone());
|
||||||
|
let content = form.render(&mut cx).await;
|
||||||
|
|
||||||
|
Page::admin(request)
|
||||||
|
.with_title(Lc::n(title))
|
||||||
|
.with_child(
|
||||||
|
AdminFrame::new()
|
||||||
|
.with_title(Lc::n(title))
|
||||||
|
.with_child(Html::with(move |_| content.clone())),
|
||||||
|
)
|
||||||
|
.render()
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Para formularios de configuración enteramente automáticos (GET y POST gestionados
|
||||||
|
por `pagetop-admin`), usa `AdminPageKind::ConfigForm(schema)` en lugar de `View` y
|
||||||
|
no registres la ruta tú mismo.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use pagetop::prelude::*;
|
||||||
|
use pagetop_seaorm::install_migrations;
|
||||||
|
|
||||||
|
include_locales!(LOCALES_ADMIN);
|
||||||
|
|
||||||
|
/// Ruta raíz del panel de administración. No es configurable: otras piezas del ecosistema (p. ej.
|
||||||
|
/// las rutas de `pagetop-user`, `/admin/user/...`) ya asumen este valor de forma literal.
|
||||||
|
pub(crate) const ADMIN_BASE_PATH: &str = "/admin";
|
||||||
|
|
||||||
|
pub mod action;
|
||||||
|
pub mod component;
|
||||||
|
pub mod error;
|
||||||
|
pub mod registry;
|
||||||
|
pub mod settings;
|
||||||
|
|
||||||
|
pub(crate) mod entity;
|
||||||
|
pub(crate) mod handlers;
|
||||||
|
pub(crate) mod migration;
|
||||||
|
pub(crate) mod seed;
|
||||||
|
|
||||||
|
pub use action::{
|
||||||
|
ActionBag, DeclareAdminActions, DeclareAdminPages, DeclareAdminSections, DeclareAdminTasks,
|
||||||
|
PageBag, SectionBag, TaskBag,
|
||||||
|
};
|
||||||
|
pub use component::{AdminFrame, AdminMenu, ConfigForm};
|
||||||
|
pub use registry::{AdminAction, AdminPage, AdminPageKind, AdminSection, AdminTask};
|
||||||
|
pub use settings::{SettingField, SettingFieldType, SettingsSchema};
|
||||||
|
|
||||||
|
/// Prelude de `pagetop-admin`.
|
||||||
|
pub mod prelude {
|
||||||
|
pub use crate::action::{
|
||||||
|
ActionBag, DeclareAdminActions, DeclareAdminPages, DeclareAdminSections, DeclareAdminTasks,
|
||||||
|
PageBag, SectionBag, TaskBag,
|
||||||
|
};
|
||||||
|
pub use crate::component::{AdminFrame, AdminMenu, ConfigForm};
|
||||||
|
pub use crate::error::AdminError;
|
||||||
|
pub use crate::registry::{AdminAction, AdminPage, AdminPageKind, AdminSection, AdminTask};
|
||||||
|
pub use crate::settings::{SettingField, SettingFieldType, SettingsSchema};
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< Extension >**********************************************************************************
|
||||||
|
|
||||||
|
/// Implementa la extensión `pagetop-admin`.
|
||||||
|
pub struct Admin;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Extension for Admin {
|
||||||
|
fn name(&self) -> Lc {
|
||||||
|
Lc::t("extension_name", &LOCALES_ADMIN)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> Lc {
|
||||||
|
Lc::t("extension_description", &LOCALES_ADMIN)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dependencies(&self) -> Vec<ExtensionRef> {
|
||||||
|
vec![&pagetop_seaorm::SeaORM]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn actions(&self) -> Vec<ActionBox> {
|
||||||
|
actions![action::DeclareAdminSections::new(seed::declare_default_sections).with_weight(-99),]
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn initialize(&self) {
|
||||||
|
install_migrations!(m20260629_000001_create_settings);
|
||||||
|
registry::build();
|
||||||
|
|
||||||
|
// Región neutra del core: cualquier tema puede decidir renderizarla (o no) sin que
|
||||||
|
// `pagetop-admin` dependa de ninguno en concreto -- ver `CoreRegions::Aside`.
|
||||||
|
InRegion::Global(&CoreRegions::Aside).add(component::AdminMenu::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn configure_router(&self, router: Router) -> Router {
|
||||||
|
let base = ADMIN_BASE_PATH;
|
||||||
|
let reg = registry::global();
|
||||||
|
|
||||||
|
let mut r = router.route(base, web::get(handlers::dashboard));
|
||||||
|
|
||||||
|
// Página de aterrizaje de cada sección, salvo que una extensión ya haya reclamado esa
|
||||||
|
// misma ruta con su propia `AdminPage` (esa página gana y se monta más abajo).
|
||||||
|
for section in reg.sections().values() {
|
||||||
|
if !reg.pages().contains_key(§ion.path) {
|
||||||
|
r = r.route(section.path.as_str(), web::get(handlers::section_page));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (path, page) in reg.pages() {
|
||||||
|
if let AdminPageKind::ConfigForm(_) = &page.kind {
|
||||||
|
r = r.route(
|
||||||
|
path.as_str(),
|
||||||
|
web::get(handlers::config_form_get).post(handlers::config_form_post),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r
|
||||||
|
}
|
||||||
|
}
|
||||||
21
extensions/pagetop-admin/src/locale/en-US/common.ftl
Normal file
21
extensions/pagetop-admin/src/locale/en-US/common.ftl
Normal file
|
|
@ -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
|
||||||
21
extensions/pagetop-admin/src/locale/es-ES/common.ftl
Normal file
21
extensions/pagetop-admin/src/locale/es-ES/common.ftl
Normal file
|
|
@ -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
|
||||||
3
extensions/pagetop-admin/src/migration.rs
Normal file
3
extensions/pagetop-admin/src/migration.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
//! Migraciones de base de datos de `pagetop-admin`.
|
||||||
|
|
||||||
|
pub(crate) mod m20260629_000001_create_settings;
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
295
extensions/pagetop-admin/src/registry.rs
Normal file
295
extensions/pagetop-admin/src/registry.rs
Normal file
|
|
@ -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<PermissionRef>,
|
||||||
|
/// Peso para ordenar en el sidebar (menor = antes).
|
||||||
|
pub weight: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AdminSection {
|
||||||
|
/// Devuelve `true` si el usuario actual puede ver esta sección.
|
||||||
|
pub fn is_visible(&self, cx: &Context) -> bool {
|
||||||
|
match self.permission {
|
||||||
|
None => true,
|
||||||
|
Some(permission) => cx
|
||||||
|
.request()
|
||||||
|
.is_some_and(|request| has_permission(request, permission)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Página del panel de administración.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AdminPage {
|
||||||
|
/// Ruta exacta de la página (p. ej. `"/admin/config/site"`).
|
||||||
|
pub path: String,
|
||||||
|
/// Clave de la sección a la que pertenece.
|
||||||
|
pub section: String,
|
||||||
|
/// Título visible en el sidebar y encabezado de página.
|
||||||
|
pub title: Lc,
|
||||||
|
/// Descripción breve (para el dashboard y listas de páginas), si tiene una.
|
||||||
|
pub description: Option<Lc>,
|
||||||
|
/// Peso dentro de la sección (menor = antes).
|
||||||
|
pub weight: i32,
|
||||||
|
/// Permiso requerido para acceder (`None` = requiere [`AdminPermission::Access`]).
|
||||||
|
pub permission: Option<PermissionRef>,
|
||||||
|
/// Tipo de página y datos asociados.
|
||||||
|
pub kind: AdminPageKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AdminPage {
|
||||||
|
/// Permiso efectivo: el declarado, o [`AdminPermission::Access`] si no se especificó ninguno.
|
||||||
|
pub fn permission_key(&self) -> PermissionRef {
|
||||||
|
self.permission.unwrap_or(&AdminPermission::Access)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Devuelve `true` si el usuario actual puede acceder a esta página.
|
||||||
|
pub fn is_accessible(&self, cx: &Context) -> bool {
|
||||||
|
cx.request()
|
||||||
|
.is_some_and(|request| has_permission(request, self.permission_key()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Variantes de comportamiento de una [`AdminPage`].
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum AdminPageKind {
|
||||||
|
/// Página genérica cuyo handler se registra externamente.
|
||||||
|
View,
|
||||||
|
/// Formulario de configuración gestionado automáticamente por `pagetop-admin`.
|
||||||
|
ConfigForm(SettingsSchema),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tarea (pestaña) local dentro de una página de administración.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AdminTask {
|
||||||
|
/// Ruta de la tarea.
|
||||||
|
pub path: String,
|
||||||
|
/// Ruta de la página padre.
|
||||||
|
pub parent_path: String,
|
||||||
|
/// Etiqueta de la pestaña.
|
||||||
|
pub title: Lc,
|
||||||
|
/// Peso (menor = primera pestaña).
|
||||||
|
pub weight: i32,
|
||||||
|
/// Si es la tarea por defecto (pestaña activa al entrar a la página padre).
|
||||||
|
pub is_default: bool,
|
||||||
|
/// Permiso requerido (`None` = mismo que la página padre).
|
||||||
|
pub permission: Option<PermissionRef>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acción local (botón de acción) en una página de administración.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct AdminAction {
|
||||||
|
/// Ruta de destino de la acción.
|
||||||
|
pub url: String,
|
||||||
|
/// Página en la que aparece el botón.
|
||||||
|
pub for_path: String,
|
||||||
|
/// Etiqueta del botón.
|
||||||
|
pub title: Lc,
|
||||||
|
/// Peso (menor = primero).
|
||||||
|
pub weight: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< AdminRegistry >*******************************************************************************
|
||||||
|
|
||||||
|
/// Registro global del panel de administración, construido una sola vez en `initialize()`.
|
||||||
|
#[derive(Getters)]
|
||||||
|
pub struct AdminRegistry {
|
||||||
|
/// Devuelve las secciones indexadas por clave (`BTreeMap` para orden estable por clave).
|
||||||
|
sections: BTreeMap<String, AdminSection>,
|
||||||
|
/// Devuelve las páginas indexadas por ruta.
|
||||||
|
pages: BTreeMap<String, AdminPage>,
|
||||||
|
/// Devuelve las tareas indexadas por ruta de página padre.
|
||||||
|
tasks: BTreeMap<String, Vec<AdminTask>>,
|
||||||
|
/// Devuelve las acciones indexadas por ruta de página.
|
||||||
|
actions: BTreeMap<String, Vec<AdminAction>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AdminRegistry {
|
||||||
|
fn new() -> Self {
|
||||||
|
AdminRegistry {
|
||||||
|
sections: BTreeMap::new(),
|
||||||
|
pages: BTreeMap::new(),
|
||||||
|
tasks: BTreeMap::new(),
|
||||||
|
actions: BTreeMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Devuelve las páginas de una sección ordenadas por peso.
|
||||||
|
pub fn pages_for_section(&self, section_key: &str) -> Vec<&AdminPage> {
|
||||||
|
let mut pages: Vec<&AdminPage> = self
|
||||||
|
.pages
|
||||||
|
.values()
|
||||||
|
.filter(|p| p.section == section_key)
|
||||||
|
.collect();
|
||||||
|
pages.sort_by_key(|p| p.weight);
|
||||||
|
pages
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Devuelve las secciones ordenadas por peso.
|
||||||
|
pub fn ordered_sections(&self) -> Vec<&AdminSection> {
|
||||||
|
let mut sections: Vec<&AdminSection> = self.sections.values().collect();
|
||||||
|
sections.sort_by_key(|s| s.weight);
|
||||||
|
sections
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Devuelve las tareas de una página ordenadas por peso.
|
||||||
|
pub fn tasks_for(&self, path: &str) -> Vec<&AdminTask> {
|
||||||
|
let Some(tasks) = self.tasks.get(path) else {
|
||||||
|
return vec![];
|
||||||
|
};
|
||||||
|
let mut t: Vec<&AdminTask> = tasks.iter().collect();
|
||||||
|
t.sort_by_key(|t| t.weight);
|
||||||
|
t
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Devuelve las acciones de una página ordenadas por peso.
|
||||||
|
pub fn actions_for(&self, path: &str) -> Vec<&AdminAction> {
|
||||||
|
let Some(actions) = self.actions.get(path) else {
|
||||||
|
return vec![];
|
||||||
|
};
|
||||||
|
let mut a: Vec<&AdminAction> = actions.iter().collect();
|
||||||
|
a.sort_by_key(|a| a.weight);
|
||||||
|
a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< Registro global >****************************************************************************
|
||||||
|
|
||||||
|
static REGISTRY: OnceLock<AdminRegistry> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Construye el registro despachando todas las acciones de declaración.
|
||||||
|
///
|
||||||
|
/// Se llama una sola vez desde `Admin::initialize()`.
|
||||||
|
pub(crate) fn build() {
|
||||||
|
let mut registry = AdminRegistry::new();
|
||||||
|
|
||||||
|
// Secciones
|
||||||
|
let mut section_bag = SectionBag {
|
||||||
|
sections: Vec::new(),
|
||||||
|
};
|
||||||
|
DeclareAdminSections::dispatch(&mut section_bag);
|
||||||
|
for s in section_bag.sections {
|
||||||
|
registry.sections.insert(s.key.clone(), s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Páginas
|
||||||
|
let mut page_bag = PageBag { pages: Vec::new() };
|
||||||
|
DeclareAdminPages::dispatch(&mut page_bag);
|
||||||
|
for p in page_bag.pages {
|
||||||
|
registry.pages.insert(p.path.clone(), p);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tareas
|
||||||
|
let mut task_bag = TaskBag { tasks: Vec::new() };
|
||||||
|
DeclareAdminTasks::dispatch(&mut task_bag);
|
||||||
|
for t in task_bag.tasks {
|
||||||
|
registry
|
||||||
|
.tasks
|
||||||
|
.entry(t.parent_path.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acciones locales
|
||||||
|
let mut action_bag = ActionBag {
|
||||||
|
actions: Vec::new(),
|
||||||
|
};
|
||||||
|
DeclareAdminActions::dispatch(&mut action_bag);
|
||||||
|
for a in action_bag.actions {
|
||||||
|
registry
|
||||||
|
.actions
|
||||||
|
.entry(a.for_path.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
REGISTRY.set(registry).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accede al registro global del panel de administración.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Entra en pánico si se llama antes de que `Admin::initialize()` haya completado.
|
||||||
|
pub fn global() -> &'static AdminRegistry {
|
||||||
|
REGISTRY.get().expect("AdminRegistry not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< Menú de administración >*********************************************************************
|
||||||
|
|
||||||
|
/// Construye el menú plano de secciones visibles para el usuario de la petición actual.
|
||||||
|
///
|
||||||
|
/// Pensado para que un tema lo use como navegación de `CoreTemplates::Admin` (p. ej. un sidebar) --
|
||||||
|
/// ver [`crate::component::AdminMenu`]. `pagetop-admin` no impone ningún marcado propio: el
|
||||||
|
/// [`Nav`] resultante se renderiza con su aspecto por defecto salvo que el tema lo intercepte en
|
||||||
|
/// [`Theme::handle_component()`](pagetop::core::theme::Theme::handle_component).
|
||||||
|
pub fn admin_menu(cx: &Context) -> Nav {
|
||||||
|
let reg = global();
|
||||||
|
let current_path = cx.request().map(|r| r.path()).unwrap_or("");
|
||||||
|
|
||||||
|
let mut result = Nav::new();
|
||||||
|
for section in reg.ordered_sections() {
|
||||||
|
if !section.is_visible(cx) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let active = current_path.starts_with(section.path.as_str());
|
||||||
|
result = result.with_item(
|
||||||
|
nav::Item::link(section.title.clone(), section.path.clone()).with_active(active),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
50
extensions/pagetop-admin/src/seed.rs
Normal file
50
extensions/pagetop-admin/src/seed.rs
Normal file
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
238
extensions/pagetop-admin/src/settings.rs
Normal file
238
extensions/pagetop-admin/src/settings.rs
Normal file
|
|
@ -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<T: DeserializeOwned + Default>(key: &str) -> T {
|
||||||
|
get_async(key).await.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lee un valor persistido, devolviendo `default` si no existe o hay error.
|
||||||
|
pub async fn get_or<T: DeserializeOwned>(key: &str, default: T) -> T {
|
||||||
|
get_async(key).await.unwrap_or(default)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Escribe un valor en la tabla `settings`.
|
||||||
|
///
|
||||||
|
/// Si la clave no existe se inserta; si existe, se actualiza.
|
||||||
|
pub async fn set<T: Serialize>(key: &str, value: &T, scope: &str, user_id: Option<i32>) {
|
||||||
|
set_async(key, value, scope, user_id).await.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Elimina una entrada de `settings` por clave.
|
||||||
|
pub async fn delete(key: &str) {
|
||||||
|
delete_async(key).await.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Devuelve todos los pares `(key, value_json)` de un `scope` dado.
|
||||||
|
pub async fn list_scope(scope: &str) -> Vec<(String, String)> {
|
||||||
|
list_scope_async(scope).await.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< Implementación asíncrona >********************************************************************
|
||||||
|
|
||||||
|
async fn get_async<T: DeserializeOwned>(key: &str) -> Result<T, AdminError> {
|
||||||
|
let model = Entity::find_by_id(key)
|
||||||
|
.one(dbconn())
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AdminError::NotFound(key.to_owned()))?;
|
||||||
|
Ok(serde_json::from_str(&model.value)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_async<T: Serialize>(
|
||||||
|
key: &str,
|
||||||
|
value: &T,
|
||||||
|
scope: &str,
|
||||||
|
user_id: Option<i32>,
|
||||||
|
) -> Result<(), AdminError> {
|
||||||
|
let value_json = serde_json::to_string(value)?;
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
|
||||||
|
let existing = Entity::find_by_id(key).one(dbconn()).await?;
|
||||||
|
if existing.is_some() {
|
||||||
|
let model = ActiveModel {
|
||||||
|
key: ActiveValue::Unchanged(key.to_owned()),
|
||||||
|
scope: ActiveValue::Set(scope.to_owned()),
|
||||||
|
value: ActiveValue::Set(value_json),
|
||||||
|
updated_at: ActiveValue::Set(now),
|
||||||
|
updated_by: ActiveValue::Set(user_id),
|
||||||
|
};
|
||||||
|
model.update(dbconn()).await?;
|
||||||
|
} else {
|
||||||
|
let model = ActiveModel {
|
||||||
|
key: ActiveValue::Set(key.to_owned()),
|
||||||
|
scope: ActiveValue::Set(scope.to_owned()),
|
||||||
|
value: ActiveValue::Set(value_json),
|
||||||
|
updated_at: ActiveValue::Set(now),
|
||||||
|
updated_by: ActiveValue::Set(user_id),
|
||||||
|
};
|
||||||
|
model.insert(dbconn()).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_async(key: &str) -> Result<(), AdminError> {
|
||||||
|
Entity::delete_by_id(key).exec(dbconn()).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_scope_async(scope: &str) -> Result<Vec<(String, String)>, AdminError> {
|
||||||
|
let rows = Entity::find()
|
||||||
|
.filter(Column::Scope.eq(scope))
|
||||||
|
.all(dbconn())
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(|m| (m.key, m.value)).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< Tipos de esquema >****************************************************************************
|
||||||
|
|
||||||
|
/// Tipo de campo de configuración para un [`SettingsSchema`].
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum SettingFieldType {
|
||||||
|
/// Texto libre con longitud máxima opcional.
|
||||||
|
Text { max_length: Option<usize> },
|
||||||
|
/// Número (entero o decimal).
|
||||||
|
Number { min: Option<f64>, max: Option<f64> },
|
||||||
|
/// Casilla de verificación (booleano).
|
||||||
|
Boolean,
|
||||||
|
/// Lista de opciones `(valor, etiqueta)`.
|
||||||
|
Select { options: Vec<(String, String)> },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Definición de un campo dentro de un [`SettingsSchema`].
|
||||||
|
#[derive(Clone, Debug, Getters)]
|
||||||
|
pub struct SettingField {
|
||||||
|
/// Devuelve el nombre del campo (clave dentro del scope, p. ej. `"site_name"`).
|
||||||
|
name: String,
|
||||||
|
/// Devuelve la etiqueta visible en el formulario.
|
||||||
|
label: String,
|
||||||
|
/// Devuelve el tipo de campo.
|
||||||
|
field_type: SettingFieldType,
|
||||||
|
/// Devuelve si el campo es obligatorio.
|
||||||
|
required: bool,
|
||||||
|
/// Devuelve el texto de ayuda opcional bajo el campo.
|
||||||
|
help_text: Option<String>,
|
||||||
|
/// Devuelve el valor por defecto como cadena JSON.
|
||||||
|
default_value: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SettingField {
|
||||||
|
/// Crea un campo de texto con nombre y etiqueta.
|
||||||
|
pub fn text(name: impl Into<String>, label: impl Into<String>) -> Self {
|
||||||
|
SettingField {
|
||||||
|
name: name.into(),
|
||||||
|
label: label.into(),
|
||||||
|
field_type: SettingFieldType::Text { max_length: None },
|
||||||
|
required: false,
|
||||||
|
help_text: None,
|
||||||
|
default_value: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crea un campo numérico con nombre y etiqueta.
|
||||||
|
pub fn number(name: impl Into<String>, label: impl Into<String>) -> Self {
|
||||||
|
SettingField {
|
||||||
|
name: name.into(),
|
||||||
|
label: label.into(),
|
||||||
|
field_type: SettingFieldType::Number {
|
||||||
|
min: None,
|
||||||
|
max: None,
|
||||||
|
},
|
||||||
|
required: false,
|
||||||
|
help_text: None,
|
||||||
|
default_value: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crea un campo booleano con nombre y etiqueta.
|
||||||
|
pub fn boolean(name: impl Into<String>, label: impl Into<String>) -> Self {
|
||||||
|
SettingField {
|
||||||
|
name: name.into(),
|
||||||
|
label: label.into(),
|
||||||
|
field_type: SettingFieldType::Boolean,
|
||||||
|
required: false,
|
||||||
|
help_text: None,
|
||||||
|
default_value: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crea un campo de selección con nombre, etiqueta y opciones.
|
||||||
|
pub fn select(
|
||||||
|
name: impl Into<String>,
|
||||||
|
label: impl Into<String>,
|
||||||
|
options: Vec<(String, String)>,
|
||||||
|
) -> Self {
|
||||||
|
SettingField {
|
||||||
|
name: name.into(),
|
||||||
|
label: label.into(),
|
||||||
|
field_type: SettingFieldType::Select { options },
|
||||||
|
required: false,
|
||||||
|
help_text: None,
|
||||||
|
default_value: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Establece si el campo es obligatorio.
|
||||||
|
#[builder_fn]
|
||||||
|
pub fn with_required(mut self, required: bool) -> Self {
|
||||||
|
self.required = required;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Añade texto de ayuda bajo el campo.
|
||||||
|
#[builder_fn]
|
||||||
|
pub fn with_help(mut self, text: impl Into<String>) -> Self {
|
||||||
|
self.help_text = Some(text.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Establece el valor por defecto (como valor JSON serializado).
|
||||||
|
#[builder_fn]
|
||||||
|
pub fn with_default<T: Serialize>(mut self, value: &T) -> Self {
|
||||||
|
self.default_value = serde_json::to_string(value).ok();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Esquema de formulario de configuración: describe un grupo de ajustes en un scope.
|
||||||
|
#[derive(Clone, Debug, Getters)]
|
||||||
|
pub struct SettingsSchema {
|
||||||
|
/// Devuelve el identificador del grupo (prefijo de las claves en `settings`).
|
||||||
|
scope: String,
|
||||||
|
/// Devuelve los campos del formulario en orden de presentación.
|
||||||
|
fields: Vec<SettingField>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SettingsSchema {
|
||||||
|
/// Crea un nuevo esquema vacío para el `scope` dado.
|
||||||
|
pub fn new(scope: impl Into<String>) -> Self {
|
||||||
|
SettingsSchema {
|
||||||
|
scope: scope.into(),
|
||||||
|
fields: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Añade un campo al esquema.
|
||||||
|
#[builder_fn]
|
||||||
|
pub fn with_field(mut self, field: SettingField) -> Self {
|
||||||
|
self.fields.push(field);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Devuelve la clave completa de un campo: `"{scope}.{field_name}"`.
|
||||||
|
pub fn key_for(&self, field_name: &str) -> String {
|
||||||
|
format!("{}.{}", self.scope, field_name)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue