✨ (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
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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue