paginas
Página administrativa
admin/index.php
Esta será nuestra primera pantalla del CMS.
<?php require_once '../config/database.php'; $sql = " SELECT e.id, e.titulo, e.subtitulo, e.estado, e.fecha_creacion, t.nombre AS tema, s.nombre AS subtema FROM entradas e INNER JOIN temas t ON t.id = e.tema_id LEFT JOIN subtemas s ON s.id = e.subtema_id ORDER BY e.id DESC "; $stmt = $pdo->query($sql); $entradas = $stmt->fetchAll(); ?> <!DOCTYPE html> <html lang="es"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>CMS Educativo</title> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> </head> <body> <div class="container mt-5"> <h1>CMS Educativo</h1> <p> Administración de entradas </p> <a href="nueva-entrada.php" class="btn btn-primary mb-4"> Nueva entrada </a> <table class="table table-bordered"> <thead> <tr> <th>ID</th> <th>Título</th> <th>Tema</th> <th>Subtema</th> <th>Estado</th> <th>Fecha</th> <th>Acciones</th> </tr> </thead> <tbody> <?php foreach ($entradas as $entrada): ?> <tr> <td> <?= $entrada['id'] ?> </td> <td> <?= htmlspecialchars( $entrada['titulo'], ENT_QUOTES, 'UTF-8' ) ?> </td> <td> <?= htmlspecialchars( $entrada['tema'], ENT_QUOTES, 'UTF-8' ) ?> </td> <td> <?= htmlspecialchars( $entrada['subtema'] ?? '', ENT_QUOTES, 'UTF-8' ) ?> </td> <td> <?= htmlspecialchars( $entrada['estado'], ENT_QUOTES, 'UTF-8' ) ?> </td> <td> <?= $entrada['fecha_creacion'] ?> </td> <td> <a href="../entrada.php?id=<?= $entrada['id'] ?>" class="btn btn-sm btn-secondary"> Ver </a> </td> </tr> <?php endforeach; ?> </tbody> </table> </div> </body> </html>
7. Crear una entrada
admin/nueva-entrada.php
Aquí empieza a tomar forma el CMS.
<?php require_once '../config/database.php'; /* |-------------------------------------------------------------------------- | Obtener temas |-------------------------------------------------------------------------- */ $stmt = $pdo->query(" SELECT id, nombre FROM temas WHERE activo = 1 ORDER BY nombre "); $temas = $stmt->fetchAll(); /* |-------------------------------------------------------------------------- | Obtener subtemas |-------------------------------------------------------------------------- */ $stmt = $pdo->query(" SELECT id, tema_id, nombre FROM subtemas WHERE activo = 1 ORDER BY nombre "); $subtemas = $stmt->fetchAll(); ?> <!DOCTYPE html> <html lang="es"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Nueva entrada</title> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/trumbowyg@2.27.3/dist/ui/trumbowyg.min.css"> </head> <body> <div class="container mt-5"> <h1>Nueva entrada</h1> <form method="POST" action="guardar-entrada.php"> <!-- Tema --> <div class="mb-3"> <label class="form-label"> Tema </label> <select name="tema_id" id="tema_id" class="form-select" required> <option value=""> Seleccionar tema </option> <?php foreach ($temas as $tema): ?> <option value="<?= $tema['id'] ?>"> <?= htmlspecialchars( $tema['nombre'], ENT_QUOTES, 'UTF-8' ) ?> </option> <?php endforeach; ?> </select> </div> <!-- Subtema --> <div class="mb-3"> <label class="form-label"> Subtema </label> <select name="subtema_id" id="subtema_id" class="form-select"> <option value=""> Sin subtema </option> <?php foreach ($subtemas as $subtema): ?> <option value="<?= $subtema['id'] ?>" data-tema="<?= $subtema['tema_id'] ?>"> <?= htmlspecialchars( $subtema['nombre'], ENT_QUOTES, 'UTF-8' ) ?> </option> <?php endforeach; ?> </select> </div> <!-- Título --> <div class="mb-3"> <label class="form-label"> Título </label> <input type="text" name="titulo" class="form-control" required> </div> <!-- Subtítulo --> <div class="mb-3"> <label class="form-label"> Subtítulo </label> <input type="text" name="subtitulo" class="form-control"> </div> <!-- Contenido --> <div class="mb-3"> <label class="form-label"> Contenido </label> <textarea id="editor" name="contenido" class="form-control" rows="15" required></textarea> </div> <!-- Estado --> <div class="mb-3"> <label class="form-label"> Estado </label> <select name="estado" class="form-select"> <option value="borrador"> Borrador </option> <option value="publicado"> Publicado </option> </select> </div> <button type="submit" class="btn btn-primary"> Guardar entrada </button> <a href="index.php" class="btn btn-secondary"> Cancelar </a> </form> </div> <script src="https://code.jquery.com/jquery-3.7.1.min.js"> </script> <script src="https://cdn.jsdelivr.net/npm/trumbowyg@2.27.3/dist/trumbowyg.min.js"> </script> <script src="https://cdn.jsdelivr.net/npm/trumbowyg@2.27.3/dist/langs/es.min.js"> </script> <script> $(document).ready(function() { $('#editor').trumbowyg({ lang: 'es', btns: [ ['viewHTML'], ['formatting'], ['bold', 'italic', 'underline'], ['superscript', 'subscript'], ['link'], ['insertImage'], ['unorderedList', 'orderedList'], [ 'justifyLeft', 'justifyCenter', 'justifyRight', 'justifyFull' ], ['horizontalRule'], ['removeformat'], ['fullscreen'] ] }); /* |-------------------------------------------------------------------------- | Filtrar subtemas |-------------------------------------------------------------------------- */ $('#tema_id').on('change', function() { const temaId = $(this).val(); $('#subtema_id option').each(function() { const subtemaTema = $(this).data('tema'); if ( !subtemaTema || subtemaTema == temaId ) { $(this).show(); } else { $(this).hide(); } }); $('#subtema_id').val(''); }); }); </script> </body> </html>
8. Guardar entrada
admin/guardar-entrada.php
<?php require_once '../config/database.php'; if ($_SERVER['REQUEST_METHOD'] !== 'POST') { header('Location: index.php'); exit; } /* |-------------------------------------------------------------------------- | Obtener datos |-------------------------------------------------------------------------- */ $tema_id = filter_input( INPUT_POST, 'tema_id', FILTER_VALIDATE_INT ); $subtema_id = filter_input( INPUT_POST, 'subtema_id', FILTER_VALIDATE_INT ); $titulo = trim( $_POST['titulo'] ?? '' ); $subtitulo = trim( $_POST['subtitulo'] ?? '' ); $contenido = $_POST['contenido'] ?? ''; $estado = $_POST['estado'] ?? 'borrador'; /* |-------------------------------------------------------------------------- | Validaciones |-------------------------------------------------------------------------- */ if (!$tema_id) { die('Debe seleccionar un tema.'); } if ($titulo === '') { die('El título es obligatorio.'); } if ($contenido === '') { die('El contenido es obligatorio.'); } $estadosPermitidos = [ 'borrador', 'publicado' ]; if (!in_array( $estado, $estadosPermitidos, true )) { $estado = 'borrador'; } /* |-------------------------------------------------------------------------- | Generar slug |-------------------------------------------------------------------------- */ function generarSlug($texto) { $texto = mb_strtolower( trim($texto), 'UTF-8' ); $texto = iconv( 'UTF-8', 'ASCII//TRANSLIT', $texto ); $texto = preg_replace( '/[^a-z0-9]+/', '-', $texto ); $texto = trim( $texto, '-' ); return $texto; } $slug = generarSlug($titulo); /* |-------------------------------------------------------------------------- | Evitar slug duplicado |-------------------------------------------------------------------------- */ $slugBase = $slug; $contador = 1; while (true) { $stmt = $pdo->prepare(" SELECT COUNT(*) FROM entradas WHERE slug = ? "); $stmt->execute([$slug]); $existe = $stmt->fetchColumn(); if (!$existe) { break; } $contador++; $slug = $slugBase . '-' . $contador; } /* |-------------------------------------------------------------------------- | Guardar |-------------------------------------------------------------------------- */ $stmt = $pdo->prepare(" INSERT INTO entradas ( tema_id, subtema_id, titulo, subtitulo, slug, contenido, estado, fecha_publicacion ) VALUES ( :tema_id, :subtema_id, :titulo, :subtitulo, :slug, :contenido, :estado, :fecha_publicacion ) "); $fechaPublicacion = null; if ($estado === 'publicado') { $fechaPublicacion = date( 'Y-m-d H:i:s' ); } $stmt->execute([ ':tema_id' => $tema_id, ':subtema_id' => $subtema_id ?: null, ':titulo' => $titulo, ':subtitulo' => $subtitulo ?: null, ':slug' => $slug, ':contenido' => $contenido, ':estado' => $estado, ':fecha_publicacion' => $fechaPublicacion ]); $id = $pdo->lastInsertId(); header( 'Location: ../entrada.php?id=' . $id ); exit;
9. Ver la entrada
entrada.php
Aquí es donde podremos comprobar que Trumbowyg realmente está guardando HTML en MySQL y posteriormente lo estamos mostrando.
<?php require_once 'config/database.php'; $id = filter_input( INPUT_GET, 'id', FILTER_VALIDATE_INT ); if (!$id) { die('Entrada no válida.'); } $stmt = $pdo->prepare(" SELECT e.*, t.nombre AS tema, s.nombre AS subtema FROM entradas e INNER JOIN temas t ON t.id = e.tema_id LEFT JOIN subtemas s ON s.id = e.subtema_id WHERE e.id = ? LIMIT 1 "); $stmt->execute([$id]); $entrada = $stmt->fetch(); if (!$entrada) { die('Entrada no encontrada.'); } ?> <!DOCTYPE html> <html lang="es"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title> <?= htmlspecialchars( $entrada['titulo'], ENT_QUOTES, 'UTF-8' ) ?> </title> </head> <body> <main> <p> <?= htmlspecialchars( $entrada['tema'], ENT_QUOTES, 'UTF-8' ) ?> <?php if ($entrada['subtema']): ?> / <?= htmlspecialchars( $entrada['subtema'], ENT_QUOTES, 'UTF-8' ) ?> <?php endif; ?> </p> <h1> <?= htmlspecialchars( $entrada['titulo'], ENT_QUOTES, 'UTF-8' ) ?> </h1> <?php if ($entrada['subtitulo']): ?> <h2> <?= htmlspecialchars( $entrada['subtitulo'], ENT_QUOTES, 'UTF-8' ) ?> </h2> <?php endif; ?> <hr> <article> <?= $entrada['contenido'] ?> </article> </main> </body> </html>
Aquí hay una cuestión importante de seguridad
Observa que en:
<?= $entrada['contenido'] ?>
no usamos htmlspecialchars().
Esto es intencional porque Trumbowyg guarda HTML:
<p>Este es un párrafo.</p> <strong>Este texto está en negritas.</strong> <ul> <li>Primer punto</li> <li>Segundo punto</li> </ul>
Si aplicáramos htmlspecialchars(), el navegador mostraría las etiquetas en lugar de interpretarlas.
Pero en la versión final tendremos que sanitizar el HTML permitido, especialmente si otros usuarios además del administrador pueden introducir contenido.
10. ¿Qué acabamos de conseguir?
Con estos pocos archivos ya podemos probar:
MySQL ↓ temas ↓ subtemas ↓ entradas ↓ titulo subtitulo contenido HTML
Por ejemplo:
Tema
Matemáticas
Subtema
Fracciones
Entrada
Título:
¿Qué son las fracciones?
Subtítulo:
Aprende a identificar las partes de una fracción
Contenido:
Una fracción representa una parte de un todo...
Y Trumbowyg puede guardar:
<h2>¿Qué es una fracción?</h2> <p> Una fracción representa una parte de un todo. </p> <p> Una fracción tiene dos elementos: </p> <ul> <li>Numerador</li> <li>Denominador</li> </ul>
Todo eso se almacena en:
entradas.contenido
como LONGTEXT.
11. Algo que quiero cambiar respecto al proyecto original
Ahora que ya estamos aterrizando la base de datos, no agregaría todavía tablas para Memorama.
El Memorama puede funcionar así:
multimedia │ ├── imagen 1 ├── imagen 2 ├── imagen 3 ├── imagen 4 └── imagen 5 ↓ seleccionar 4 ↓ duplicar en memoria ↓ mezclar ↓ crear 8 cartas
No necesitamos guardar:
memorama_cartas memorama_parejas memorama_imagenes
para la primera versión.
Esto mantiene el sistema mucho más limpio.
12. Y tampoco necesitamos una tabla para Quiz Visual
El Quiz Visual reutilizará:
preguntas │ ├── tipo = visual │ └── multimedia_id ↓ imagen
Y:
preguntas ↓ respuestas
Por ejemplo:
Pregunta #15 tipo: visual pregunta: ¿Qué animal aparece en la imagen? multimedia_id: 82
Y las respuestas:
respuesta #50 Perro correcta respuesta #51 Gato incorrecta respuesta #52 Caballo incorrecta respuesta #53 León incorrecta
Con eso tenemos un Quiz Visual sin duplicar información.
13. La estructura final que estamos construyendo
Quedaría aproximadamente:
BLOG EDUCATIVO │ ┌─────────┴─────────┐ │ │ TEMAS USUARIOS │ SUBTEMAS │ ENTRADAS │ MULTIMEDIA │ ┌──────────┴──────────┐ │ │ IMÁGENES VIDEOS TEMAS │ ┌─────┴─────┐ │ │ PREGUNTAS FLASHCARDS │ RESPUESTAS │ EXÁMENES │ EXAMEN_PREGUNTAS MULTIMEDIA │ └──────→ MEMORAMA
Y posteriormente:
PROGRESO │ RESULTADOS │ IA │ APRENDIZAJE PERSONALIZADO
Yo empezaría exactamente por este CMS mínimo. Una vez que comprobemos que puedes crear tema → subtema → entrada y que Trumbowyg guarda correctamente el contenido, el siguiente paso natural sería agregar editar/eliminar entradas y el CRUD de temas/subtemas. Después incorporaríamos la biblioteca de multimedia para que las imágenes que subas puedan reutilizarse directamente en las entradas, Flashcards, Quiz Visual y Memorama.
Comentarios
Publicar un comentario