Build: Versión final estable y con nuevo diseño

Aplicación completamente funcional con servidor Waitress. Solucionados todos los problemas de arranque y timeouts. Se ha implementado un nuevo diseño visual moderno en todas las plantillas. El script de instalación está actualizado y es robusto.
This commit is contained in:
jlimolina 2025-06-08 22:01:14 +00:00
parent 2bc9fbcdf8
commit 4ea56f3247
2 changed files with 218 additions and 290 deletions

309
templates/base.html Executable file → Normal file
View file

@ -1,200 +1,123 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="es"> <html lang="es">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>{% block title %}Noticias RSS{% endblock %}</title> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="https://fonts.googleapis.com/css?family=Inter:400,600&display=swap" rel="stylesheet"> <title>{% block title %}Agregador de Noticias RSS{% endblock %}</title>
<style>
body { <link rel="preconnect" href="https://fonts.googleapis.com">
background: #f6f8fa; <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
font-family: 'Inter', Arial, sans-serif; <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap" rel="stylesheet">
margin: 0;
color: #222; <style>
} /* --- Variables Globales de Diseño --- */
.container { :root {
max-width: 900px; --primary-color: #6a11cb;
margin: 32px auto; --secondary-color: #2575fc;
background: #fff; --gradiente-principal: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
border-radius: 12px; --error-color: #f72585;
box-shadow: 0 4px 16px #0001; --text-color: #2c3e50;
padding: 32px 32px 20px 32px; --text-color-light: #576475;
min-height: 80vh; --card-bg: rgba(255, 255, 255, 0.75);
} --border-color: rgba(200, 200, 220, 0.4);
h1, h2 { --shadow-color: rgba(67, 97, 238, 0.15);
font-weight: 600; --border-radius-md: 12px;
color: #2d3e50; --border-radius-sm: 8px;
margin-bottom: 18px; --transition-speed: 0.3s;
} }
.card {
background: #f8fafc; /* --- Estilos Base --- */
border-radius: 8px; * { box-sizing: border-box; }
box-shadow: 0 1px 4px #0001; body {
padding: 24px 20px 18px 20px; font-family: 'Poppins', 'Segoe UI', Tahoma, sans-serif;
margin-bottom: 30px; margin: 0;
overflow-x: auto; padding: 20px 0;
} background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
.btn { color: var(--text-color);
display: inline-block; line-height: 1.6;
background: #396afc; font-weight: 400;
color: #fff !important; }
font-weight: 600;
padding: 8px 20px; /* --- Contenedor Principal con Efecto Vidrio --- */
border: none; .container {
border-radius: 6px; max-width: 900px;
text-decoration: none; margin: 30px auto;
font-size: 1em; padding: 30px 40px;
margin: 5px 0; background: var(--card-bg);
cursor: pointer; border-radius: 20px;
transition: background 0.2s; border: 1px solid rgba(255, 255, 255, 0.2);
} box-shadow: 0 8px 32px 0 var(--shadow-color);
.btn:hover { backdrop-filter: blur(12px);
background: #274c8a; -webkit-backdrop-filter: blur(12px);
} }
label {
font-weight: 500; /* --- Encabezados y Títulos --- */
margin-top: 10px; header { text-align: center; margin-bottom: 40px; border-bottom: 1px solid var(--border-color); padding-bottom: 30px; }
margin-bottom: 2px; h1 { font-size: 2.8rem; font-weight: 700; margin: 0 0 5px 0; background: var(--gradiente-principal); -webkit-background-clip: text; -webkit-text-fill-color: transparent; display: inline-block; }
display: block; h2 { font-size: 1.8rem; font-weight: 600; color: var(--primary-color); margin-bottom: 20px; }
} .subtitle { color: var(--text-color-light); font-size: 1.1rem; margin-top: 5px; }
input[type="text"],
input[type="url"], /* --- Formularios y Controles --- */
input[type="file"], .form-section, .card { margin-bottom: 30px; background: rgba(255, 255, 255, 0.6); padding: 25px; border-radius: var(--border-radius-md); border: 1px solid var(--border-color); }
select, label { display: block; margin-bottom: 8px; font-weight: 600; color: var(--text-color); font-size: 0.9rem; }
textarea { select, input[type="text"], input[type="url"], input[type="file"], textarea { width: 100%; padding: 12px 15px; border: 1px solid var(--border-color); background-color: #f8f9fa; border-radius: var(--border-radius-sm); font-size: 1rem; font-family: 'Poppins', sans-serif; transition: all var(--transition-speed) ease; }
width: 100%; select:focus, input:focus, textarea:focus { outline: none; border-color: var(--primary-color); box-shadow: 0 0 0 3px var(--shadow-color); background-color: white; }
padding: 7px 10px;
margin-bottom: 13px; /* --- Botones y Enlaces --- */
border: 1px solid #d3d8e1; .btn, button { padding: 12px 25px; background: var(--gradiente-principal); color: white !important; border: none; border-radius: var(--border-radius-sm); font-size: 1rem; font-weight: 600; cursor: pointer; transition: all var(--transition-speed) ease; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); text-decoration: none; display: inline-block; text-align: center; }
border-radius: 5px; .btn:hover, button:hover { transform: translateY(-3px); box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2); text-decoration: none; }
font-size: 1em; .btn-secondary { background: #34495e; } .btn-secondary:hover { background: #2c3e50; }
font-family: inherit; a { color: var(--secondary-color); text-decoration: none; font-weight: 500; } a:hover { text-decoration: underline; }
background: #f8fafc; .top-link { display: inline-block; margin-bottom: 25px; font-weight: 500; color: var(--primary-color); }
transition: border 0.2s; .top-link:hover { text-decoration: underline; }
box-sizing: border-box;
} /* --- Estilos para la lista de noticias --- */
input[type="text"]:focus, .noticias-list { list-style: none; padding: 0; margin: 0; }
input[type="url"]:focus, .noticia-item { display: flex; gap: 20px; padding: 20px 10px; border-bottom: 1px solid var(--border-color); transition: background-color 0.2s ease; }
select:focus, .noticia-item:last-child { border-bottom: none; }
textarea:focus { .noticia-item:hover { background-color: rgba(255,255,255,0.4); }
outline: none; .noticia-imagen img { width: 150px; height: 100px; border-radius: var(--border-radius-sm); object-fit: cover; }
border-color: #396afc; .noticia-texto h3 { margin: 0 0 5px 0; }
background: #f1f6fd; .noticia-texto h3 a { color: var(--text-color); font-weight: 600; }
} .noticia-texto h3 a:hover { color: var(--primary-color); }
table { .noticia-meta { font-size: 0.8rem; color: var(--text-color-light); margin-bottom: 8px; }
width: 100%;
border-collapse: collapse; /* --- Tabla de Gestión de Feeds --- */
margin-top: 10px; .table-wrapper { overflow-x: auto; }
background: #fff; table { width: 100%; border-collapse: collapse; margin-top: 20px; }
font-size: 1em; th, td { padding: 12px 15px; text-align: left; border-bottom: 1px solid var(--border-color); }
box-shadow: 0 1px 8px #0001; th { background-color: rgba(106, 27, 203, 0.05); font-weight: 600; }
} tr:last-child td { border-bottom: none; }
th, td { td .actions a { margin-right: 10px; }
padding: 9px 10px;
border-bottom: 1px solid #e5e7eb; /* --- Alertas y Mensajes Flash --- */
text-align: left; .flash-messages { list-style: none; padding: 0; margin-bottom: 20px; }
} .flash-messages li { padding: 15px 20px; border-radius: var(--border-radius-sm); border-left: 5px solid; }
th { .flash-messages .error { background-color: #fff0f3; color: #d90429; border-color: var(--error-color); }
background: #f1f5fa; .flash-messages .success { background-color: #e6fcf5; color: #00b894; border-color: #00b894; }
font-weight: 600; .flash-messages .warning { background-color: #fffbeb; color: #f39c12; border-color: #f39c12; }
color: #396afc;
border-top: 1px solid #e5e7eb; /* --- Responsividad --- */
} @media (max-width: 768px) {
tr:last-child td { .container { padding: 20px; margin: 15px; }
border-bottom: none; h1 { font-size: 2rem; }
} .noticia-item { flex-direction: column; }
.top-link { }
display: inline-block; </style>
margin-bottom: 22px;
color: #396afc;
font-weight: 500;
text-decoration: none;
font-size: 1em;
transition: color 0.2s;
}
.top-link:hover {
color: #274c8a;
text-decoration: underline;
}
/* ------ Noticias con imagen a la derecha ------ */
.noticias-list {
list-style: none;
padding: 0;
margin: 0;
}
.noticia-item {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #ececec;
padding: 20px 0 20px 0;
gap: 30px;
min-height: 110px;
}
.noticia-texto {
flex: 1 1 62%;
min-width: 0;
}
.noticia-imagen {
flex: 0 0 165px;
display: flex;
align-items: center;
justify-content: flex-end;
}
.noticia-imagen img {
max-width: 150px;
max-height: 95px;
border-radius: 9px;
box-shadow: 0 1px 8px #0002;
object-fit: cover;
display: block;
}
@media (max-width: 730px) {
.container { padding: 10px; }
.noticia-item { flex-direction: column; align-items: flex-start; gap: 9px; }
.noticia-imagen { align-self: flex-start; }
}
/* ------ BADGES DE ESTADO ------ */
.badge-ok {
background: #ddffdd;
color: #1c8c1c;
font-weight: bold;
padding: 3px 14px;
border-radius: 8px;
font-size: 1em;
display: inline-block;
min-width: 38px;
text-align: center;
}
.badge-ko {
background: #ffdddd;
color: #b20000;
font-weight: bold;
padding: 3px 14px;
border-radius: 8px;
font-size: 1em;
display: inline-block;
min-width: 38px;
text-align: center;
}
.badge-warn {
background: #fff7cc;
color: #b68900;
font-weight: bold;
padding: 3px 14px;
border-radius: 8px;
font-size: 1em;
display: inline-block;
min-width: 38px;
text-align: center;
}
/* Scroll suave para tablas grandes */
.card { overflow-x: auto; }
</style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
{% block content %}{% endblock %} {% with messages = get_flashed_messages(with_categories=true) %}
</div> {% if messages %}
<ul class="flash-messages">
{% for category, message in messages %}
<li class="{{ category }}">{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
</body> </body>
</html> </html>

199
templates/noticias.html Executable file → Normal file
View file

@ -1,107 +1,112 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Últimas Noticias RSS{% endblock %} {% block title %}Últimas Noticias RSS{% endblock %}
{% block content %} {% block content %}
<h1>Últimas Noticias Recopiladas</h1> <header>
<a href="/feeds" class="top-link">⚙️ Gestionar feeds RSS</a> <h1>Agregador de Noticias</h1>
<p class="subtitle">Tus fuentes de información, en un solo lugar.</p>
<a href="{{ url_for('feeds') }}" class="top-link" style="margin-top:15px;">⚙️ Gestionar Feeds</a>
</header>
<div class="card"> <div class="card">
<form method="get" action=""> <h2>Filtrar Noticias</h2>
<div style="display: flex; flex-wrap: wrap; gap: 12px;"> <form method="get" action="{{ url_for('home') }}">
<div style="flex:1;"> <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; align-items: flex-end;">
<label for="categoria_id">Categoría</label> <div>
<select name="categoria_id" id="categoria_id"> <label for="categoria_id">Categoría</label>
<option value="">— Categoría —</option> <select name="categoria_id" id="categoria_id">
{% for cid, cnom in categorias %} <option value="">— Todas —</option>
<option value="{{ cid }}" {% if cat_id == cid %}selected{% endif %}>{{ cnom }}</option> {% for cat in categorias %}
{% endfor %} <option value="{{ cat.id }}" {% if cat_id == cat.id %}selected{% endif %}>{{ cat.nombre }}</option>
</select> {% endfor %}
</div> </select>
<div style="flex:1;"> </div>
<label for="continente_id">Continente</label> <div>
<select name="continente_id" id="continente_id" onchange="filtrarPaisesPorContinente()"> <label for="continente_id">Continente</label>
<option value="">— Continente —</option> <select name="continente_id" id="continente_id" onchange="filtrarPaises()">
{% for coid, conom in continentes %} <option value="">— Todos —</option>
<option value="{{ coid }}" {% if cont_id == coid %}selected{% endif %}>{{ conom }}</option> {% for cont in continentes %}
{% endfor %} <option value="{{ cont.id }}" {% if cont_id == cont.id %}selected{% endif %}>{{ cont.nombre }}</option>
</select> {% endfor %}
</div> </select>
<div style="flex:1;"> </div>
<label for="pais_id">País</label> <div>
<select name="pais_id" id="pais_id"> <label for="pais_id">País</label>
<option value="">— País —</option> <select name="pais_id" id="pais_id">
{% for pid, pnom, contid in paises %} <option value="">— Todos —</option>
<option value="{{ pid }}" {# El JavaScript llenará esto dinámicamente, pero mostramos el seleccionado si existe #}
{% if pais_id == pid %}selected{% endif %} {% for pais in paises %}
{% if cont_id and contid != cont_id %}style="display:none"{% endif %}> {% if pais_id == pais.id %}
{{ pnom }} <option value="{{ pais.id }}" selected>{{ pais.nombre }}</option>
</option> {% endif %}
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div style="align-self: flex-end;"> <div>
<button class="btn" type="submit">Filtrar</button> <button type="submit" class="btn">Filtrar</button>
</div> </div>
</div>
<script type="application/json" id="paises-data">{{ paises|tojson }}</script>
</form>
</div>
<div class="card">
<h2 style="margin-top:0;">Noticias recientes</h2>
<ul class="noticias-list">
{% for fecha, titulo, resumen, url, imagen_url, cat_nom, pais_nom, cont_nom in noticias %}
<li class="noticia-item">
<div class="noticia-texto">
<div style="color:#64748b; font-size:0.97em;"><b>{{ fecha }}</b></div>
<a href="{{ url }}" target="_blank"><strong>{{ titulo }}</strong></a><br>
<span>{{ resumen|safe }}</span><br>
<small style="color:#64748b;">
Categoría: {{ cat_nom or 'N/A' }} |
País: {{ pais_nom or 'N/A' }} |
Continente: {{ cont_nom or 'N/A' }}
</small>
</div>
{% if imagen_url %}
<div class="noticia-imagen">
<img src="{{ imagen_url }}" alt="img">
</div> </div>
{% endif %} </form>
</li> </div>
{% else %}
<li>No hay noticias que mostrar con estos filtros.</li>
{% endfor %}
</ul>
</div>
<a href="/feeds" class="top-link">← Volver a gestión de feeds</a> <div class="noticias-list">
{% for noticia in noticias %}
<article class="noticia-item">
{% if noticia.imagen_url %}
<div class="noticia-imagen">
<a href="{{ noticia.url }}" target="_blank" rel="noopener noreferrer"><img src="{{ noticia.imagen_url }}" alt="{{ noticia.titulo }}"></a>
</div>
{% endif %}
<div class="noticia-texto">
<h3><a href="{{ noticia.url }}" target="_blank" rel="noopener noreferrer">{{ noticia.titulo }}</a></h3>
<div class="noticia-meta">
<span>🗓️ {{ noticia.fecha.strftime('%d-%m-%Y %H:%M') if noticia.fecha else 'Fecha no disponible' }}</span> |
<span>{{ noticia.categoria or 'N/A' }}</span> |
<span>{{ noticia.pais or 'Global' }}</span>
</div>
<p>{{ noticia.resumen | striptags | safe_html | truncate(280) }}</p>
</div>
</article>
{% else %}
<div class="card" style="text-align:center;">
<p>No hay noticias que mostrar con los filtros seleccionados.</p>
</div>
{% endfor %}
</div>
<script> <script type="application/json" id="paises-data">{{ paises | tojson }}</script>
// Filtra países según continente seleccionado, robusto con tipos <script>
function filtrarPaisesPorContinente() { const todosLosPaises = JSON.parse(document.getElementById('paises-data').textContent);
const continenteId = document.getElementById('continente_id').value; const continenteSelect = document.getElementById('continente_id');
const paises = JSON.parse(document.getElementById('paises-data').textContent); const paisSelect = document.getElementById('pais_id');
const selectPais = document.getElementById('pais_id'); const paisSeleccionadoId = '{{ pais_id or '' }}';
selectPais.innerHTML = '';
// Opción N/A siempre presente function filtrarPaises() {
const optionNA = document.createElement('option'); const continenteId = continenteSelect.value;
optionNA.value = ''; // Guardamos el valor actual por si necesitamos restaurarlo
optionNA.textContent = '— País —'; const valorActualPais = paisSelect.value;
selectPais.appendChild(optionNA);
paises.forEach(([id, nombre, contId]) => { paisSelect.innerHTML = '<option value="">— Todos —</option>'; // Opción por defecto
// Convertimos ambos a string y número para máxima compatibilidad
if (!continenteId || contId == continenteId || contId == Number(continenteId)) { if (todosLosPaises) {
const opt = document.createElement('option'); todosLosPaises.forEach(pais => {
opt.value = id; if (!continenteId || pais.continente_id == continenteId) {
opt.textContent = nombre; const option = document.createElement('option');
selectPais.appendChild(opt); option.value = pais.id;
option.textContent = pais.nombre;
// Si el ID del país coincide con el que estaba seleccionado, lo volvemos a seleccionar
if (pais.id == valorActualPais || pais.id == paisSeleccionadoId) {
option.selected = true;
}
paisSelect.appendChild(option);
}
});
}
} }
});
}
// Al cargar la página, aplicar filtro si hay continente seleccionado // Ejecutamos la función una vez al cargar la página para inicializar el select de países
window.addEventListener('DOMContentLoaded', () => { // según el continente que ya esté seleccionado.
filtrarPaisesPorContinente(); document.addEventListener('DOMContentLoaded', filtrarPaises);
}); </script>
</script>
{% endblock %} {% endblock %}