🎨 Flask + Bootstrap: estilos, imágenes y videos

🎨 Flask + Bootstrap: estilos, imágenes y videos

Ya sabes crear rutas, manejar datos con JSON, hacer un CRUD completo y dominar Jinja2 con herencia de plantillas. Ahora toca darle vida y estilo a tus aplicaciones.

En este post vas a aprender a:

  • ✅ Integrar Bootstrap en Flask para tener diseños profesionales sin escribir CSS desde cero.
  • ✅ Manejar archivos estáticos: imágenes, videos, CSS y JS.
  • ✅ Crear una galería de imágenes con cards y un bucle de Jinja2.
  • ✅ Mejorar el directorio telefónico con tablas y botones estilizados.
  • Solucionar errores comunes (404, rutas, caché, códigos HTTP).

Al terminar, tu app se verá así: profesional, responsive y con una galería dinámica.


🤔 ¿Por qué necesitamos Bootstrap si HTML ya funciona?

usando boostrap + jinja2Es una pregunta que todo principiante se hace. La respuesta es sencilla: HTML te da estructura, pero Bootstrap te da estilo profesional sin que tengas que escribir cientos de líneas de CSS.

Sin Bootstrap: un formulario genérico, sin estilo, que parece de los años 90.

Con Bootstrap: el mismo formulario, pero con diseño moderno, responsive y profesional.

La diferencia es abismal. Y lo mejor: sin escribir ni una línea de CSS. Bootstrap te da un diseño consistente en todos los navegadores y dispositivos.

⚠️ Importante: Bootstrap NO reemplaza a Flask

Flask es un framework de Python que genera las páginas web (el contenido, la lógica, los datos).

Bootstrap es un framework de CSS que cambia la apariencia de esas páginas (colores, fuentes, márgenes, responsive).

Son herramientas complementarias, no sustitutas. Juntos, son imparables.


🎨 ¿Qué es Bootstrap y por qué usarlo con Flask?

Bootstrap es el framework CSS más popular del mundo. Creado por Twitter, te permite construir interfaces web responsive, modernas y profesionales sin tener que escribir CSS desde cero. Es como tener una biblioteca de componentes visuales listos para usar.

En Flask, integrar Bootstrap es tan sencillo como añadir unas líneas en tu plantilla base. Ya lo hicimos en el post anterior, pero hoy vamos a profundizar en cómo sacarle el máximo partido.

📐 El sistema de grillas: la base de todo diseño Bootstrap

Bootstrap organiza el contenido en un sistema de 12 columnas. La idea es sencilla: envuelves tu contenido en un container, dentro pones una row (fila), y dentro de ella, col (columnas) que suman 12. Así puedes crear layouts de cualquier tamaño y forma.

Ejemplo básico en un template de Flask:

<div class="container">
    <div class="row">
        <div class="col-md-6">
            <p>Este bloque ocupa la mitad de la pantalla en escritorio.</p>
        </div>
        <div class="col-md-6">
            <p>Y esta la otra mitad. En móvil, se apilan verticalmente.</p>
        </div>
    </div>
</div>

💡 ¿Por qué 6? Porque 12 ÷ 6 = 2, así que entran dos columnas por fila. Más adelante verás col-md-3 (12 ÷ 3 = 4 columnas por fila). Algo así como dividir el espacio al 50%.

💡 ¿Por qué es útil en Flask? Porque puedes generar filas y columnas dinámicamente de una forma muy sencilla y que se vean limpias y ordenadas con un bucle de Jinja2, como veremos más adelante con la galería.

🧩 Componentes esenciales para Flask

Antes de ver la tabla, es importante que entiendas una cosa: Bootstrap funciona mediante clases CSS. No tienes que escribir estilos desde cero, sino que añades clases a tus elementos HTML y Bootstrap se encarga del resto. Estas clases se pueden personalizar con CSS personalizado, pero lo importante es que ya nos da la «estructura» base para trabajar sin estar calculando demasiados breakpoints para tener una aplicación responsive.

Cada clase CSS de Bootstrap tiene un propósito específico. Por ejemplo:

  • .table convierte una tabla HTML común en una tabla con estilo Bootstrap.
  • .btn transforma un botón genérico en un botón con aspecto profesional.
  • .navbar convierte un <nav> en una barra de navegación responsive.

Bootstrap elementos flask

La clave está en combinar clases. Por ejemplo, un botón puede tener class="btn btn-primary btn-lg" (se indica primero que es un botón con «btn» y luego la clase «btn-primary» que asigna el color y estilo básico + btn-lg que le indica el tamaño [large]) para ser un botón grande y azul. O una tabla puede llevar class="table table-striped table-hover" para tener filas alternadas y efecto hover.

Bootstrap tiene decenas de componentes, pero estos son los que más vas a usar en tus aplicaciones Flask:

Componente Clase Bootstrap Uso en Flask
Barra de navegación .navbar Menú principal con enlaces a rutas de Flask
Tablas .table, .table-striped Mostrar listados de datos (directorio telefónico)
Tarjetas (cards) .card Mostrar elementos con imagen, título y descripción
Botones .btn, .btn-primary Acciones en formularios y listados
Alertas .alert Mensajes flash (éxito, error, información)
Formularios .form-control Campos de entrada con estilo consistente

📌 Recuerda: La clave de Bootstrap está en aplicar las clases correctas a los elementos HTML. Si quieres profundizar, te recomiendo:

En los próximos posts veremos en detalle formularios y alertas. Hoy para este tutorial no enfocaremos en tablas, tarjetas y navbar.


🖼️ Archivos estáticos en Flask: imágenes, videos, CSS y JS

proyecto-flask-static-ejemploHasta ahora hemos trabajado con código y texto, pero una aplicación web de verdad necesita imágenes, videos, hojas de estilo y scripts. En Flask, todos estos archivos se guardan en la carpeta static/ (por convención).

Esta carpeta es especial: Flask la sirve automáticamente en la ruta /static/. Así, un archivo static/img/logo.png estará disponible en http://tudominio/static/img/logo.png.

Para que nunca olvides qué es static/, imagina que Flask es un restaurante:

  • templates/ es la cocina. Ahí se prepara la comida (el HTML dinámico).
  • static/ es el depósito. Ahí se guardan los ingredientes ya listos: imágenes, CSS, JavaScript, videos.

Cuando alguien pide un ingrediente, Flask simplemente lo entrega desde el depósito. No lo cocina, no lo modifica, solo lo sirve. Por eso se llaman archivos estáticos: no cambian.

📂 Estructura de carpetas ideal MVC – Flask + Bootstrap

mi_proyecto_flask/
├── controlador.py
├── models.py
├── templates/          ← La cocina (HTML dinámico)
│   ├── base.html
│   ├── header.html
│   ├── footer.html
│   ├── index.html
│   └── galeria.html
└── static/             ← El depósito (archivos estáticos)
    ├── css/
    │   └── style.css
    ├── img/
    │   ├── logo.png
    │   ├── python-logo.png
    │   └── flask-logo.png
    ├── video/
    │   └── presentacion.mp4
    └── js/
        └── script.js

