(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:
Manuel Cillero 2026-08-28 20:11:44 +02:00
parent c0a5a8c3ab
commit 88acc08b5f
18 changed files with 1847 additions and 0 deletions

View 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)) }
}
}
}
}
}

View 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)
}
}

View 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
}
}