feat(custom-css-manager): implementar TIPO 3 - CSS Crítico Personalizado
Nuevo sistema de gestión de CSS personalizado con panel admin: - Admin/CustomCSSManager: CRUD de snippets CSS (crítico/diferido) - Public/CustomCSSManager: Inyección dinámica en frontend - Schema JSON para configuración del componente Migración de CSS estático a BD: - Tablas APU (~14KB) → snippet diferido en BD - Tablas Genéricas (~10KB) → snippet diferido en BD - Comentadas funciones legacy en enqueue-scripts.php Limpieza de archivos obsoletos: - Eliminado build-bootstrap-subset.js - Eliminado migrate-legacy-options.php - Eliminado minify-css.php - Eliminado purgecss.config.js Beneficios: - CSS editable desde admin sin tocar código - Soporte crítico (head) y diferido (footer) - Filtrado por scope (all/home/single/archive) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,462 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Admin\CustomCSSManager\Infrastructure\Ui;
|
||||
|
||||
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
|
||||
use ROITheme\Admin\CustomCSSManager\Infrastructure\Persistence\WordPressSnippetRepository;
|
||||
use ROITheme\Admin\CustomCSSManager\Application\UseCases\SaveSnippetUseCase;
|
||||
use ROITheme\Admin\CustomCSSManager\Application\UseCases\DeleteSnippetUseCase;
|
||||
use ROITheme\Admin\CustomCSSManager\Application\UseCases\GetAllSnippetsUseCase;
|
||||
use ROITheme\Admin\CustomCSSManager\Application\DTOs\SaveSnippetRequest;
|
||||
use ROITheme\Admin\CustomCSSManager\Domain\ValueObjects\SnippetId;
|
||||
use ROITheme\Shared\Domain\Exceptions\ValidationException;
|
||||
|
||||
/**
|
||||
* FormBuilder para gestión de CSS snippets en Admin Panel
|
||||
*
|
||||
* Sigue el patrón estándar de FormBuilders del tema:
|
||||
* - Constructor recibe AdminDashboardRenderer
|
||||
* - Método buildForm() genera el HTML del formulario
|
||||
*
|
||||
* Design System: Gradiente navy #0E2337 → #1e3a5f, accent #FF8600
|
||||
*/
|
||||
final class CustomCSSManagerFormBuilder
|
||||
{
|
||||
private const COMPONENT_ID = 'custom-css-manager';
|
||||
private const NONCE_ACTION = 'roi_custom_css_manager';
|
||||
|
||||
private WordPressSnippetRepository $repository;
|
||||
private GetAllSnippetsUseCase $getAllUseCase;
|
||||
private SaveSnippetUseCase $saveUseCase;
|
||||
private DeleteSnippetUseCase $deleteUseCase;
|
||||
|
||||
public function __construct(
|
||||
private readonly AdminDashboardRenderer $renderer
|
||||
) {
|
||||
// Crear repositorio y Use Cases internamente
|
||||
global $wpdb;
|
||||
$this->repository = new WordPressSnippetRepository($wpdb);
|
||||
$this->getAllUseCase = new GetAllSnippetsUseCase($this->repository);
|
||||
$this->saveUseCase = new SaveSnippetUseCase($this->repository);
|
||||
$this->deleteUseCase = new DeleteSnippetUseCase($this->repository);
|
||||
|
||||
// Registrar handler de formulario POST
|
||||
$this->registerFormHandler();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registra handler para procesar formularios POST
|
||||
*/
|
||||
private function registerFormHandler(): void
|
||||
{
|
||||
// Solo registrar una vez
|
||||
static $registered = false;
|
||||
if ($registered) {
|
||||
return;
|
||||
}
|
||||
$registered = true;
|
||||
|
||||
add_action('admin_init', function() {
|
||||
$this->handleFormSubmission();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa envío de formulario
|
||||
*/
|
||||
public function handleFormSubmission(): void
|
||||
{
|
||||
if (!isset($_POST['roi_css_action'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Verificar nonce
|
||||
if (!wp_verify_nonce($_POST['_wpnonce'] ?? '', self::NONCE_ACTION)) {
|
||||
wp_die('Nonce verification failed');
|
||||
}
|
||||
|
||||
// Verificar permisos
|
||||
if (!current_user_can('manage_options')) {
|
||||
wp_die('Insufficient permissions');
|
||||
}
|
||||
|
||||
$action = sanitize_text_field($_POST['roi_css_action']);
|
||||
|
||||
try {
|
||||
match ($action) {
|
||||
'save' => $this->processSave($_POST),
|
||||
'delete' => $this->processDelete($_POST),
|
||||
default => null,
|
||||
};
|
||||
|
||||
// Redirect con mensaje de éxito
|
||||
wp_redirect(add_query_arg('roi_message', 'success', wp_get_referer()));
|
||||
exit;
|
||||
|
||||
} catch (ValidationException $e) {
|
||||
// Redirect con mensaje de error
|
||||
wp_redirect(add_query_arg([
|
||||
'roi_message' => 'error',
|
||||
'roi_error' => urlencode($e->getMessage())
|
||||
], wp_get_referer()));
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa guardado de snippet
|
||||
*/
|
||||
private function processSave(array $data): void
|
||||
{
|
||||
$id = sanitize_text_field($data['snippet_id'] ?? '');
|
||||
|
||||
// Generar ID si es nuevo
|
||||
if (empty($id)) {
|
||||
$id = SnippetId::generate()->value();
|
||||
}
|
||||
|
||||
$request = SaveSnippetRequest::fromArray([
|
||||
'id' => $id,
|
||||
'name' => sanitize_text_field($data['snippet_name'] ?? ''),
|
||||
'description' => sanitize_textarea_field($data['snippet_description'] ?? ''),
|
||||
'css' => wp_strip_all_tags($data['snippet_css'] ?? ''),
|
||||
'type' => sanitize_text_field($data['snippet_type'] ?? 'deferred'),
|
||||
'pages' => array_map('sanitize_text_field', $data['snippet_pages'] ?? ['all']),
|
||||
'enabled' => isset($data['snippet_enabled']),
|
||||
'order' => absint($data['snippet_order'] ?? 100),
|
||||
]);
|
||||
|
||||
$this->saveUseCase->execute($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa eliminación de snippet
|
||||
*/
|
||||
private function processDelete(array $data): void
|
||||
{
|
||||
$id = sanitize_text_field($data['snippet_id'] ?? '');
|
||||
|
||||
if (empty($id)) {
|
||||
throw new ValidationException('ID de snippet requerido para eliminar');
|
||||
}
|
||||
|
||||
$this->deleteUseCase->execute($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye el formulario del componente
|
||||
*
|
||||
* @param string $componentId ID del componente (custom-css-manager)
|
||||
* @return string HTML del formulario
|
||||
*/
|
||||
public function buildForm(string $componentId): string
|
||||
{
|
||||
$snippets = $this->getAllUseCase->execute();
|
||||
$message = $this->getFlashMessage();
|
||||
|
||||
$html = '';
|
||||
|
||||
// Header
|
||||
$html .= $this->buildHeader($componentId, count($snippets));
|
||||
|
||||
// Mensajes flash
|
||||
if ($message) {
|
||||
$html .= sprintf(
|
||||
'<div class="alert alert-%s m-3">%s</div>',
|
||||
esc_attr($message['type']),
|
||||
esc_html($message['text'])
|
||||
);
|
||||
}
|
||||
|
||||
// Lista de snippets existentes
|
||||
$html .= $this->buildSnippetsList($snippets);
|
||||
|
||||
// Formulario de creación/edición
|
||||
$html .= $this->buildSnippetForm();
|
||||
|
||||
// JavaScript
|
||||
$html .= $this->buildJavaScript();
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye el header del componente
|
||||
*/
|
||||
private function buildHeader(string $componentId, int $snippetCount): string
|
||||
{
|
||||
$html = '<div class="card shadow-sm mb-4" style="border-left: 4px solid #FF8600;">';
|
||||
$html .= ' <div class="card-header d-flex justify-content-between align-items-center" style="background: linear-gradient(135deg, #0E2337 0%, #1e3a5f 100%);">';
|
||||
$html .= ' <h3 class="mb-0 text-white"><i class="bi bi-file-earmark-code me-2"></i>Gestor de CSS Personalizado</h3>';
|
||||
$html .= ' <span class="badge bg-light text-dark">' . esc_html($snippetCount) . ' snippet(s)</span>';
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="card-body">';
|
||||
$html .= ' <p class="text-muted mb-0">Gestiona snippets de CSS personalizados. Los snippets críticos se cargan en el head, los diferidos en el footer.</p>';
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye la lista de snippets existentes
|
||||
*/
|
||||
private function buildSnippetsList(array $snippets): string
|
||||
{
|
||||
$html = '<div class="card shadow-sm mb-4" style="border-left: 4px solid #FF8600;">';
|
||||
$html .= ' <div class="card-body">';
|
||||
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
|
||||
$html .= ' <i class="bi bi-list-ul me-2" style="color: #FF8600;"></i>';
|
||||
$html .= ' Snippets Configurados';
|
||||
$html .= ' </h5>';
|
||||
|
||||
if (empty($snippets)) {
|
||||
$html .= ' <p class="text-muted">No hay snippets configurados.</p>';
|
||||
} else {
|
||||
$html .= ' <div class="table-responsive">';
|
||||
$html .= ' <table class="table table-hover">';
|
||||
$html .= ' <thead>';
|
||||
$html .= ' <tr>';
|
||||
$html .= ' <th>Nombre</th>';
|
||||
$html .= ' <th>Tipo</th>';
|
||||
$html .= ' <th>Páginas</th>';
|
||||
$html .= ' <th>Estado</th>';
|
||||
$html .= ' <th>Acciones</th>';
|
||||
$html .= ' </tr>';
|
||||
$html .= ' </thead>';
|
||||
$html .= ' <tbody>';
|
||||
foreach ($snippets as $snippet) {
|
||||
$html .= $this->renderSnippetRow($snippet);
|
||||
}
|
||||
$html .= ' </tbody>';
|
||||
$html .= ' </table>';
|
||||
$html .= ' </div>';
|
||||
}
|
||||
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderiza una fila de snippet en la tabla
|
||||
*/
|
||||
private function renderSnippetRow(array $snippet): string
|
||||
{
|
||||
$id = esc_attr($snippet['id']);
|
||||
$name = esc_html($snippet['name']);
|
||||
$type = $snippet['type'] === 'critical' ? 'Crítico' : 'Diferido';
|
||||
$typeBadge = $snippet['type'] === 'critical' ? 'bg-danger' : 'bg-info';
|
||||
$pages = implode(', ', $snippet['pages'] ?? ['all']);
|
||||
$enabled = ($snippet['enabled'] ?? false) ? 'Activo' : 'Inactivo';
|
||||
$enabledBadge = ($snippet['enabled'] ?? false) ? 'bg-success' : 'bg-secondary';
|
||||
|
||||
// Usar data-attribute para JSON seguro
|
||||
$snippetJson = esc_attr(wp_json_encode($snippet, JSON_HEX_APOS | JSON_HEX_QUOT));
|
||||
$nonce = wp_create_nonce(self::NONCE_ACTION);
|
||||
|
||||
return <<<HTML
|
||||
<tr>
|
||||
<td><strong>{$name}</strong></td>
|
||||
<td><span class="badge {$typeBadge}">{$type}</span></td>
|
||||
<td><small>{$pages}</small></td>
|
||||
<td><span class="badge {$enabledBadge}">{$enabled}</span></td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary btn-edit-snippet"
|
||||
data-snippet="{$snippetJson}">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<form method="post" class="d-inline"
|
||||
onsubmit="return confirm('¿Eliminar este snippet?');">
|
||||
<input type="hidden" name="_wpnonce" value="{$nonce}">
|
||||
<input type="hidden" name="roi_css_action" value="delete">
|
||||
<input type="hidden" name="snippet_id" value="{$id}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
HTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye el formulario de creación/edición de snippets
|
||||
*/
|
||||
private function buildSnippetForm(): string
|
||||
{
|
||||
$nonce = wp_create_nonce(self::NONCE_ACTION);
|
||||
|
||||
$html = '<div class="card shadow-sm mb-4" style="border-left: 4px solid #FF8600;">';
|
||||
$html .= ' <div class="card-body">';
|
||||
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
|
||||
$html .= ' <i class="bi bi-plus-circle me-2" style="color: #FF8600;"></i>';
|
||||
$html .= ' Agregar/Editar Snippet';
|
||||
$html .= ' </h5>';
|
||||
|
||||
$html .= ' <form method="post" id="roi-snippet-form">';
|
||||
$html .= ' <input type="hidden" name="_wpnonce" value="' . esc_attr($nonce) . '">';
|
||||
$html .= ' <input type="hidden" name="roi_css_action" value="save">';
|
||||
$html .= ' <input type="hidden" name="snippet_id" id="snippet_id" value="">';
|
||||
|
||||
$html .= ' <div class="row g-3">';
|
||||
|
||||
// Nombre
|
||||
$html .= ' <div class="col-md-6">';
|
||||
$html .= ' <label class="form-label">Nombre *</label>';
|
||||
$html .= ' <input type="text" name="snippet_name" id="snippet_name" class="form-control" required maxlength="100" placeholder="Ej: Estilos Tablas APU">';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Tipo
|
||||
$html .= ' <div class="col-md-3">';
|
||||
$html .= ' <label class="form-label">Tipo *</label>';
|
||||
$html .= ' <select name="snippet_type" id="snippet_type" class="form-select">';
|
||||
$html .= ' <option value="critical">Crítico (head)</option>';
|
||||
$html .= ' <option value="deferred" selected>Diferido (footer)</option>';
|
||||
$html .= ' </select>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Orden
|
||||
$html .= ' <div class="col-md-3">';
|
||||
$html .= ' <label class="form-label">Orden</label>';
|
||||
$html .= ' <input type="number" name="snippet_order" id="snippet_order" class="form-control" value="100" min="1" max="999">';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Descripción
|
||||
$html .= ' <div class="col-12">';
|
||||
$html .= ' <label class="form-label">Descripción</label>';
|
||||
$html .= ' <input type="text" name="snippet_description" id="snippet_description" class="form-control" maxlength="255" placeholder="Descripción breve del propósito del CSS">';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Páginas
|
||||
$html .= ' <div class="col-md-6">';
|
||||
$html .= ' <label class="form-label">Aplicar en páginas</label>';
|
||||
$html .= ' <div class="d-flex flex-wrap gap-2">';
|
||||
foreach ($this->getPageOptions() as $value => $label) {
|
||||
$checked = $value === 'all' ? 'checked' : '';
|
||||
$html .= sprintf(
|
||||
'<div class="form-check"><input type="checkbox" name="snippet_pages[]" value="%s" id="page_%s" class="form-check-input" %s><label class="form-check-label" for="page_%s">%s</label></div>',
|
||||
esc_attr($value),
|
||||
esc_attr($value),
|
||||
$checked,
|
||||
esc_attr($value),
|
||||
esc_html($label)
|
||||
);
|
||||
}
|
||||
$html .= ' </div>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Estado
|
||||
$html .= ' <div class="col-md-6">';
|
||||
$html .= ' <label class="form-label">Estado</label>';
|
||||
$html .= ' <div class="form-check form-switch">';
|
||||
$html .= ' <input type="checkbox" name="snippet_enabled" id="snippet_enabled" class="form-check-input" checked>';
|
||||
$html .= ' <label class="form-check-label" for="snippet_enabled">Habilitado</label>';
|
||||
$html .= ' </div>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Código CSS
|
||||
$html .= ' <div class="col-12">';
|
||||
$html .= ' <label class="form-label">Código CSS *</label>';
|
||||
$html .= ' <textarea name="snippet_css" id="snippet_css" class="form-control font-monospace" rows="10" required placeholder="/* Tu CSS aquí */"></textarea>';
|
||||
$html .= ' <small class="text-muted">Crítico: máx 14KB | Diferido: máx 100KB</small>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Botones
|
||||
$html .= ' <div class="col-12">';
|
||||
$html .= ' <button type="submit" class="btn text-white" style="background-color: #FF8600;">';
|
||||
$html .= ' <i class="bi bi-save me-1"></i> Guardar Snippet';
|
||||
$html .= ' </button>';
|
||||
$html .= ' <button type="button" class="btn btn-secondary" onclick="resetCssForm()">';
|
||||
$html .= ' <i class="bi bi-x-circle me-1"></i> Cancelar';
|
||||
$html .= ' </button>';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
$html .= ' </form>';
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera el JavaScript necesario para el formulario
|
||||
*/
|
||||
private function buildJavaScript(): string
|
||||
{
|
||||
return <<<JS
|
||||
<script>
|
||||
// Event delegation para botones de edición
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.btn-edit-snippet').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
const snippet = JSON.parse(this.dataset.snippet);
|
||||
editCssSnippet(snippet);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function editCssSnippet(snippet) {
|
||||
document.getElementById('snippet_id').value = snippet.id;
|
||||
document.getElementById('snippet_name').value = snippet.name;
|
||||
document.getElementById('snippet_description').value = snippet.description || '';
|
||||
document.getElementById('snippet_type').value = snippet.type;
|
||||
document.getElementById('snippet_order').value = snippet.order || 100;
|
||||
document.getElementById('snippet_css').value = snippet.css;
|
||||
document.getElementById('snippet_enabled').checked = snippet.enabled;
|
||||
|
||||
// Actualizar checkboxes de páginas
|
||||
document.querySelectorAll('input[name="snippet_pages[]"]').forEach(cb => {
|
||||
cb.checked = (snippet.pages || ['all']).includes(cb.value);
|
||||
});
|
||||
|
||||
document.getElementById('snippet_name').focus();
|
||||
|
||||
// Scroll al formulario
|
||||
document.getElementById('roi-snippet-form').scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function resetCssForm() {
|
||||
document.getElementById('roi-snippet-form').reset();
|
||||
document.getElementById('snippet_id').value = '';
|
||||
}
|
||||
</script>
|
||||
JS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opciones de páginas disponibles
|
||||
*/
|
||||
private function getPageOptions(): array
|
||||
{
|
||||
return [
|
||||
'all' => 'Todas',
|
||||
'home' => 'Inicio',
|
||||
'posts' => 'Posts',
|
||||
'pages' => 'Páginas',
|
||||
'archives' => 'Archivos',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene mensaje flash de la URL
|
||||
*/
|
||||
private function getFlashMessage(): ?array
|
||||
{
|
||||
$message = $_GET['roi_message'] ?? null;
|
||||
|
||||
if ($message === 'success') {
|
||||
return ['type' => 'success', 'text' => 'Snippet guardado correctamente'];
|
||||
}
|
||||
|
||||
if ($message === 'error') {
|
||||
$error = urldecode($_GET['roi_error'] ?? 'Error desconocido');
|
||||
return ['type' => 'danger', 'text' => $error];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user