💡 Organización: separar por tipo de archivo (css, img, video, js) facilita el mantenimiento. A medida que tu proyecto crezca, lo agradecerás.

🖼️ Insertar imágenes en templates con url_for

Para mostrar una imagen, usamos la etiqueta <img> y generamos la URL con url_for('static', filename='...'):

<img src="{{ url_for('static', filename='img/logo.png') }}" alt="Logo de mi app" style="height: 50px;">

💡 Ventaja: Si cambias la estructura de URLs, url_for se adapta automáticamente, de esta forma que te explico aunque movieras el proyecto de carpeta lo podrías ejecutar igualmente porque siempre buscará desde la raíz del proyecto -> static y dentro lo que coloques como file. Nunca escribas rutas fijas directamente como /static/img/logo.png y menos algo como C:/proyectos/mi_aplicacion_flask/img/loquesea.png.

🎬 Insertar videos con url_for

Con la etiqueta <video> y url_for puedes incrustar videos que estén en la carpeta static/video/. El código básico es:

<video controls width="100%">
    <source src="{{ url_for('static', filename='video/presentacion.mp4') }}" type="video/mp4">
    Tu navegador no soporta la reproducción de videos.
</video>

Puedes añadir el atributo autoplay para que el video comience automáticamente (y muted si quieres que no tenga sonido). En este ejemplo, lo usaremos con autoplay muted loop para que se reproduzca en bucle como una presentación.

💡 Nota: Este video es una presentación simple creada con Canva para darle vida a la aplicación y ejemplificar el uso de archivos estáticos. Se encuentra en static/video/presentacion.mp4.

🎨 CSS y JS personalizados con url_for

Ya vimos cómo enlazar CSS personalizado en el post anterior. Aquí un recordatorio:

<!-- En base.html, dentro del <head> -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">

<!-- Y al final del <body> -->
<script src="{{ url_for('static', filename='js/script.js') }}"></script>

💡 Orden importa: Si usas Bootstrap, carga tu CSS después de Bootstrap para sobrescribir estilos.

🌐 ¿Qué pasa cuando escribes url_for('static', ...)?

Entender esto es clave. Cuando el navegador ve url_for('static', filename='img/logo.png'), pasa esto:

El navegador pide el archivo. Flask recibe la petición, ve que es un archivo estático, busca dentro de la carpeta static/, y lo devuelve. No ejecuta código Python, no procesa lógica, solo entrega el archivo. Por eso es tan rápido.

Mini resumen: Hasta aquí ya sabes organizar recursos estáticos, cargarlos con url_for y entender por qué Flask los sirve así. Esto es la base para cualquier aplicación con imágenes o estilos.


👌 Mejorando el directorio telefónico con Bootstrap

Vamos a aplicar lo aprendido a nuestra aplicación del directorio telefónico. Si has seguido la serie, ya tienes un CRUD funcional con Jinja2. Ahora le vamos a dar un lavado de cara profesional.

Esto es lo que tenemos ahora. Funcional, pero… algo feo. 😅


nuevo-estilo-css-personalizado-bootstrap-jinja2

Así queda después de aplicar Bootstrap. ¿Notas la diferencia? Ahora parece una aplicación profesional.

Ejemplo aplicación Flask + Bootstrap

Venga, vamos a darle este estilo:

🔝 Navbar con logo

Vamos a modificar el header.html para que tenga un logo (imágen) más grande, un texto más descriptivo y un orden de menú que incluya la nueva sección de «Galería» antes de «Soporte«. El enlace a «Soporte» lo dejaremos con # (por ahora) porque aún no hemos creado esa página, pero ya está preparado para cuando la tengamos.

El logo lo agrandamos ligeramente para que sea más visible sin aumentar la altura total del header.

Actualizamos header.html con estos cambios:

<nav class="navbar navbar-expand-lg navbar-light bg-light">
    <a class="navbar-brand" href="{{ url_for('inicio') }}">
        <img src="{{ url_for('static', filename='img/logo.png') }}" alt="Logo" height="40" class="d-inline-block align-top">
        Directorio Online
    </a>
    <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav">
        <span class="navbar-toggler-icon"></span>
    </button>
    <div class="collapse navbar-collapse" id="navbarNav">
        <ul class="navbar-nav ml-auto">
            <li class="nav-item {% if request.endpoint == 'inicio' %}active{% endif %}">
                <a class="nav-link" href="{{ url_for('inicio') }}">Inicio</a>
            </li>
            <li class="nav-item {% if request.endpoint == 'agregar' %}active{% endif %}">
                <a class="nav-link" href="{{ url_for('agregar') }}">Agregar</a>
            </li>
            <li class="nav-item {% if request.endpoint == 'galeria' %}active{% endif %}">
                <a class="nav-link" href="{{ url_for('galeria') }}">Galería</a>
            </li>
            <li class="nav-item {% if request.endpoint == 'soporte' %}active{% endif %}">
                <a class="nav-link" href="#">Soporte</a>
            </li>
        </ul>
    </div>
</nav>

header-app-flask-bootstrap

Con estos cambios, el logo se ve más grande, el texto identifica mejor la aplicación y el menú ahora incluye «Galería» antes de «Soporte (futuro formulario)«, manteniendo la coherencia con los posts anteriores.

 

📊 Tabla con estilo

En index.html, mejoramos la tabla con clases de Bootstrap, nuestro index.html quedaría:

{% extends "base.html" %}

{% block title %}Inicio - Directorio{% endblock %}

{% block content %}

<h1 style="text-align:center;">Directorio Telefónico - Mi primera aplicación en Flask</h1>

<div class="row mt-4">
    <div class="col-md-6">
        <h2>Datos completos de la persona</h2>
        <table class="table table-bordered table-striped">
            <thead class="thead-dark">
                <tr>
                    <th>Nombre</th>
                    <th>Apellido</th>
                    <th>Apodo</th>
                    <th>Teléfono</th>
                    <th>Dirección</th>
                </tr>
            </thead>
            <tbody>
                {% for (index, persona) in Todos.items() %}
                {% if index == index_ver %}
                <tr>
                    <td>{{persona.nombre}}</td>
                    <td>{{persona.apellido}}</td>
                    <td>{{persona.apodo}}</td>
                    <td>{{persona.telefono}}</td>
                    <td>{{persona.direccion}}</td>
                </tr>
                {% endif %}
                {% endfor %}
            </tbody>
        </table>
    </div>

    <div class="col-md-6">
        <h2>Personas</h2>
        <table class="table table-hover">
            <thead>
                <tr>
                    <th>Nombre</th>
                    <th>Acciones</th>
                </tr>
            </thead>
            <tbody>
                {% for (index, persona) in Todos.items() %}
                <tr>
                    <td>{{persona.nombre}}, {{persona.apellido}}</td>
                    <td>
                        <a href="{{url_for('ver', index = index)}}" class="btn btn-sm btn-info">Ver</a>
                        <a href="{{url_for('editar', index = index)}}" class="btn btn-sm btn-warning">Editar</a>
                        <a href="{{url_for('eliminar', id = persona.id)}}" class="btn btn-sm btn-danger">Eliminar</a>
                    </td>
                </tr>
                {% endfor %}
                <tr>
                    <td colspan="2">
                        <a href="{{url_for('agregar')}}" class="btn btn-success btn-block">➕ Agregar contacto</a>
                    </td>
                </tr>
            </tbody>
        </table>
    </div>
</div>

{% endblock %}

🎯 Resultado:

Captura aplicación flask crud + boostrap - sección agregar contacto.

Una interfaz mucho más clara, con botones de colores y tablas que se ven profesionales.

Mini resumen: Ya sabes aplicar clases de Bootstrap a tablas, botones y navbar. También entiendes cómo usar url_for dentro de los atributos href para enlaces dinámicos.

 


🖼️ Galería de imágenes con Cards y bucle Jinja2

imagenes-static-flask-bootstrapHasta ahora cargábamos una sola imagen estática. Pero, ¿qué pasa si tenemos diez imágenes? ¿Vamos a escribir diez etiquetas <img>? Eso no es programar, eso es repetir. Y si para repetir se trata tenemos los bucles.

La solución es sencilla: guardamos los datos en una lista (en el controlador o base de datos) y dejamos que Jinja2 genere el HTML automáticamente con un bucle for. Así, si añades o quitas imágenes, solo cambias la lista, no el HTML.

 

Ahora que sabemos manejar imágenes y usar Bootstrap, vamos a crear una galería dinámica con tarjetas (cards). Esto te servirá para mostrar productos, perfiles, publicaciones o cualquier elemento visual.

 

📦 Controlador: pasando datos a la vista

En controlador.py, añadimos una nueva ruta para nuestra galería, e incluímos una lista de imágenes (simulando datos de una base de datos):

@app.route('/galeria')
def galeria():
    imagenes = [
        {'titulo': 'Python', 'archivo': 'python-logo.png', 'descripcion': 'El lenguaje más versátil'},
        {'titulo': 'Flask', 'archivo': 'flask-logo.png', 'descripcion': 'Microframework minimalista'},
        {'titulo': 'Bootstrap', 'archivo': 'bootstrap-logo.png', 'descripcion': 'Framework CSS profesional'},
        {'titulo': 'Jinja2', 'archivo': 'jinja2-logo.png', 'descripcion': 'Motor de plantillas para Flask'},
    ]
    return render_template('galeria.html', imagenes=imagenes)

💡 ¿Por qué una lista de diccionarios? Porque cada imagen tiene varios datos: título, nombre del archivo y descripción. Un diccionario es la forma más natural de representarlo en Python.

Tutorial flask parte 1 - meme 2Este diccionario podría ser incluso un archivo config-imagenes.json donde se almacenen las rutas de las imagenes que sea leído desde el controlador y pasado a la vista.

Lo que nos proporciona la habilidad de cambiar imágenes desde un mismo lugar y no tener que buscar dentro de cada página html.

Aquí te dejo las imagenes, recuerda que deben existir en tu carpeta «static/img» y deben llamarse especificamente como están definidas en el diccionario que usamos.

📦 Descarga todas las imágenes de este post
Coloca los archivos dentro de static/img/ en tu proyecto Flask


⬇️ Descargar imágenes (ZIP)

Archivo ZIP con: logo.png · python-logo.png · flask-logo.png · bootstrap-logo.png · jinja2-logo.png

🏷️ Template: grid de cards con Jinja2

Creamos templates/galeria.html que extiende base.html como una página cualquiera de nuestra app:

{% extends "base.html" %}

{% block title %}Galería de Imágenes{% endblock %}

{% block content %}

<h1 class="text-center my-4">🖼️ Galería de Tecnologías</h1>

<div class="row">
    {% for imagen in imagenes %}
    <div class="col-md-3 col-sm-6 mb-4">
        <div class="card h-100 shadow-sm">
            <img src="{{ url_for('static', filename='img/' + imagen.archivo) }}" 
                 class="card-img-top" 
                 alt="{{ imagen.titulo }}"
                 style="height: 200px; object-fit: contain; background: #f8f9fa; padding: 15px;">
            <div class="card-body">
                <h5 class="card-title">{{ imagen.titulo }}</h5>
                <p class="card-text">{{ imagen.descripcion }}</p>
            </div>
        </div>
    </div>
    {% endfor %}
</div>

<div class="text-center mt-4">
    <a href="{{ url_for('inicio') }}" class="btn btn-primary">Volver al directorio</a>
</div>

{% endblock %}

🎯 ¿Qué está pasando aquí?

  • row crea una fila.
  • col-md-3 → 12 ÷ 3 = 4, así que entran 4 tarjetas por fila en escritorio. En móvil, col-sm-6 hace que sean 2 por fila.
  • card h-100 asegura que todas las tarjetas tengan la misma altura.
  • url_for('static', filename='img/' + imagen.archivo) genera la ruta dinámica a cada imagen.
  • El bucle for de Jinja2 recorre la lista y genera automáticamente cada tarjeta. (Esto lo aprendimos en el anterior post jinja2)

💡 Flexibilidad: Si añades o quitas imágenes de la lista en el controlador, la galería se actualiza sin tocar el código de la vista osea HTML.

Mini resumen: Ahora sabes cómo pasar una lista de datos desde Python a Jinja2, iterar con for y generar HTML dinámico con Bootstrap cards. Esto es la base de cualquier listado de productos, posts o galerías.

🎬 Ejemplo de video en la galería

Para terminar, aquí tienes un ejemplo de cómo quedaría un video insertado como archivo estático dentro de la galería. Este video es una presentación simple del logo, con autoplay muted loop para que se reproduzca automáticamente en bucle.

<video autoplay muted loop width="100%" style="max-width: 600px; border-radius: 8px; display: block; margin: 0 auto;">
    <source src="{{ url_for('static', filename='video/presentacion.mp4') }}" type="video/mp4">
    Tu navegador no soporta la reproducción de videos.
</video>

Fíjate ahora lo insertamos en nuestra galería:

{% extends "base.html" %}

{% block title %}Galería de Imágenes{% endblock %}

{% block content %}

<h1 class="text-center my-4">🖼️ Galería de Tecnologías</h1>

<div class="row">
    {% for imagen in imagenes %}
    <div class="col-md-3 col-sm-6 mb-4">
        <div class="card h-100 shadow-sm">
            <img src="{{ url_for('static', filename='img/' + imagen.archivo) }}" class="card-img-top"
                alt="{{ imagen.titulo }}"
                style="height: 200px; object-fit: contain; background: #f8f9fa; padding: 15px;">
            <div class="card-body">
                <h5 class="card-title">{{ imagen.titulo }}</h5>
                <p class="card-text">{{ imagen.descripcion }}</p>
            </div>
        </div>
    </div>
    {% endfor %}
</div>

<!-- Ejemplo de video insertado con autoplay (presentación del logo) -->
<div style="text-align: center; margin: 30px 0;">
    <video autoplay muted loop width="100%"
        style="max-width: 600px; border-radius: 8px; display: block; margin: 0 auto; background: #000;">
        <source src="{{ url_for('static', filename='video/presentacion.mp4') }}" type="video/mp4">
        Tu navegador no soporta la reproducción de videos.
    </video>
    <p style="font-size: 0.85rem; color: #888; margin-top: 6px;">Presentación del logo (video estático)</p>
</div>

<div class="text-center mt-4">
    <a href="{{ url_for('inicio') }}" class="btn btn-primary">Volver al directorio</a>
</div>

{% endblock %}

 

 

Como ves, es muy sencillo. Puedes usar este mismo método para mostrar tutoriales, demos o cualquier contenido multimedia en tu aplicación Flask.


⚠️ Errores comunes al cargar imágenes, CSS y videos (y cómo solucionarlos)

Es frustrante cuando una imagen no se carga. Aquí los problemas más frecuentes y sus soluciones:

Error Causa más probable Solución
404 Not Found La ruta del archivo no es correcta. Verifica la ubicación del archivo en static/ y que el nombre esté bien escrito (mayúsculas/minúsculas importan).
La imagen no se ve Error en la sintaxis de url_for o en la etiqueta img. Usa {{ url_for('static', filename='img/mi_imagen.jpg') }} y comprueba la extensión (.png, .jpg, etc.).
El video no reproduce Formato no soportado o falta el type correcto. Usa <source src="..." type="video/mp4"> y asegúrate de que el navegador soporte el formato.
CSS no se aplica El orden de carga o la ruta del archivo. Carga style.css después de Bootstrap. Verifica el nombre del archivo.
El archivo está en static/ pero no se encuentra El servidor no está corriendo o la ruta está mal. Reinicia el servidor Flask. En la terminal, el código de estado 200 significa OK, 404 que no se encontró.
⚠️ El CSS no se actualiza aunque lo cambies El navegador guardó el archivo en caché. Forza la recarga con Ctrl+F5 (Windows/Linux) o Cmd+Shift+R (Mac). También puedes reiniciar Flask.

🔍 Códigos HTTP que debes conocer

Cuando ejecutas Flask, en la terminal ves líneas como estas:

127.0.0.1 - - [06/Aug/2026 12:34:56] "GET /static/img/logo.png HTTP/1.1" 200 -
127.0.0.1 - - [06/Aug/2026 12:34:57] "GET /static/img/noexiste.png HTTP/1.1" 404 -

Captura de terminal visual studio code ejemplo de error

Estos son los códigos de estado HTTP que el servidor devuelve con cada petición y te permite detectar si algún elemento no se está mostrando. Los más importantes para ti ahora son:

Código Significado Qué hacer
200 OK La petición fue exitosa. El archivo se cargó correctamente. ✅ Todo bien. No necesitas hacer nada.
301 Moved Permanently La URL ha cambiado y el servidor redirige. Normalmente ocurre si Flask redirige (ej. después de un POST). Es normal.
404 Not Found El archivo no existe en la ruta indicada. ❌ Revisa la ruta, el nombre del archivo y su extensión.
500 Internal Server Error Error en el servidor (normalmente un bug en tu código). ❌ Revisa la terminal de Flask. Busca el error en Python y corrígelo.

💡 Consejo: Cuando algo no funcione, mira la terminal de Flask.. Ahí te dirá exactamente qué ruta está fallando y con qué código. Es tu mejor aliado para depurar.

Mini resumen: Los errores 404 y de caché son los más frecuentes al empezar. Recuerda: Ctrl+F5 para forzar recarga y revisar siempre la terminal de Flask.


📦 Código completo del proyecto (con Bootstrap y archivos estáticos)

✅ Código completo del proyecto con Bootstrap y archivos estáticos
📁 Estructura del proyecto
mi_proyecto_flask/
├── controlador.py
├── models.py
├── DB/
│   └── base_de_datos.json
├── templates/
│   ├── base.html
│   ├── header.html
│   ├── footer.html
│   ├── index.html
│   ├── editar_contacto.html
│   ├── agregar_contacto.html
│   └── galeria.html
└── static/
    ├── css/
    │   └── style.css
    ├── img/
    │   ├── logo.png
    │   ├── python-logo.png
    │   ├── flask-logo.png
    │   ├── bootstrap-logo.png
    │   └── jinja2-logo.png
    ├── video/
    │   └── presentacion.mp4
    └── js/
        └── script.js
🐍 controlador.py
# ============================================================
# controlador.py - Aplicacion Flask con CRUD sobre JSON
# ============================================================
# Este archivo actua como el CONTROLADOR en el patron MVC.
# Gestiona las peticiones HTTP, orquesta la logica de negocio
# (usando el modelo Persona) y renderiza las vistas (templates).
# ============================================================

from flask import Flask, redirect, request, render_template
from models import Persona  # Importamos el modelo para acceder a los datos

app = Flask(__name__)  # Instancia de la aplicacion Flask


# ============================================================
# RUTA PRINCIPAL (READ - Listar todos los contactos)
# ============================================================
@app.route('/', methods=['GET'])
def inicio():
    """
    Pagina de inicio de la aplicacion.
    - Metodo: GET
    - Funcion: Obtiene todas las personas almacenadas en la base de datos JSON
      mediante el metodo leer_contacto('id', 'all') del modelo.
    - Renderiza: index.html pasando el diccionario 'Todos' con todas las personas
      y 'index_ver=0' para que por defecto se muestre la primera persona.
    """
    Todos = Persona.leer_contacto('id', 'all')
    return render_template('index.html', Todos=Todos, index_ver=0)


# ============================================================
# RUTA VER (READ - Mostrar un contacto especifico)
# ============================================================
@app.route('/ver/<int:index>', methods=['GET'])
def ver(index):
    """
    Muestra los detalles completos de una persona en particular.
    - Metodo: GET
    - Parametro URL: index (entero) -> posicion de la persona en el diccionario.
    - Funcion: Recarga la misma vista index.html pero con 'index_ver' igual al
      indice recibido, lo que activa el condicional en la plantilla para mostrar
      solo esa persona.
    """
    Todos = Persona.leer_contacto('id', 'all')
    return render_template('index.html', Todos=Todos, index_ver=index)


# ============================================================
# RUTA EDITAR (UPDATE - Modificar un contacto)
# ============================================================
@app.route('/editar/<int:index>', methods=['GET', 'POST'])
def editar(index):
    """
    Permite modificar los datos de una persona existente.
    - Metodos: GET y POST
    - Parametro URL: index (entero) -> posicion de la persona a editar.
    - GET: Muestra el formulario con los datos actuales de la persona.
    - POST: Recibe los datos del formulario, actualiza el registro en la base de
      datos JSON mediante el modelo, y redirige a la misma vista para reflejar
      los cambios.
    """
    Todos = Persona.leer_contacto('id', 'all')
    if request.method == 'POST':
        # Obtenemos los datos enviados desde el formulario HTML
        id = int(request.form['id'])          # ID oculto de la persona
        Nombre = request.form['Nombre']
        Apellido = request.form['Apellido']
        Apodo = request.form['Apodo']
        Telefono = request.form['Telefono']
        Direccion = request.form['Direccion']

        # Llamamos al modelo para actualizar cada atributo
        Persona.actualizar_contacto(id, 'nombre', Nombre)
        Persona.actualizar_contacto(id, 'apellido', Apellido)
        Persona.actualizar_contacto(id, 'apodo', Apodo)
        Persona.actualizar_contacto(id, 'telefono', Telefono)
        Persona.actualizar_contacto(id, 'direccion', Direccion)
        # Podriamos redirigir a la misma pagina de edicion para mostrar los cambios
        # return redirect(f'/editar/{index}')
    else:
        # Si es GET, solo mostramos el formulario sin procesar nada
        pass

    return render_template('editar_contacto.html', Todos=Todos, index_ver=index)


# ============================================================
# RUTA AGREGAR (CREATE - Crear un nuevo contacto)
# ============================================================
@app.route('/agregar', methods=['GET', 'POST'])
def agregar():
    """
    Permite anadir una nueva persona a la base de datos.
    - Metodos: GET y POST
    - GET: Muestra un formulario vacio para que el usuario ingrese los datos.
    - POST: Recibe los datos del formulario, crea una nueva instancia de Persona
      con esos valores y llama a crear_contacto() para guardarla en el JSON.
      Luego redirige a la misma URL (GET) para recargar la pagina y mostrar la
      lista actualizada con el nuevo contacto.
    """
    Todos = Persona.leer_contacto('id', 'all')
    if request.method == 'POST':
        # Obtenemos los datos del formulario
        Nombre = request.form['Nombre']
        Apellido = request.form['Apellido']
        Apodo = request.form['Apodo']
        Telefono = request.form['Telefono']
        Direccion = request.form['Direccion']

        # Creamos un objeto Persona y lo persistimos
        nuevo_contacto = Persona(Nombre, Apellido, Apodo, Telefono, Direccion)
        nuevo_contacto.crear_contacto()

        # Redirigimos a la misma pagina para que se recargue con la lista actualizada
        return redirect('agregar')
    else:
        pass

    return render_template('agregar_contacto.html', Todos=Todos)


# ============================================================
# RUTA ELIMINAR (DELETE - Borrar un contacto)
# ============================================================
@app.route('/eliminar/<int:id>', methods=['GET'])
def eliminar(id):
    """
    Elimina una persona de la base de datos mediante su ID.
    - Metodo: GET (por simplicidad, aunque lo correcto seria DELETE)
    - Parametro URL: id (entero) -> identificador unico de la persona.
    - Funcion: Llama al metodo eliminar_contacto(id) del modelo y redirige a la
      pagina principal para que se actualice la lista de contactos.
    """
    Persona.eliminar_contacto(id)
    return redirect('/')


# ============================================================
# NUEVA RUTA: GALERIA DE IMAGENES
# ============================================================
@app.route('/galeria')
def galeria():
    """
    Muestra una galeria de imagenes con cards de Bootstrap.
    - Metodo: GET
    - Funcion: Carga una lista de diccionarios con los datos de las imagenes
      y los pasa a la plantilla galeria.html para que Jinja2 genere las cards.
    """
    imagenes = [
        {'titulo': 'Python', 'archivo': 'python-logo.png', 'descripcion': 'El lenguaje mas versatil'},
        {'titulo': 'Flask', 'archivo': 'flask-logo.png', 'descripcion': 'Microframework minimalista'},
        {'titulo': 'Bootstrap', 'archivo': 'bootstrap-logo.png', 'descripcion': 'Framework CSS profesional'},
        {'titulo': 'Jinja2', 'archivo': 'jinja2-logo.png', 'descripcion': 'Motor de plantillas para Flask'},
    ]
    return render_template('galeria.html', imagenes=imagenes)


# ============================================================
# RUTA DE AGRADECIMIENTO (Ejemplo adicional)
# ============================================================
@app.route('/agradecimiento', methods=['GET'])
def agradecer():
    return 'Gracias pythones.net!'


# ============================================================
# INICIO DE LA APLICACION
# ============================================================
if __name__ == '__main__':
    app.run('127.0.0.1', 5000, debug=True)
🗄️ models.py
# ============================================================
# models.py - Modelo de datos para la aplicacion Flask
# ============================================================
# Este archivo actua como el MODELO en el patron MVC.
# Contiene la logica de negocio y el acceso a la base de datos
# (en este caso, un archivo JSON que funciona como almacenamiento persistente).
# ============================================================

import os
import json

# ============================================================
# CONFIGURACION DE LA RUTA DEL ARCHIVO JSON
# ============================================================

# Obtenemos la ruta absoluta del directorio donde se encuentra este script
THIS_FOLDER = os.path.dirname(os.path.abspath(__file__))
# Construimos la ruta al archivo base_de_datos.json dentro de la carpeta /DB/
my_file = os.path.join(THIS_FOLDER + '/DB/' 'base_de_datos.json')


# ============================================================
# FUNCIONES DE ACCESO A DATOS (Persistencia)
# ============================================================

def leer_json():
    """
    Lee el archivo JSON y devuelve su contenido como un diccionario de Python.
    - Abre el archivo en modo lectura ('r').
    - Carga los datos con json.load().
    - Cierra el archivo automaticamente.
    - Retorna el diccionario 'datos'.
    """
    with open(my_file, "r") as f:
        datos = json.load(f)
        f.close()
        return datos

# Cargamos los datos al iniciar el modulo (se ejecuta una sola vez)
datos = leer_json()


def modificar_json():
    """
    Guarda el diccionario 'datos' actualizado en el archivo JSON.
    - Abre el archivo en modo escritura ('w').
    - Escribe los datos con formato indentado (4 espacios) para legibilidad.
    - Cierra el archivo.
    - Esta funcion se llama despues de cada operacion que modifica los datos.
    """
    with open(my_file, "w") as modid:
        json.dump(datos, modid, indent=4)
        modid.close()


# ============================================================
# CLASE PERSONA - REPRESENTA UNA ENTIDAD DEL DOMINIO
# ============================================================

