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

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

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

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

@ -1,107 +1,112 @@
{% extends "base.html" %}
{% block title %}Últimas Noticias RSS{% endblock %}
{% block content %}
<h1>Últimas Noticias Recopiladas</h1>
<a href="/feeds" class="top-link">⚙️ Gestionar feeds RSS</a>
<header>
<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">
<form method="get" action="">
<div style="display: flex; flex-wrap: wrap; gap: 12px;">
<div style="flex:1;">
<h2>Filtrar Noticias</h2>
<form method="get" action="{{ url_for('home') }}">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; align-items: flex-end;">
<div>
<label for="categoria_id">Categoría</label>
<select name="categoria_id" id="categoria_id">
<option value="">Categoría</option>
{% for cid, cnom in categorias %}
<option value="{{ cid }}" {% if cat_id == cid %}selected{% endif %}>{{ cnom }}</option>
<option value="">Todas</option>
{% for cat in categorias %}
<option value="{{ cat.id }}" {% if cat_id == cat.id %}selected{% endif %}>{{ cat.nombre }}</option>
{% endfor %}
</select>
</div>
<div style="flex:1;">
<div>
<label for="continente_id">Continente</label>
<select name="continente_id" id="continente_id" onchange="filtrarPaisesPorContinente()">
<option value="">Continente</option>
{% for coid, conom in continentes %}
<option value="{{ coid }}" {% if cont_id == coid %}selected{% endif %}>{{ conom }}</option>
<select name="continente_id" id="continente_id" onchange="filtrarPaises()">
<option value="">Todos</option>
{% for cont in continentes %}
<option value="{{ cont.id }}" {% if cont_id == cont.id %}selected{% endif %}>{{ cont.nombre }}</option>
{% endfor %}
</select>
</div>
<div style="flex:1;">
<div>
<label for="pais_id">País</label>
<select name="pais_id" id="pais_id">
<option value="">— País —</option>
{% for pid, pnom, contid in paises %}
<option value="{{ pid }}"
{% if pais_id == pid %}selected{% endif %}
{% if cont_id and contid != cont_id %}style="display:none"{% endif %}>
{{ pnom }}
</option>
<option value="">— Todos —</option>
{# El JavaScript llenará esto dinámicamente, pero mostramos el seleccionado si existe #}
{% for pais in paises %}
{% if pais_id == pais.id %}
<option value="{{ pais.id }}" selected>{{ pais.nombre }}</option>
{% endif %}
{% endfor %}
</select>
</div>
<div style="align-self: flex-end;">
<button class="btn" type="submit">Filtrar</button>
<div>
<button type="submit" class="btn">Filtrar</button>
</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="noticias-list">
{% for noticia in noticias %}
<article class="noticia-item">
{% if noticia.imagen_url %}
<div class="noticia-imagen">
<img src="{{ imagen_url }}" alt="img">
<a href="{{ noticia.url }}" target="_blank" rel="noopener noreferrer"><img src="{{ noticia.imagen_url }}" alt="{{ noticia.titulo }}"></a>
</div>
{% endif %}
</li>
<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 %}
<li>No hay noticias que mostrar con estos filtros.</li>
<div class="card" style="text-align:center;">
<p>No hay noticias que mostrar con los filtros seleccionados.</p>
</div>
{% endfor %}
</ul>
</div>
<a href="/feeds" class="top-link">← Volver a gestión de feeds</a>
<script type="application/json" id="paises-data">{{ paises | tojson }}</script>
<script>
// Filtra países según continente seleccionado, robusto con tipos
function filtrarPaisesPorContinente() {
const continenteId = document.getElementById('continente_id').value;
const paises = JSON.parse(document.getElementById('paises-data').textContent);
const selectPais = document.getElementById('pais_id');
selectPais.innerHTML = '';
// Opción N/A siempre presente
const optionNA = document.createElement('option');
optionNA.value = '';
optionNA.textContent = '— País —';
selectPais.appendChild(optionNA);
paises.forEach(([id, nombre, contId]) => {
// Convertimos ambos a string y número para máxima compatibilidad
if (!continenteId || contId == continenteId || contId == Number(continenteId)) {
const opt = document.createElement('option');
opt.value = id;
opt.textContent = nombre;
selectPais.appendChild(opt);
const todosLosPaises = JSON.parse(document.getElementById('paises-data').textContent);
const continenteSelect = document.getElementById('continente_id');
const paisSelect = document.getElementById('pais_id');
const paisSeleccionadoId = '{{ pais_id or '' }}';
function filtrarPaises() {
const continenteId = continenteSelect.value;
// Guardamos el valor actual por si necesitamos restaurarlo
const valorActualPais = paisSelect.value;
paisSelect.innerHTML = '<option value="">— Todos —</option>'; // Opción por defecto
if (todosLosPaises) {
todosLosPaises.forEach(pais => {
if (!continenteId || pais.continente_id == continenteId) {
const option = document.createElement('option');
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
window.addEventListener('DOMContentLoaded', () => {
filtrarPaisesPorContinente();
});
// Ejecutamos la función una vez al cargar la página para inicializar el select de países
// según el continente que ya esté seleccionado.
document.addEventListener('DOMContentLoaded', filtrarPaises);
</script>
{% endblock %}