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

Entradas más populares de este blog

PROMPT MAESTRO — PLATAFORMA EDUCATIVA PHP + MYSQL

bd-2

La Estructura que Simula una Base de Datos en PHP