class Persona(object):
    """
    Clase que modela a una persona (contacto) en el directorio telefonico.
    - Atributos: id, nombre, apellido, apodo, telefono, direccion.
    - Proporciona metodos CRUD para gestionar instancias en la base de datos JSON.
    - El 'id' es autoincremental y se gestiona mediante 'contador_id'.
    """

    # Variable de clase que almacena el ultimo ID asignado (se sincroniza con el JSON)
    contador_id = datos["Configuraciones"][0]["contador_id_db"]

    def __init__(self, nombre, apellido, apodo, telefono, direccion):
        """
        Constructor de la clase Persona.
        - Asigna un ID unico tomado de contador_id.
        - Almacena los demas atributos proporcionados.
        - Imprime en consola el ID asignado (util para depuracion).
        """
        print("El contador esta en: ", Persona.contador_id)
        self.id = Persona.contador_id          # ID unico autoincremental
        self.nombre = nombre
        self.apellido = apellido
        self.apodo = apodo
        self.telefono = telefono
        self.direccion = direccion

    # ============================================================
    # METODO CREATE - Crear un nuevo contacto
    # ============================================================

    def crear_contacto(self):
        """
        Guarda la instancia actual como un nuevo registro en la base de datos JSON.
        - Incrementa el contador de IDs en el archivo JSON y en la variable de clase.
        - Convierte la instancia a diccionario (__dict__) y la anade a la lista 'Personas'.
        - Persiste los cambios llamando a modificar_json().
        - Imprime el nuevo valor del contador (para depuracion).
        """
        # Actualizamos el contador en la base de datos
        datos["Configuraciones"][0]["contador_id_db"] += 1
        modificar_json()
        print("El contador esta ahora en: ", datos["Configuraciones"][0]["contador_id_db"])
        Persona.contador_id += 1  # Sincronizamos la variable de clase

        # Creamos una nueva instancia (esto es necesario para que __init__ asigne el ID correcto)
        # Pero aqui reutilizamos la instancia actual: convertimos self a diccionario
        nueva_persona = Persona(
            self.nombre,
            self.apellido,
            self.apodo,
            self.telefono,
            self.direccion
        ).__dict__

        # Anadimos el diccionario a la lista de personas
        datos['Personas'].append(nueva_persona)
        modificar_json()  # Guardamos los cambios

    # ============================================================
    # METODO READ - Leer contactos (busqueda)
    # ============================================================

    def leer_contacto(atr, valor):
        """
        Busca personas segun un atributo y un valor dado.
        - Si valor == 'all': devuelve todas las personas con su indice como clave.
        - Si valor != 'all': busca personas donde el atributo 'atr' sea igual a 'valor'.
        - Retorna un diccionario {indice: persona_diccionario} con los resultados.
        - Este metodo es de clase (@staticmethod de facto) porque no necesita una instancia.
        """
        if valor == 'all':
            # Devuelve todas las personas con su indice como clave
            todas = {}
            for i, persona in enumerate(datos["Personas"]):
                todas[i] = persona
            return todas
        else:
            # Busqueda por atributo especifico
            encontradas = {}
            for persona in datos["Personas"]:
                if persona[atr] == valor:
                    indice = datos["Personas"].index(persona)
                    encontradas[indice] = persona
            return encontradas

    # ============================================================
    # METODO UPDATE - Actualizar un contacto existente
    # ============================================================

    def actualizar_contacto(id, atr, nuevo_valor):
        """
        Actualiza un atributo especifico de una persona identificada por su ID.
        - Itera sobre la lista de personas hasta encontrar el ID.
        - Una vez encontrado, obtiene su indice y modifica el atributo indicado.
        - Persiste los cambios llamando a modificar_json().
        - No retorna nada (la actualizacion se hace in-place).
        """
        for persona in datos["Personas"]:
            if persona["id"] == id:
                print(persona)  # Mostramos el estado anterior (depuracion)
                indice = datos["Personas"].index(persona)
                datos["Personas"][indice][atr] = nuevo_valor
                print(datos["Personas"][indice][atr])  # Mostramos el nuevo valor (depuracion)
                modificar_json()
                break  # Salimos del bucle tras encontrar y actualizar

    # ============================================================
    # METODO DELETE - Eliminar un contacto
    # ============================================================

    def eliminar_contacto(id):
        """
        Elimina la persona con el ID especificado de la base de datos.
        - Itera sobre la lista de personas hasta encontrar el ID.
        - Una vez encontrado, obtiene su indice y lo elimina con pop().
        - Persiste los cambios llamando a modificar_json().
        - Muestra un mensaje en consola con la persona eliminada (depuracion).
        """
        for persona in datos["Personas"]:
            if persona["id"] == id:
                print("Se va a borrar: ", persona)
                indice = datos["Personas"].index(persona)
                datos["Personas"].pop(indice)
                modificar_json()
                break  # Salimos tras eliminar
📄 base_de_datos.json (estructura inicial)
{
  "Configuraciones": [
    {
      "contador_id_db": 2
    }
  ],
  "Personas": [
    {
      "id": 0,
      "nombre": "Juan",
      "apellido": "Pérez",
      "apodo": "juanpe",
      "telefono": "123456789",
      "direccion": "Calle Falsa 123"
    },
    {
      "id": 1,
      "nombre": "María",
      "apellido": "Gómez",
      "apodo": "mary",
      "telefono": "987654321",
      "direccion": "Avenida Siempreviva 456"
    }
  ]
}
🧩 base.html – Plantilla base (con Bootstrap y CSS personalizado)
<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{% block title %}Directorio Telefónico{% endblock %}</title>
    
    <!-- Bootstrap CSS (CDN) -->
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" 
          integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" 
          crossorigin="anonymous">

    <!-- CSS PERSONALIZADO (se carga DESPUES de Bootstrap para sobrescribir) -->
    <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
    {% include 'header.html' %}  <!-- Barra de navegacion comun -->

    <main class="container mt-4">
        {% block content %}{% endblock %}  <!-- Contenido especifico de cada pagina -->
    </main>

    {% include 'footer.html' %}  <!-- Pie de pagina comun -->

    <!-- Bootstrap JS (jQuery, Popper, Bootstrap) -->
    <script src="https://code.jquery.com/jquery-3.4.1.slim.min.js" 
            integrity="sha384-J6qa4849blE2+poT4WnyKhv5vZF5SrPo0iEjwBvKU7imGFAV0wwj1yYfoRSJoZ+n" 
            crossorigin="anonymous"></script>
    <script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" 
            integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" 
            crossorigin="anonymous"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/js/bootstrap.min.js" 
            integrity="sha384-wfSDF2E50Y2D1uUdj0O3uMBJnjuUD4Ih7YwaYd1iqfktj0Uod8GCExl3Og8ifwB6" 
            crossorigin="anonymous"></script>
</body>
</html>
🔝 header.html – Barra de navegación con logo y menú activo
<nav class="navbar navbar-expand-lg navbar-light bg-light">
    <a class="navbar-brand" href="{{ url_for('inicio') }}">
        <img src="{{ url_for('static', filename='img/logo.png') }}" alt="Logo" height="40" class="d-inline-block align-top">
        Directorio Online
    </a>
    <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav">
        <span class="navbar-toggler-icon"></span>
    </button>
    <div class="collapse navbar-collapse" id="navbarNav">
        <ul class="navbar-nav ml-auto">
            <li class="nav-item {% if request.endpoint == 'inicio' %}active{% endif %}">
                <a class="nav-link" href="{{ url_for('inicio') }}">Inicio</a>
            </li>
            <li class="nav-item {% if request.endpoint == 'agregar' %}active{% endif %}">
                <a class="nav-link" href="{{ url_for('agregar') }}">Agregar</a>
            </li>
            <li class="nav-item {% if request.endpoint == 'galeria' %}active{% endif %}">
                <a class="nav-link" href="{{ url_for('galeria') }}">Galería</a>
            </li>
            <li class="nav-item {% if request.endpoint == 'soporte' %}active{% endif %}">
                <a class="nav-link" href="#">Soporte</a>
            </li>
        </ul>
    </div>
</nav>
👣 footer.html – Pie de página
<footer class="text-center py-3 mt-4 border-top">
    <p class="mb-0">&copy; 2026 - Directorio Telefónico con Flask </p>
</footer>
🎨 static/css/style.css – Estilos personalizados
/* ============================================================
   ESTILOS PERSONALIZADOS - Pythones Flask App
   ============================================================ */

/* ---- Barra de navegacion ---- */
.navbar {
    background-color: #f8f9fa;
    border-bottom: 2px solid #e0e0e0;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}

.navbar .navbar-brand {
    color: #2c3e50;
    font-weight: 600;
    font-size: 1.3rem;
    letter-spacing: -0.5px;
}

.navbar .navbar-brand:hover {
    color: #27ae60;
}

.navbar .navbar-nav .nav-link {
    color: #555;
    font-weight: 500;
    transition: color 0.2s ease;
    padding: 0.5rem 1rem;
}

.navbar .navbar-nav .nav-link:hover {
    color: #27ae60;
}

/* Elemento activo del menu */
.navbar .navbar-nav .nav-item.active .nav-link {
    color: #27ae60;
    font-weight: 600;
    border-bottom: 2px solid #27ae60;
    padding-bottom: 0.3rem;
}

/* ---- Pie de pagina ---- */
footer {
    background-color: #f8f9fa;
    color: #7f8c8d;
    border-top: 1px solid #e0e0e0;
    padding: 20px 0;
    margin-top: 40px;
}

footer p {
    margin: 0;
    font-size: 0.9rem;
}

/* ---- Mejoras generales ---- */
body {
    background-color: #fcfcfc;
    min-height: 100vh;
    display: flex;
    flex-direction: column;
}

main {
    flex: 1;
}

/* Tablas con un poco mas de estilo */
.table {
    background-color: #ffffff;
    border-radius: 6px;
    overflow: hidden;
    box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
}

.table thead th {
    background-color: #f1f2f6;
    border-bottom: 2px solid #dcdde1;
    font-weight: 600;
    color: #2c3e50;
}

/* Botones mejorados */
.btn {
    border-radius: 20px;
    padding: 6px 18px;
    font-weight: 500;
    font-size: 0.85rem;
}

.btn-success {
    background-color: #27ae60;
    border-color: #27ae60;
}

.btn-success:hover {
    background-color: #219a52;
    border-color: #219a52;
}

.btn-warning {
    background-color: #f39c12;
    border-color: #f39c12;
    color: #fff;
}

.btn-warning:hover {
    background-color: #d68910;
    border-color: #d68910;
}

.btn-danger {
    background-color: #e74c3c;
    border-color: #e74c3c;
}

.btn-danger:hover {
    background-color: #c0392b;
    border-color: #c0392b;
}

.btn-primary {
    background-color: #3498db;
    border-color: #3498db;
}

.btn-primary:hover {
    background-color: #2980b9;
    border-color: #2980b9;
}

/* Titulos */
h1, h2, h3, h4 {
    color: #2c3e50;
}

h1 {
    font-weight: 700;
    letter-spacing: -1px;
}

h2 {
    font-weight: 600;
    border-bottom: 2px solid #ecf0f1;
    padding-bottom: 8px;
    margin-bottom: 20px;
}

/* Cards con efecto hover */
.card {
    transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.card:hover {
    transform: scale(1.02);
    box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
}

/* Alertas y mensajes */
.alert {
    border-radius: 8px;
    border: none;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
🏠 index.html – Página de inicio (extiende base.html)
{% extends "base.html" %}

{% block title %}Inicio - Directorio Telefónico{% endblock %}

{% block content %}

<h1 style="text-align:center;">Directorio Telefónico - Mi primera aplicación en Flask</h1>

<div class="row mt-4">
    <div class="col-md-6">
        <h2>Datos completos de la persona</h2>
        <table class="table table-bordered table-striped">
            <thead class="thead-dark">
                <tr>
                    <th>Nombre</th>
                    <th>Apellido</th>
                    <th>Apodo</th>
                    <th>Teléfono</th>
                    <th>Dirección</th>
                </tr>
            </thead>
            <tbody>
                {% for (index, persona) in Todos.items() %}
                {% if index == index_ver %}
                <tr>
                    <td>{{persona.nombre}}</td>
                    <td>{{persona.apellido}}</td>
                    <td>{{persona.apodo}}</td>
                    <td>{{persona.telefono}}</td>
                    <td>{{persona.direccion}}</td>
                </tr>
                {% endif %}
                {% endfor %}
            </tbody>
        </table>
    </div>

    <div class="col-md-6">
        <h2>Personas</h2>
        <table class="table table-hover">
            <thead>
                <tr>
                    <th>Nombre</th>
                    <th>Acciones</th>
                </tr>
            </thead>
            <tbody>
                {% for (index, persona) in Todos.items() %}
                <tr>
                    <td>{{persona.nombre}}, {{persona.apellido}}</td>
                    <td>
                        <a href="{{url_for('ver', index = index)}}" class="btn btn-sm btn-info">Ver</a>
                        <a href="{{url_for('editar', index = index)}}" class="btn btn-sm btn-warning">Editar</a>
                        <a href="{{url_for('eliminar', id = persona.id)}}" class="btn btn-sm btn-danger">Eliminar</a>
                    </td>
                </tr>
                {% endfor %}
                <tr>
                    <td colspan="2">
                        <a href="{{url_for('agregar')}}" class="btn btn-success btn-block">➕ Agregar contacto</a>
                    </td>
                </tr>
            </tbody>
        </table>
    </div>
</div>

{% endblock %}
✏️ editar_contacto.html – Editar contacto (extiende base.html)
{% extends "base.html" %}

{% block title %}Editar Contacto{% endblock %}

{% block content %}

<div class="row mt-4">
    <div class="col-md-6">
        <h2>Datos completos de la persona</h2>
        <table class="table table-bordered table-striped">
            <thead class="thead-dark">
                <tr>
                    <th>Nombre</th>
                    <th>Apellido</th>
                    <th>Apodo</th>
                    <th>Teléfono</th>
                    <th>Dirección</th>
                </tr>
            </thead>
            <tbody>
                {% for (index, persona) in Todos.items() %}
                {% if index == index_ver %}
                <tr>
                    <td>{{persona.nombre}}</td>
                    <td>{{persona.apellido}}</td>
                    <td>{{persona.apodo}}</td>
                    <td>{{persona.telefono}}</td>
                    <td>{{persona.direccion}}</td>
                </tr>
                {% endif %}
                {% endfor %}
            </tbody>
        </table>
    </div>

    <div class="col-md-6">
        <h2>Personas</h2>
        <table class="table table-hover">
            <thead>
                <tr>
                    <th>Nombre</th>
                    <th>Acciones</th>
                </tr>
            </thead>
            <tbody>
                {% for (index, persona) in Todos.items() %}
                <tr>
                    <td>{{persona.nombre}}, {{persona.apellido}}</td>
                    <td>
                        <a href="{{url_for('ver', index = index)}}" class="btn btn-sm btn-info">Ver</a>
                        <a href="{{url_for('editar', index = index)}}" class="btn btn-sm btn-warning">Editar</a>
                        <a href="{{url_for('eliminar', id = persona.id)}}" class="btn btn-sm btn-danger">Eliminar</a>
                    </td>
                </tr>
                {% endfor %}
                <tr>
                    <td colspan="2">
                        <a href="{{url_for('agregar')}}" class="btn btn-success btn-block">➕ Agregar contacto</a>
                    </td>
                </tr>
            </tbody>
        </table>
    </div>
</div>

<!-- SECCION DE EDICION (formulario) -->
<div class="row mt-4">
    <div class="col-12">
        <h2>Modificar Persona</h2>
        <h4>Base de datos JSON (Index = {{index_ver}} ID = {{Todos[index_ver].id}})</h4>
        <form action="{{url_for('editar', index = index_ver)}}" method="post">
            <div class="form-row">
                <input type="hidden" name="id" value="{{Todos[index_ver].id}}">
                <div class="form-group col-md-2">
                    <input type="text" class="form-control" name="Nombre" value="{{Todos[index_ver].nombre}}">
                </div>
                <div class="form-group col-md-2">
                    <input type="text" class="form-control" name="Apellido" value="{{Todos[index_ver].apellido}}">
                </div>
                <div class="form-group col-md-2">
                    <input type="text" class="form-control" name="Apodo" value="{{Todos[index_ver].apodo}}">
                </div>
                <div class="form-group col-md-2">
                    <input type="tel" class="form-control" name="Telefono" value="{{Todos[index_ver].telefono}}">
                </div>
                <div class="form-group col-md-2">
                    <input type="text" class="form-control" name="Direccion" value="{{Todos[index_ver].direccion}}">
                </div>
                <div class="form-group col-md-2">
                    <button type="submit" class="btn btn-primary btn-block">Modificar</button>
                </div>
            </div>
        </form>
    </div>
</div>

{% endblock %}
➕ agregar_contacto.html – Agregar contacto (extiende base.html)
{% extends "base.html" %}

{% block title %}Agregar Contacto{% endblock %}

{% block content %}

<div class="row mt-4">
    <div class="col-md-6">
        <h2>Agregar Persona</h2>
        <form action="{{url_for('agregar')}}" method="post">
            <div class="form-row">
                <div class="form-group col-md-6">
                    <input type="text" class="form-control" name="Nombre" placeholder="Nombre">
                </div>
                <div class="form-group col-md-6">
                    <input type="text" class="form-control" name="Apellido" placeholder="Apellido">
                </div>
                <div class="form-group col-md-6">
                    <input type="text" class="form-control" name="Apodo" placeholder="Apodo">
                </div>
                <div class="form-group col-md-6">
                    <input type="tel" class="form-control" name="Telefono" placeholder="Teléfono">
                </div>
                <div class="form-group col-md-12">
                    <input type="text" class="form-control" name="Direccion" placeholder="Dirección">
                </div>
                <div class="form-group col-md-12">
                    <button type="submit" class="btn btn-success btn-block">➕ Agregar contacto</button>
                </div>
            </div>
        </form>
    </div>

    <div class="col-md-6">
        <h2>Personas</h2>
        <table class="table table-hover">
            <thead>
                <tr>
                    <th>Nombre</th>
                    <th>Acciones</th>
                </tr>
            </thead>
            <tbody>
                {% for (index, persona) in Todos.items() %}
                <tr>
                    <td>{{persona.nombre}}, {{persona.apellido}}</td>
                    <td>
                        <a href="{{url_for('ver', index = index)}}" class="btn btn-sm btn-info">Ver</a>
                        <a href="{{url_for('editar', index = index)}}" class="btn btn-sm btn-warning">Editar</a>
                        <a href="{{url_for('eliminar', id = persona.id)}}" class="btn btn-sm btn-danger">Eliminar</a>
                    </td>
                </tr>
                {% endfor %}
                <tr>
                    <td colspan="2">
                        <a href="{{url_for('agregar')}}" class="btn btn-success btn-block">➕ Agregar contacto</a>
                    </td>
                </tr>
            </tbody>
        </table>
    </div>
</div>

{% endblock %}
🖼️ galeria.html – Galería de imágenes (extiende base.html)
{% extends "base.html" %}

{% block title %}Galería de Imágenes{% endblock %}

{% block content %}

<h1 class="text-center my-4">🖼️ Galería de Tecnologías</h1>

<div class="row">
    {% for imagen in imagenes %}
    <div class="col-md-3 col-sm-6 mb-4">
        <div class="card h-100 shadow-sm">
            <img src="{{ url_for('static', filename='img/' + imagen.archivo) }}" 
                 class="card-img-top" 
                 alt="{{ imagen.titulo }}"
                 style="height: 200px; object-fit: contain; background: #f8f9fa; padding: 15px;">
            <div class="card-body">
                <h5 class="card-title">{{ imagen.titulo }}</h5>
                <p class="card-text">{{ imagen.descripcion }}</p>
            </div>
        </div>
    </div>
    {% endfor %}
</div>

<!-- Ejemplo de video insertado con autoplay (presentación del logo) -->
<div style="text-align: center; margin: 30px 0;">
    <video autoplay muted loop width="100%" style="max-width: 600px; border-radius: 8px; display: block; margin: 0 auto; background: #000;">
        <source src="{{ url_for('static', filename='video/presentacion.mp4') }}" type="video/mp4">
        Tu navegador no soporta la reproducción de videos.
    </video>
    <p style="font-size: 0.85rem; color: #888; margin-top: 6px;">Presentación del logo (video estático)</p>
</div>

<div class="text-center mt-4">
    <a href="{{ url_for('inicio') }}" class="btn btn-primary">Volver al directorio</a>
</div>

{% endblock %}

🎯 Resumen

Hoy has aprendido a integrar Bootstrap en Flask, a manejar archivos estáticos (imágenes, videos, CSS y JS), y a crear una galería dinámica con cards usando un bucle de Jinja2. También has mejorado tu directorio telefónico con tablas y botones estilizados, y has entendido cómo depurar errores comunes como 404 o problemas de caché.

Lo más importante:

Ya no solo sabes cómo hacerlo, sino por qué se hace así.

Eso es lo que marca la diferencia.

En el próximo post, vamos a dar el siguiente salto: formularios profesionales con WTForms, validación y envío de correos.

🐍 ¿Te ha servido este post? Déjame un comentario o compártelo con quien esté aprendiendo Flask.

📅 Última actualización: agosto 7, 2026

¡Compartir es una forma de agradecer! :)

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio