Initial clean commit

This commit is contained in:
jlimolina 2026-01-13 13:39:51 +01:00
commit 6784d81c2c
141 changed files with 25219 additions and 0 deletions

128
templates/_feeds_table.html Normal file
View file

@ -0,0 +1,128 @@
<!-- Tabla -->
<div id="feeds-table-container" class="feed-body" style="padding: 0;">
<div class="mt-2" style="margin: 10px 15px;">
{% set activos = total_feeds - feeds_caidos %}
<strong style="color: #27ae60;">{{ activos }} Activos</strong>
<span class="text-muted" style="margin-left: 5px;">(de {{ total_feeds }} Feeds)</span>
{% if filtro_pais_id or filtro_categoria_id or filtro_estado %}
<span class="text-muted" style="font-size:0.9em; margin-left: 10px;">(con filtros aplicados)</span>
{% endif %}
</div>
<table style="width:100%; border-collapse: collapse;">
<thead>
<tr style="background-color: rgba(0,0,0,0.05);">
<th style="padding: 12px 15px; text-align: left;">Nombre</th>
<th style="padding: 12px 15px; text-align: left;">Categoría</th>
<th style="padding: 12px 15px; text-align: left;">País</th>
<th style="padding: 12px 15px; text-align: center;">Noticias</th>
<th style="padding: 12px 15px; text-align: center;">Estado</th>
<th style="padding: 12px 15px; text-align: center;">Fallos</th>
<th style="padding: 12px 15px; text-align: right;">Acciones</th>
</tr>
</thead>
<tbody>
{% for feed in feeds %}
<tr {% if feed.fallos and feed.fallos> 0 %}style="background-color: rgba(192,57,43,0.05);" {% endif %}>
<td style="padding: 12px 15px; border-top: 1px solid var(--border-color);">
<a href="{{ feed.url }}" target="_blank" title="{{ feed.url }}">{{ feed.nombre }}</a>
</td>
<td style="padding: 12px 15px; border-top: 1px solid var(--border-color);">
{{ feed.categoria or 'N/A' }}
</td>
<td style="padding: 12px 15px; border-top: 1px solid var(--border-color);">
{{ feed.pais or 'Global' }}
</td>
<td style="padding: 12px 15px; text-align:center; border-top: 1px solid var(--border-color);">
<span class="badge"
style="background: rgba(52, 152, 219, 0.1); color: #3498db; padding: 2px 8px; border-radius: 10px;">
{{ feed.noticias_count or 0 }}
</span>
</td>
<td style="padding: 12px 15px; text-align: center; border-top: 1px solid var(--border-color);">
{% if not feed.activo %}
<span style="color: #c0392b; font-weight: bold;" title="Inactivo">KO</span>
{% elif feed.fallos and feed.fallos >= 5 %}
<span style="color: #e67e22; font-weight: bold; cursor: help;"
title="{{ feed.last_error or (feed.fallos ~ ' fallos') }}">⚠️</span>
{% elif feed.fallos and feed.fallos > 0 %}
<span style="color: #f39c12; font-weight: bold; cursor: help;"
title="{{ feed.last_error or (feed.fallos ~ ' fallos') }}">OK</span>
{% else %}
<span style="color: #27ae60; font-weight: bold;">OK</span>
{% endif %}
</td>
<td style="padding: 12px 15px; text-align:center; border-top: 1px solid var(--border-color);">
{{ feed.fallos or 0 }}
</td>
<td style="padding: 12px 15px; text-align:right; border-top: 1px solid var(--border-color);">
<a href="{{ url_for('feeds.edit_feed', feed_id=feed.id) }}" class="btn btn-small btn-info">
<i class="fas fa-edit"></i>
</a>
<a href="{{ url_for('feeds.delete_feed', feed_id=feed.id) }}" class="btn btn-small btn-danger"
onclick="return confirm('¿Estás seguro?')">
<i class="fas fa-trash"></i>
</a>
{% if not feed.activo %}
<a href="{{ url_for('feeds.reactivar_feed', feed_id=feed.id) }}" class="btn btn-small">
<i class="fas fa-sync-alt"></i>
</a>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="6" style="padding:20px; text-align:center;">
No hay feeds para mostrar.
<a href="{{ url_for('feeds.add_feed') }}">Añade el primero</a>.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Paginación -->
{% if total_pages > 1 %}
<nav class="pagination">
{% if page > 1 %}
<a href="{{ url_for('feeds.list_feeds',
page=page-1,
pais_id=filtro_pais_id,
categoria_id=filtro_categoria_id,
estado=filtro_estado) }}" class="page-link"
onclick="handlePageClick(event, this.href)">&laquo; Anterior</a>
{% endif %}
{% for p in range(1, total_pages + 1) %}
{% if p == page %}
<a href="#" class="page-link active">{{ p }}</a>
{% else %}
<a href="{{ url_for('feeds.list_feeds',
page=p,
pais_id=filtro_pais_id,
categoria_id=filtro_categoria_id,
estado=filtro_estado) }}" class="page-link"
onclick="handlePageClick(event, this.href)">{{ p }}</a>
{% endif %}
{% endfor %}
{% if page < total_pages %} <a href="{{ url_for('feeds.list_feeds',
page=page+1,
pais_id=filtro_pais_id,
categoria_id=filtro_categoria_id,
estado=filtro_estado) }}" class="page-link" onclick="handlePageClick(event, this.href)">
Siguiente &raquo;</a>
{% endif %}
</nav>
{% endif %}

View file

@ -0,0 +1,79 @@
{% for n in noticias %}
{% if n.traduccion_id %}
{% set detalle_url = url_for('noticia.noticia', tr_id=n.traduccion_id) %}
{% else %}
{% set detalle_url = url_for('noticia.noticia', id=n.id) %}
{% endif %}
<article class="noticia-card">
<div class="noticia-card-image-wrapper">
<a href="{{ detalle_url }}">
{% if n.imagen_url %}
<img src="{{ n.imagen_url }}" alt="{{ n.titulo }}" loading="lazy"
onerror="this.style.display='none'; this.parentElement.querySelector('.no-image-placeholder').style.display='flex';">
<div class="no-image-placeholder" style="display:none;"></div>
{% else %}
<div class="no-image-placeholder"></div>
{% endif %}
</a>
</div>
<div class="noticia-card-content">
<div class="noticia-meta">
{{ n.fuente_nombre }}
{% if n.fecha %} &bull; {{ n.fecha|format_date }}{% endif %}
{% if n.pais %} &bull; {{ n.pais }}{% endif %}
</div>
<h3>
<a href="{{ detalle_url }}">
{% if use_tr and n.tiene_traduccion %}
{{ n.titulo_traducido }}
{% else %}
{{ n.titulo_original or n.titulo }}
{% endif %}
</a>
</h3>
<div class="noticia-summary">
{% if use_tr and n.tiene_traduccion %}
{{ (n.resumen_traducido or '') | striptags | truncate(200) }}
{% else %}
{{ (n.resumen_original or n.resumen) | striptags | truncate(200) }}
{% endif %}
</div>
<div class="noticia-actions">
<button class="btn-fav" data-id="{{ n.id }}" onclick="toggleFav(this)" title="Guardar">
<i class="far fa-star"></i>
</button>
<a href="{{ detalle_url }}" class="btn btn-sm">Leer más</a>
</div>
</div>
</article>
{% else %}
<div style="grid-column: 1 / -1; text-align: center; padding: 50px;">
<p>No hay noticias para mostrar.</p>
</div>
{% endfor %}
{# Pagination Logic #}
{% if total_pages and total_pages > 1 %}
<div
style="grid-column: 1 / -1; margin-top: 30px; text-align: center; padding-top: 20px; border-top: 1px solid var(--border-color);">
{% set current = page %}
{% if current > 1 %}
<button class="btn" data-page="{{ current - 1 }}"
onclick="setPage(this.getAttribute('data-page')); cargarNoticias(true);">Newer</button>
{% endif %}
<span style="margin: 0 15px; font-weight: bold; font-family: var(--secondary-font);">
Page {{ current }} of {{ total_pages }}
</span>
{% if current < total_pages %} <button class="btn" data-page="{{ current + 1 }}"
onclick="setPage(this.getAttribute('data-page')); cargarNoticias(true);">
Older</button>
{% endif %}
</div>
{% endif %}

183
templates/account.html Normal file
View file

@ -0,0 +1,183 @@
{% extends "base.html" %}
{% block title %}Tu Cuenta - {{ user.username }}{% endblock %}
{% block content %}
<div style="max-width: 900px; margin: 20px auto;">
<h2 style="margin-bottom: 30px;"><i class="fas fa-user-circle"></i> Tu Cuenta</h2>
<!-- User Info Card -->
<div style="background: #f9f9f9; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
<h3 style="margin-top: 0; color: #6c63ff;">Información del Perfil</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px;">
<div style="text-align: center; margin-bottom: 15px;">
{% if user.avatar_url %}
<img src="{{ user.avatar_url }}" alt="Avatar" style="width: 120px; height: 120px; object-fit: cover; border-radius: 50%; border: 3px solid #6c63ff; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
{% else %}
<div style="width: 120px; height: 120px; background: #e0e0e0; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto; color: #888; font-size: 50px;">
<i class="fas fa-user"></i>
</div>
{% endif %}
<form action="{{ url_for('account.upload_avatar') }}" method="post" enctype="multipart/form-data" style="margin-top: 15px;">
<input type="file" name="avatar" id="avatar" accept="image/*" style="display: none;" onchange="this.form.submit()">
<label for="avatar" style="cursor: pointer; padding: 6px 12px; border: 1px solid #6c63ff; color: #6c63ff; background: transparent; border-radius: 4px; font-size: 14px; transition: all 0.2s;">
<i class="fas fa-camera"></i> Cambiar foto
</label>
</form>
</div>
</div>
<div>
<strong>Usuario:</strong> {{ user.username }}
</div>
<div>
<strong>Email:</strong> {{ user.email }}
</div>
<div>
<strong>Miembro desde:</strong> {{ user.created_at.strftime('%d/%m/%Y') }}
</div>
<div>
<strong>Último acceso:</strong> {{ user.last_login.strftime('%d/%m/%Y %H:%M') if user.last_login else
'N/A' }}
</div>
</div>
</div>
<!-- Statistics Card -->
<div style="background: #f9f9f9; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
<h3 style="margin-top: 0; color: #6c63ff;">Estadísticas</h3>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px;">
<div style="text-align: center; padding: 15px; background: white; border-radius: 8px;">
<div style="font-size: 32px; font-weight: bold; color: #6c63ff;">{{ favorites_count }}</div>
<div style="color: #666; margin-top: 5px;">Favoritos guardados</div>
</div>
<div style="text-align: center; padding: 15px; background: white; border-radius: 8px;">
<div style="font-size: 32px; font-weight: bold; color: #6c63ff;">{{ searches_count }}</div>
<div style="color: #666; margin-top: 5px;">Búsquedas realizadas</div>
</div>
</div>
</div>
<!-- Recent Searches -->
{% if recent_searches %}
<div style="background: #f9f9f9; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
<h3 style="margin-top: 0; color: #6c63ff;">Búsquedas Recientes</h3>
<div style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="border-bottom: 2px solid #ddd;">
<th style="padding: 10px; text-align: left;">Búsqueda</th>
<th style="padding: 10px; text-align: center;">Resultados</th>
<th style="padding: 10px; text-align: right;">Fecha</th>
</tr>
</thead>
<tbody>
{% for search in recent_searches %}
<tr style="border-bottom: 1px solid #eee;">
<td style="padding: 10px;">
<a href="/api/search?q={{ search.query | urlencode }}"
style="color: #6c63ff; text-decoration: none;">
{{ search.query }}
</a>
</td>
<td style="padding: 10px; text-align: center;">{{ search.results_count }}</td>
<td style="padding: 10px; text-align: right; color: #666; font-size: 14px;">
{{ search.searched_at.strftime('%d/%m/%Y %H:%M') }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if searches_count > 10 %}
<div style="text-align: center; margin-top: 15px;">
<a href="{{ url_for('account.search_history') }}"
style="color: #6c63ff; text-decoration: none; font-weight: 500;">
Ver historial completo →
</a>
</div>
{% endif %}
</div>
{% endif %}
<!-- Recent Favorites -->
{% if recent_favorites %}
<div style="background: #f9f9f9; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
<h3 style="margin-top: 0; color: #6c63ff;">Favoritos Recientes</h3>
<div style="display: grid; gap: 15px;">
{% for noticia in recent_favorites %}
<div style="display: flex; gap: 15px; padding: 15px; background: white; border-radius: 8px;">
{% if noticia.imagen_url %}
<img src="{{ noticia.imagen_url }}" alt=""
style="width: 100px; height: 70px; object-fit: cover; border-radius: 5px;">
{% endif %}
<div style="flex: 1;">
{% if noticia.traduccion_id %}
<a href="/noticia?tr_id={{ noticia.traduccion_id }}"
style="color: #333; text-decoration: none; font-weight: 500;">
{{ noticia.titulo_trad or noticia.titulo }}
</a>
{% else %}
<a href="/noticia?id={{ noticia.id }}"
style="color: #333; text-decoration: none; font-weight: 500;">
{{ noticia.titulo }}
</a>
{% endif %}
<div style="color: #666; font-size: 12px; margin-top: 5px;">
Guardado: {{ noticia.created_at.strftime('%d/%m/%Y') }}
</div>
</div>
</div>
{% endfor %}
</div>
<div style="text-align: center; margin-top: 15px;">
<a href="{{ url_for('favoritos.view_favorites') }}"
style="color: #6c63ff; text-decoration: none; font-weight: 500;">
Ver todos los favoritos →
</a>
</div>
</div>
{% endif %}
<!-- Account Actions -->
<div style="background: #f9f9f9; padding: 25px; border-radius: 10px;">
<h3 style="margin-top: 0; color: #6c63ff;">Acciones</h3>
<!-- Change Password Form -->
<details style="margin-bottom: 20px;">
<summary style="cursor: pointer; font-weight: 500; padding: 10px; background: white; border-radius: 5px;">
<i class="fas fa-key"></i> Cambiar contraseña
</summary>
<form method="post" action="{{ url_for('account.change_password') }}"
style="margin-top: 15px; padding: 15px; background: white; border-radius: 5px;">
<div style="margin-bottom: 15px;">
<label for="current_password" style="display: block; margin-bottom: 5px;">Contraseña actual</label>
<input type="password" id="current_password" name="current_password" required
style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">
</div>
<div style="margin-bottom: 15px;">
<label for="new_password" style="display: block; margin-bottom: 5px;">Nueva contraseña</label>
<input type="password" id="new_password" name="new_password" required minlength="6"
style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">
</div>
<div style="margin-bottom: 15px;">
<label for="new_password_confirm" style="display: block; margin-bottom: 5px;">Confirmar nueva
contraseña</label>
<input type="password" id="new_password_confirm" name="new_password_confirm" required minlength="6"
style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">
</div>
<button type="submit"
style="padding: 10px 20px; background: #6c63ff; color: white; border: none; border-radius: 5px; cursor: pointer;">
Actualizar contraseña
</button>
</form>
</details>
<form method="post" action="{{ url_for('auth.logout') }}">
<button type="submit"
style="padding: 12px 24px; background: #dc3545; color: white; border: none; border-radius: 5px; cursor: pointer; font-size: 14px;">
<i class="fas fa-sign-out-alt"></i> Cerrar sesión
</button>
</form>
</div>
</div>
{% endblock %}

102
templates/add_feed.html Normal file
View file

@ -0,0 +1,102 @@
{% extends "base.html" %}
{% block title %}Añadir Feed{% endblock %}
{% block content %}
<div class="card feed-detail-card"
style="padding: 40px; border-radius: 15px; background-color: var(--glass-bg); box-shadow: 0 10px 30px rgba(0,0,0,0.05); backdrop-filter: blur(10px);">
<h1
style="font-family: var(--primary-font); font-weight: 700; margin-bottom: 30px; border-bottom: 2px solid var(--accent-color); display: inline-block; padding-bottom: 10px;">
Añadir Feed
</h1>
<form action="{{ url_for('feeds.add_feed') }}" method="post" id="addFeedForm" class="form-grid">
<!-- Nombre -->
<div class="floating-label-group">
<input type="text" id="nombre" name="nombre" placeholder=" " required>
<label for="nombre">Nombre del feed</label>
</div>
<!-- Descripción -->
<div class="floating-label-group">
<textarea id="descripcion" name="descripcion" placeholder=" " rows="3"></textarea>
<label for="descripcion">Descripción (opcional)</label>
</div>
<!-- URL -->
<div class="floating-label-group">
<input type="url" id="url" name="url" placeholder=" " required>
<label for="url">URL del feed</label>
</div>
<div class="form-row" style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
<!-- Categoria (Searchable) -->
<div class="form-group">
<label for="categoria_id"
style="display: block; margin-bottom: 8px; font-weight: 600;">Categoría</label>
<select id="categoria_id" name="categoria_id" class="searchable"
placeholder="Selecciona una categoría...">
<option value="">— Sin categoría —</option>
{% for c in categorias %}
<option value="{{ c.id }}">{{ c.nombre }}</option>
{% endfor %}
</select>
</div>
<!-- Pais (Searchable) -->
<div class="form-group">
<label for="pais_id" style="display: block; margin-bottom: 8px; font-weight: 600;">País</label>
<select id="pais_id" name="pais_id" class="searchable" placeholder="Selecciona un país...">
<option value="">Global</option>
{% for p in paises %}
<option value="{{ p.id }}">{{ p.nombre }}</option>
{% endfor %}
</select>
</div>
</div>
<!-- Idioma & Submit -->
<div class="form-row"
style="display: grid; grid-template-columns: 1fr 2fr; gap: 20px; align-items: end; margin-top: 20px;">
<div class="floating-label-group" style="margin-bottom: 0;">
<input type="text" id="idioma" name="idioma" placeholder=" " maxlength="5">
<label for="idioma">Idioma (ej: es)</label>
</div>
<div class="form-actions" style="text-align: right;">
<a href="{{ url_for('feeds.list_feeds') }}" class="btn btn-secondary"
style="margin-right: 10px; background: transparent; color: var(--text-color); border: 1px solid var(--border-color);">
Cancelar
</a>
<button class="btn btn-primary" type="submit" id="submitBtn">
<i class="fas fa-plus"></i> Añadir Feed
</button>
</div>
</div>
</form>
</div>
<script>
document.addEventListener("DOMContentLoaded", function () {
// Real-time URL Validation
const urlInput = document.getElementById('url');
const submitBtn = document.getElementById('submitBtn');
urlInput.addEventListener('input', function () {
if (this.value && this.validity.valid) {
this.style.borderColor = "#2ecc71"; // Green
} else if (this.value) {
this.style.borderColor = "#e74c3c"; // Red
} else {
this.style.borderColor = ""; // Reset
}
});
// Form Submit State
document.getElementById('addFeedForm').addEventListener('submit', function () {
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Guardando...';
});
});
</script>
{% endblock %}

57
templates/add_feeds.html Normal file
View file

@ -0,0 +1,57 @@
{% extends "base.html" %}
{% block title %}Añadir Nuevo Feed{% endblock %}
{% block content %}
<header>
<h1>Añadir Nuevo Feed</h1>
<p class="subtitle">Introduce los detalles de la nueva fuente de noticias RSS.</p>
<a href="{{ url_for('dashboard') }}" class="top-link" style="margin-top:15px;">← Volver al Dashboard</a>
</header>
<div class="form-section">
<form action="{{ url_for('add_feed') }}" method="post" autocomplete="off">
<div>
<label for="nombre">Nombre del feed</label>
<input id="nombre" name="nombre" type="text" placeholder="Ej: Noticias de Tecnología" required>
</div>
<div style="margin-top:15px;">
<label for="url">URL del RSS</label>
<input id="url" name="url" type="url" placeholder="https://ejemplo.com/rss" required>
</div>
<div style="margin-top:15px;">
<label for="descripcion">Descripción</label>
<textarea id="descripcion" name="descripcion" rows="2" placeholder="Breve descripción del contenido del feed"></textarea>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 15px; margin-top: 15px;">
<div>
<label for="categoria_id">Categoría</label>
<select id="categoria_id" name="categoria_id" required>
<option value="">— Elige categoría —</option>
{% for cat in categorias %}
<option value="{{ cat.id }}">{{ cat.nombre }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="pais_id">País</label>
<select name="pais_id" id="pais_id">
<option value="">— Global / No aplica —</option>
{% for pais in paises %}
<option value="{{ pais.id }}">{{ pais.nombre }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="idioma">Idioma (código)</label>
<input id="idioma" name="idioma" type="text" maxlength="2" placeholder="ej: es, en">
</div>
</div>
<button type="submit" class="btn" style="margin-top: 25px; width: 100%;">Añadir Feed</button>
</form>
</div>
{% endblock %}

59
templates/add_url.html Normal file
View file

@ -0,0 +1,59 @@
{% extends "base.html" %}
{% block title %}Añadir Noticia desde URL{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header bg-info text-white">
<h4 class="mb-0">Añadir Noticia desde URL</h4>
</div>
<div class="card-body">
<p class="card-text text-muted">Pega la URL de un artículo de noticias. El sistema intentará extraer el título, resumen e imagen automáticamente.</p>
<form action="{{ url_for('add_url') }}" method="post" class="mt-3">
<!-- Campo para la URL -->
<div class="mb-3">
<label for="url" class="form-label"><strong>URL de la Noticia</strong></label>
<input type="url" class="form-control" id="url" name="url" required placeholder="https://ejemplo.com/noticia-a-scrapear">
</div>
<!-- Selector de Categoría -->
<div class="mb-3">
<label for="categoria_id" class="form-label"><strong>Categoría</strong></label>
<select class="form-select" id="categoria_id" name="categoria_id" required>
<option value="" disabled selected>-- Selecciona una categoría --</option>
{% for categoria in categorias %}
<option value="{{ categoria.id }}">{{ categoria.nombre }}</option>
{% endfor %}
</select>
</div>
<!-- Selector de País -->
<div class="mb-3">
<label for="pais_id" class="form-label"><strong>País</strong></label>
<select class="form-select" id="pais_id" name="pais_id" required>
<option value="" disabled selected>-- Selecciona un país --</option>
{% for pais in paises %}
<option value="{{ pais.id }}">{{ pais.nombre }}</option>
{% endfor %}
</select>
</div>
<!-- Botones de Acción -->
<div class="d-flex justify-content-end pt-3">
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary me-2">Cancelar</a>
<button type="submit" class="btn btn-primary">Añadir Noticia</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,74 @@
{% extends "base.html" %}
{% block title %}Añadir Fuente URL{% endblock %}
{% block content %}
<h1>Añadir Fuente URL</h1>
<div style="margin-bottom: 25px;">
<div class="tabs" style="display: flex; gap: 10px; border-bottom: 2px solid #ddd; padding-bottom: 1px;">
<button class="tab-btn active" onclick="switchTab('manual')"
style="padding: 10px 20px; border: none; background: #fff; cursor: pointer; border-bottom: 3px solid #007bff; color: #007bff; font-weight: bold;">
<i class="fas fa-edit"></i> Añadir Manualmente
</button>
<a href="{{ url_for('feeds.discover_feed') }}" class="tab-btn"
style="padding: 10px 20px; border: none; background: #f8f9fa; cursor: pointer; text-decoration: none; color: #555; display: flex; align-items: center; gap: 8px;">
<i class="fas fa-search"></i> Analizar Web (Descubrimiento Automático)
<span class="badge"
style="background: #e9ecef; color: #555; font-size: 10px; padding: 2px 6px; border-radius: 4px;">RECOMENDADO</span>
</a>
</div>
</div>
<div class="card" id="manual-tab">
<div
style="margin-bottom: 20px; padding: 15px; background: #e3f2fd; border-radius: 8px; border-left: 4px solid #1976D2;">
<i class="fas fa-info-circle" style="color: #1976D2;"></i>
Utiliza esta opción para añadir una fuente de URL monitorizada manualmente. Si quieres buscar todos los feeds
RSS dentro de un sitio web, usa la pestaña <strong>Analizar Web</strong>.
</div>
<form method="post" action="{{ url_for('urls.add_url_source') }}" autocomplete="off">
<label for="nombre">Nombre</label>
<input id="nombre" name="nombre" type="text" required placeholder="Ej. El País">
<label for="url" style="margin-top:15px;">URL</label>
<input id="url" name="url" type="url" required placeholder="https://elpais.com">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;margin-top:15px;">
<div>
<label for="categoria_id">Categoría</label>
<select id="categoria_id" name="categoria_id">
<option value="">— Sin categoría —</option>
{% for c in categorias %}
<option value="{{ c.id }}">{{ c.nombre }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="pais_id">País</label>
<select id="pais_id" name="pais_id">
<option value="">— Global —</option>
{% for p in paises %}
<option value="{{ p.id }}">{{ p.nombre }}</option>
{% endfor %}
</select>
</div>
</div>
<label for="idioma" style="margin-top:15px;">Idioma (2 letras)</label>
<input id="idioma" name="idioma" type="text" maxlength="2" value="es">
<div style="margin-top:20px;display:flex;gap:10px;justify-content:flex-end;">
<a href="{{ url_for('urls.manage_urls') }}" class="btn btn-secondary">Cancelar</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Guardar Fuente
</button>
</div>
</form>
</div>
<a href="{{ url_for('urls.manage_urls') }}" class="top-link">← Volver</a>
{% endblock %}

448
templates/base.html Normal file
View file

@ -0,0 +1,448 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<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&family=Roboto:wght@300;400;500;700&display=swap"
rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}?v=10">
<!-- TomSelect CSS -->
<link href="https://cdn.jsdelivr.net/npm/tom-select@2.2.2/dist/css/tom-select.css" rel="stylesheet">
<style>
.badge {
display: inline-block;
font-size: .75rem;
line-height: 1;
padding: .35rem .5rem;
border-radius: .5rem;
background: var(--secondary-color, #6c63ff);
color: #fff;
margin-left: .4rem;
}
.switch {
position: relative;
display: inline-block;
width: 42px;
height: 22px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #ccc;
transition: .2s;
border-radius: 999px;
}
.slider:before {
position: absolute;
content: "";
height: 16px;
width: 16px;
left: 3px;
bottom: 3px;
background: #fff;
transition: .2s;
border-radius: 50%;
}
.switch input:checked+.slider {
background: var(--secondary-color, #6c63ff);
}
.switch input:checked+.slider:before {
transform: translateX(20px);
}
</style>
</head>
<body class="theme-rss2">
<div class="container">
<!-- Mobile/Global Nav Elements -->
<div class="mobile-header">
<div class="logo-mobile">
<a href="/">THE DAILY FEED</a>
</div>
<button class="mobile-menu-toggle" id="mobile-menu-toggle" aria-label="Abrir menú">
<i class="fas fa-bars"></i>
</button>
</div>
<div class="nav-overlay" id="nav-overlay"></div>
<!-- Desktop Header -->
<header class="desktop-header">
<div class="header-top-row">
<div class="header-title-wrapper">
<h1><a href="/" style="text-decoration: none; color: inherit;">THE DAILY FEED</a></h1>
</div>
<div class="header-user-menu">
<div class="dropdown">
{% if session.get('user_id') %}
<button class="nav-link dropbtn user-menu-large">
{% if session.get('avatar_url') %}
<img src="{{ session.get('avatar_url') }}" alt="Avatar"
style="width: 24px; height: 24px; border-radius: 50%; vertical-align: middle; object-fit: cover; margin-right: 5px;">
{% else %}
<i class="fas fa-user-circle"></i>
{% endif %}
{{ session.get('username') }} <i class="fas fa-chevron-down"></i>
</button>
<div class="dropdown-content dropdown-right">
<a href="{{ url_for('account.index') }}"><i class="fas fa-user"></i> Tu Cuenta</a>
<a href="{{ url_for('favoritos.view_favorites') }}"><i class="fas fa-star"></i> Mis Favoritos</a>
<a href="{{ url_for('account.index', _anchor='search-history') }}"><i class="fas fa-history"></i>
Historial</a>
<div class="dropdown-divider"></div>
<form action="{{ url_for('auth.logout') }}" method="post" style="margin: 0;">
<button type="submit" class="dropdown-logout">
<i class="fas fa-sign-out-alt"></i> Cerrar Sesión
</button>
</form>
</div>
{% else %}
<button class="nav-link dropbtn user-menu-large">
<i class="fas fa-user"></i> Cuenta <i class="fas fa-chevron-down"></i>
</button>
<div class="dropdown-content dropdown-right">
<a href="{{ url_for('auth.login') }}"><i class="fas fa-sign-in-alt"></i> Iniciar Sesión</a>
<a href="{{ url_for('auth.register') }}"><i class="fas fa-user-plus"></i> Registrarse</a>
</div>
{% endif %}
</div>
</div>
</div>
<div class="header-date">
<span id="current-date-header"></span> |
MADRID: <span id="madrid-time">--:--:--</span>
</div>
</header>
<nav class="main-nav" id="main-nav">
<div class="nav-content-wrapper">
<div class="nav-left">
<a href="{{ url_for('home.home') }}" class="nav-link">Inicio</a>
<div class="dropdown">
<button class="nav-link dropbtn">Noticias <i class="fas fa-chevron-down"></i></button>
<div class="dropdown-content">
<a href="{{ url_for('home.home') }}">Todas las Noticias</a>
<a href="{{ url_for('topics.monitor') }}">Temas</a>
<a href="{{ url_for('favoritos.view_favorites') }}">Favoritos</a>
</div>
</div>
<div class="dropdown">
<button class="nav-link dropbtn">Análisis <i class="fas fa-chevron-down"></i></button>
<div class="dropdown-content">
<a href="{{ url_for('stats.index') }}">Estadísticas</a>
<a href="{{ url_for('stats.entities_dashboard') }}">Monitor de Entidades</a>
<a href="{{ url_for('conflicts.index') }}">Conflictos</a>
</div>
</div>
<div class="dropdown">
<button class="nav-link dropbtn">Admin <i class="fas fa-chevron-down"></i></button>
<div class="dropdown-content">
<a href="{{ url_for('feeds.list_feeds') }}">Gestión de Feeds</a>
<a href="{{ url_for('feeds.discover_feed') }}"><i class="fas fa-search-plus"></i> Descubrir Feeds</a>
<a href="{{ url_for('urls.manage_urls') }}">Gestión de URLs</a>
<a href="{{ url_for('backup.restore_feeds') }}">Importar Feeds</a>
<a href="{{ url_for('backup.backup_feeds') }}">Exportar Feeds</a>
<a href="{{ url_for('config.config_home') }}">Configuración</a>
</div>
</div>
</div>
<div class="nav-right">
<button id="dark-mode-toggle" class="icon-btn" title="Cambiar tema">
<i class="fas fa-moon"></i>
</button>
</div>
</div>
</nav>
<script>
(function () {
const options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
const dateStr = new Date().toLocaleDateString('es-ES', options);
const dateHeader = document.getElementById('current-date-header');
if (dateHeader) dateHeader.textContent = dateStr;
function updateMadridTime() {
const now = new Date();
const timeString = now.toLocaleTimeString('es-ES', { timeZone: 'Europe/Madrid' });
const el = document.getElementById('madrid-time');
if (el) el.textContent = timeString;
}
setInterval(updateMadridTime, 1000);
updateMadridTime();
})();
</script>
{% 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>
<!-- TomSelect JS -->
<script src="https://cdn.jsdelivr.net/npm/tom-select@2.2.2/dist/js/tom-select.complete.min.js"></script>
<script>
// Global Tool to Init Selects
function initSearchableSelects(selector = 'select.searchable') {
document.querySelectorAll(selector).forEach((el) => {
if (!el.tomselect) {
new TomSelect(el, {
create: false,
sortField: { field: "text", direction: "asc" },
plugins: ['dropdown_input'],
maxItems: 1
});
}
});
}
// Dark Mode Toggle
const darkModeToggle = document.getElementById('dark-mode-toggle');
const icon = darkModeToggle.querySelector('i');
// Check saved preference
if (localStorage.getItem('darkMode') === 'true') {
document.body.classList.add('dark-mode');
icon.classList.replace('fa-moon', 'fa-sun');
}
darkModeToggle.addEventListener('click', () => {
document.body.classList.toggle('dark-mode');
const isDark = document.body.classList.contains('dark-mode');
localStorage.setItem('darkMode', isDark);
icon.classList.replace(isDark ? 'fa-moon' : 'fa-sun', isDark ? 'fa-sun' : 'fa-moon');
});
// ========== FAVORITES ==========
async function toggleFav(btn) {
const id = btn.dataset.id;
try {
const response = await fetch(`/favoritos/toggle/${id}`, { method: 'POST' });
if (response.ok) {
const data = await response.json();
btn.classList.toggle('active', data.is_favorite);
const i = btn.querySelector('i');
i.className = data.is_favorite ? 'fas fa-star' : 'far fa-star';
}
} catch (e) { console.error("Error favoritos", e); }
}
// Load saved favorites on page load
async function loadFavorites() {
try {
const response = await fetch('/favoritos/ids');
if (response.ok) {
const data = await response.json();
const favIds = new Set(data.ids);
document.querySelectorAll('.btn-fav').forEach(btn => {
if (favIds.has(btn.dataset.id)) {
btn.classList.add('active');
btn.querySelector('i').className = 'fas fa-star';
}
});
}
} catch (e) { /* ignore */ }
}
// Run on page load
if (document.querySelector('.btn-fav')) {
loadFavorites();
}
// ========== READ HISTORY ==========
const READ_STORAGE_KEY = 'readHistory';
const MAX_READ_ITEMS = 500;
function getReadHistory() {
try {
return JSON.parse(localStorage.getItem(READ_STORAGE_KEY)) || [];
} catch { return []; }
}
function markAsRead(id) {
const history = getReadHistory();
if (!history.includes(id)) {
history.unshift(id);
if (history.length > MAX_READ_ITEMS) history.pop();
localStorage.setItem(READ_STORAGE_KEY, JSON.stringify(history));
}
}
function applyReadStyles() {
const history = new Set(getReadHistory());
document.querySelectorAll('.noticia-card').forEach(card => {
const link = card.querySelector('a[href*="/noticia"]');
if (link) {
const href = link.getAttribute('href');
// Extract ID from URL
const match = href.match(/[?&](?:id|tr_id)=([^&]+)/);
if (match && history.has(match[1])) {
card.classList.add('is-read');
}
}
});
}
// Track clicks on news links
document.addEventListener('click', (e) => {
const link = e.target.closest('a[href*="/noticia"]');
if (link) {
const href = link.getAttribute('href');
const match = href.match(/[?&](?:id|tr_id)=([^&]+)/);
if (match) {
markAsRead(match[1]);
}
}
});
// ========== NOTIFICATIONS ==========
let lastCheck = new Date().toISOString();
async function checkNotifications() {
if (Notification.permission !== "granted") return;
try {
const response = await fetch(`/api/notifications/check?last_check=${lastCheck}`);
const data = await response.json();
if (data.has_news) {
lastCheck = data.timestamp;
new Notification("The Daily Feed", {
body: data.message,
icon: "/static/favicon.ico" // Assuming generic icon
});
} else {
// Update timestamp to now to avoid checking old news if server time drifts
lastCheck = new Date().toISOString();
}
} catch (e) {
console.error("Notification check failed", e);
}
}
// Request permission on load
if ("Notification" in window) {
if (Notification.permission === "default") {
// Add a small button or toast to ask for permission instead of auto-prompting which is annoying
// For this demo, we'll auto-check on first interaction if possible, or just log
console.log("Notifications available, waiting for permission.");
}
// Check every 60 seconds
setInterval(checkNotifications, 60000);
}
// Add bell icon to enable notifications if not granted
document.addEventListener('DOMContentLoaded', () => {
if (Notification.permission !== 'granted' && Notification.permission !== 'denied') {
const nav = document.querySelector('.main-nav');
const btn = document.createElement('button');
btn.className = 'nav-link';
btn.innerHTML = '<i class="fas fa-bell"></i>';
btn.style.background = 'none';
btn.style.border = 'none';
btn.style.cursor = 'pointer';
btn.title = 'Activar notificaciones';
btn.onclick = () => {
Notification.requestPermission().then(permission => {
if (permission === "granted") {
btn.style.display = 'none';
new Notification("The Daily Feed", { body: "¡Notificaciones activadas!" });
}
});
};
nav.appendChild(btn);
}
// Init Selects
initSearchableSelects();
});
// Apply read styles on load
applyReadStyles();
// ========== MOBILE NAVIGATION (BULLETPROOF) ==========
const mobileMenuToggle = document.getElementById('mobile-menu-toggle');
const mainNav = document.getElementById('main-nav');
const navOverlay = document.getElementById('nav-overlay');
function toggleMenu() {
const isOpen = mainNav.classList.toggle('active');
navOverlay.classList.toggle('active');
document.body.classList.toggle('no-scroll', isOpen);
const icon = mobileMenuToggle.querySelector('i');
icon.className = isOpen ? 'fas fa-times' : 'fas fa-bars';
}
if (mobileMenuToggle) {
mobileMenuToggle.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
toggleMenu();
};
}
if (navOverlay) {
navOverlay.onclick = toggleMenu;
}
// Interactive Dropdowns for Touch/Click
document.querySelectorAll('.dropbtn').forEach(btn => {
btn.onclick = (e) => {
if (window.innerWidth <= 768) {
e.preventDefault();
e.stopPropagation();
const dropdown = btn.parentNode;
const wasOpen = dropdown.classList.contains('is-open');
// Close other open ones
document.querySelectorAll('.dropdown.is-open').forEach(d => d.classList.remove('is-open'));
// Toggle current
if (!wasOpen) dropdown.classList.add('is-open');
}
};
});
// Close on escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && mainNav.classList.contains('active')) toggleMenu();
});
</script>
</body>
</html>

452
templates/config.html Normal file
View file

@ -0,0 +1,452 @@
{% extends "base.html" %}
{% block title %}Configuración{% endblock %}
{% block content %}
<div id="backup-overlay"
style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.9); z-index:9999; flex-direction:column; justify-content:center; align-items:center; color:white; padding: 2rem; text-align: center;">
<i class="fas fa-database fa-spin fa-3x" style="margin-bottom:20px; color: var(--accent-color);"></i>
<h2 id="backup-title">Preparando backup...</h2>
<div
style="width: 100%; max-width: 400px; background: #333; border-radius: 10px; height: 10px; margin: 20px 0; overflow: hidden;">
<div id="backup-progress-bar"
style="width: 0%; height: 100%; background: var(--accent-color); transition: width 0.3s;"></div>
</div>
<p id="backup-status-text">Calculando noticias...</p>
<button id="btn-close-backup" onclick="hideBackupLoading()" class="btn btn-secondary"
style="margin-top:20px; display: none;">Cerrar</button>
</div>
<script>
let isBackupRunning = false;
let autoReloadTimer = null;
function startAutoReload() {
if (!isBackupRunning) {
autoReloadTimer = setTimeout(() => location.reload(), 30000);
}
}
function stopAutoReload() {
if (autoReloadTimer) clearTimeout(autoReloadTimer);
}
function startBackup() {
isBackupRunning = true;
stopAutoReload();
document.getElementById('backup-overlay').style.display = 'flex';
document.getElementById('backup-title').innerText = "Iniciando Backup...";
document.getElementById('backup-progress-bar').style.width = '0%';
document.getElementById('btn-close-backup').style.display = 'none';
fetch('/config/backup/start')
.then(r => r.json())
.then(data => {
pollBackupStatus(data.task_id);
})
.catch(err => {
alert("Error al iniciar backup");
hideBackupLoading();
});
}
function pollBackupStatus(taskId) {
fetch(`/config/backup/status/${taskId}`)
.then(r => r.json())
.then(data => {
if (data.status === 'processing' || data.status === 'initializing') {
updateBackupUI(data);
setTimeout(() => pollBackupStatus(taskId), 2000);
} else if (data.status === 'completed') {
updateBackupUI(data);
document.getElementById('backup-title').innerText = "¡Backup Completado!";
document.getElementById('backup-status-text').innerText = "Iniciando descarga...";
window.location.href = `/config/backup/download/${taskId}`;
document.getElementById('btn-close-backup').style.display = 'block';
isBackupRunning = false;
startAutoReload();
} else if (data.status === 'error') {
alert("Error: " + data.error);
hideBackupLoading();
}
});
}
function updateBackupUI(data) {
if (data.total > 0) {
const percent = Math.round((data.progress / data.total) * 100);
document.getElementById('backup-progress-bar').style.width = percent + '%';
document.getElementById('backup-status-text').innerText = `Procesando: ${data.progress.toLocaleString()} / ${data.total.toLocaleString()} (${percent}%)`;
document.getElementById('backup-title').innerText = "Generando archivo ZIP...";
}
}
function hideBackupLoading() {
document.getElementById('backup-overlay').style.display = 'none';
isBackupRunning = false;
startAutoReload();
}
// Initialize auto-reload
startAutoReload();
</script>
<div class="config-page">
<h2><i class="fas fa-cog"></i> Configuración</h2>
<div class="config-grid">
<!-- Translator Card -->
<div class="config-card card-wide"
style="display: flex; align-items: center; justify-content: center; min-height: 120px;">
<a href="{{ url_for('config.translator_config') }}" class="btn btn-dark-outline"
style="font-size: 1.1rem; padding: 1rem 2rem;">
<i class="fas fa-robot"></i> Configurar Modelo
</a>
</div>
<!-- Backup Card -->
<div class="config-card">
<div class="card-header">
<div class="card-icon"><i class="fas fa-file-archive"></i></div>
</div>
<h3>Backup (ZIP)</h3>
<p>Exporta todas las noticias y traducciones en un archivo comprimido (ZIP) para ahorrar espacio.</p>
<button onclick="startBackup()" class="btn btn-dark" id="btn-start-backup">
<i class="fas fa-file-download"></i> Descargar ZIP (con progreso)
</button>
<div style="margin-top:10px; padding-top:10px; border-top:1px solid #eee;">
<small style="display:block; margin-bottom:5px; color:#666;">Metadatos:</small>
<div style="display:flex; gap:10px;">
<a href="{{ url_for('backup.export_paises') }}" class="btn btn-small btn-secondary_outline"
style="font-size:0.8em; padding:5px 10px;">
<i class="fas fa-file-csv"></i> Países
</a>
<a href="{{ url_for('backup.export_categorias') }}" class="btn btn-small btn-secondary_outline"
style="font-size:0.8em; padding:5px 10px;">
<i class="fas fa-file-csv"></i> Categorías
</a>
</div>
</div>
</div>
<!-- Restore Card -->
<div class="config-card">
<div class="card-header">
<div class="card-icon"><i class="fas fa-upload"></i></div>
</div>
<h3>Restaurar</h3>
<p>Importa datos desde un backup en formato <strong>JSON</strong> o <strong>ZIP</strong>.</p>
<a href="{{ url_for('config.restore_noticias') }}" class="btn btn-dark-outline">
<i class="fas fa-upload"></i> Subir Backup
</a>
</div>
</div>
</div>
<style>
.config-page h2 {
margin-bottom: 1.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
font-family: 'Playfair Display', 'Times New Roman', serif;
font-weight: 700;
letter-spacing: -0.02em;
}
/* Stats Banner */
.stats-banner {
display: flex;
align-items: center;
justify-content: center;
gap: 1.5rem;
padding: 1rem 1.5rem;
background: #111;
border-radius: 8px;
margin-bottom: 2rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.stat-item {
text-align: center;
}
.stat-value {
font-size: 1.75rem;
font-weight: 700;
color: #fff;
font-family: 'Poppins', sans-serif;
line-height: 1;
}
.stat-value.stat-warning {
color: #ffc107;
}
.stat-value.stat-processing {
color: #17a2b8;
}
.stat-value.stat-error {
color: #dc3545;
}
.stat-label {
font-size: 0.7rem;
color: rgba(255, 255, 255, 0.6);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: 0.25rem;
}
.stat-divider {
width: 1px;
height: 40px;
background: rgba(255, 255, 255, 0.15);
}
/* Cards Grid */
.config-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
}
.config-card {
background: var(--card-bg, #fff);
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
gap: 0.75rem;
transition: transform 0.2s, box-shadow 0.2s;
border-top: 3px solid #111;
}
.config-card.card-wide {
grid-column: span 2;
}
@media (max-width: 700px) {
.config-card.card-wide {
grid-column: span 1;
}
}
.config-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.card-icon {
font-size: 1.75rem;
color: #111;
}
.card-status {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #28a745;
font-weight: 600;
}
.card-status .pulse {
width: 8px;
height: 8px;
background: #28a745;
border-radius: 50%;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(1.2);
}
}
.config-card h3 {
margin: 0;
font-size: 1.25rem;
font-family: 'Playfair Display', 'Times New Roman', serif;
font-weight: 600;
}
.config-card p {
color: var(--text-muted, #666);
font-size: 0.9rem;
margin: 0;
line-height: 1.5;
}
/* Big Stats */
.big-stats {
display: flex;
gap: 2rem;
margin: 1rem 0;
padding: 1rem 0;
border-top: 1px solid var(--border-color, #eee);
border-bottom: 1px solid var(--border-color, #eee);
}
.big-stat {
text-align: center;
flex: 1;
}
.big-stat .big-number {
font-size: 2.5rem;
font-weight: 800;
color: #111;
font-family: 'Poppins', sans-serif;
line-height: 1;
}
.big-stat .big-label {
font-size: 0.75rem;
color: var(--text-muted, #666);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: 0.35rem;
}
.big-stat.highlight .big-number {
color: #28a745;
}
.card-values {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.card-values code {
background: #f4f4f4;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-family: 'JetBrains Mono', monospace;
}
/* Buttons - Black Theme */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
text-decoration: none;
text-transform: uppercase;
letter-spacing: 0.03em;
transition: all 0.2s;
}
.btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.btn-dark {
background: #111;
color: #fff;
}
.btn-dark:hover {
background: #333;
}
.btn-dark-outline {
background: transparent;
color: #111;
border: 2px solid #111;
}
.btn-dark-outline:hover {
background: #111;
color: #fff;
}
/* Dark Mode */
.dark-mode .config-card {
background: var(--card-bg-dark, #1e1e1e);
border-top-color: #fff;
}
.dark-mode .card-icon {
color: #fff;
}
.dark-mode .big-stat .big-number {
color: #fff;
}
.dark-mode .card-values code {
background: #333;
}
.dark-mode .stats-banner {
background: #000;
}
.dark-mode .btn-dark {
background: #fff;
color: #111;
}
.dark-mode .btn-dark-outline {
border-color: #fff;
color: #fff;
}
.dark-mode .btn-dark-outline:hover {
background: #fff;
color: #111;
}
/* Responsive */
@media (max-width: 600px) {
.stats-banner {
flex-wrap: wrap;
gap: 1rem;
}
.stat-divider {
display: none;
}
.stat-item {
flex: 0 0 45%;
}
.big-stats {
flex-direction: column;
gap: 1rem;
}
}
</style>
{% endblock %}

View file

@ -0,0 +1,160 @@
{% extends "base.html" %}
{% block title %}Restaurar Noticias{% endblock %}
{% block content %}
<div class="config-form-page">
<h2><i class="fas fa-upload"></i> Restaurar Noticias</h2>
<div class="restore-warning">
<i class="fas fa-exclamation-triangle"></i>
<p>Esta acción importará noticias y traducciones desde un archivo de backup.
Las noticias existentes con el mismo ID serán actualizadas.</p>
</div>
<form method="POST" enctype="multipart/form-data" class="config-form">
<div class="form-group">
<label for="file">
<i class="fas fa-file-upload"></i> Archivo de Backup (JSON)
</label>
<input type="file" id="file" name="file" required>
<small>Selecciona un archivo JSON generado por el backup</small>
</div>
<div class="form-actions">
<a href="{{ url_for('config.config_home') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Cancelar
</a>
<button type="submit" class="btn btn-primary" onclick="showLoading()">
<i class="fas fa-upload"></i> Restaurar Backup
</button>
</div>
</form>
<div id="loading-overlay"
style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.8); z-index:9999; flex-direction:column; justify-content:center; align-items:center; color:white;">
<i class="fas fa-spinner fa-spin fa-3x" style="margin-bottom:20px;"></i>
<h2>Restaurando noticias...</h2>
<p>Por favor espere, esto puede tardar unos minutos.</p>
</div>
<script>
function showLoading() {
const fileInput = document.querySelector('input[name="file"]');
if (fileInput && fileInput.files.length > 0) {
document.getElementById('loading-overlay').style.display = 'flex';
}
}
</script>
</div>
<style>
.config-form-page h2 {
margin-bottom: 1.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.restore-warning {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 1rem;
background: #fff3cd;
border-left: 4px solid #ffc107;
border-radius: 8px;
margin-bottom: 1.5rem;
max-width: 500px;
}
.restore-warning i {
color: #856404;
font-size: 1.25rem;
flex-shrink: 0;
}
.restore-warning p {
margin: 0;
color: #856404;
font-size: 0.9rem;
}
.config-form {
max-width: 500px;
background: var(--card-bg, #fff);
padding: 1.5rem;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 500;
margin-bottom: 0.5rem;
}
.form-group input[type="file"] {
width: 100%;
padding: 0.6rem;
border: 2px dashed var(--border-color, #ddd);
border-radius: 8px;
background: var(--input-bg, #fafafa);
}
.form-group small {
display: block;
margin-top: 0.35rem;
color: var(--text-muted, #666);
font-size: 0.8rem;
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 1.5rem;
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1rem;
border: none;
border-radius: 8px;
font-size: 0.9rem;
cursor: pointer;
text-decoration: none;
}
.btn-secondary {
background: #6c757d;
color: #fff;
}
.btn-warning {
background: #ffc107;
color: #212529;
}
.dark-mode .config-form {
background: var(--card-bg-dark, #1e1e1e);
}
.dark-mode .restore-warning {
background: #332701;
border-color: #ffc107;
}
.dark-mode .restore-warning p,
.dark-mode .restore-warning i {
color: #ffc107;
}
</style>
{% endblock %}

View file

@ -0,0 +1,187 @@
{% extends "base.html" %}
{% block title %}Configurar Traductor{% endblock %}
{% block content %}
<div class="config-form-page">
<h2><i class="fas fa-language"></i> Configuración del Traductor</h2>
<form method="POST" class="config-form">
<div class="form-group">
<label for="target_langs">
<i class="fas fa-globe"></i> Idiomas Destino
</label>
<input type="text" id="target_langs" name="target_langs" value="{{ config.target_langs }}"
placeholder="es,en,fr">
<small>Separados por coma. Ej: es,en,fr</small>
</div>
<div class="form-group">
<label for="translator_batch">
<i class="fas fa-layer-group"></i> Tamaño de Batch
</label>
<select id="translator_batch" name="translator_batch">
{% for b in [8, 16, 32, 64, 128] %}
<option value="{{ b }}" {% if config.translator_batch|int==b %}selected{% endif %}>{{ b }}</option>
{% endfor %}
</select>
<small>Número de textos a traducir por lote (8-128)</small>
</div>
<div class="form-group">
<label for="universal_model">
<i class="fas fa-brain"></i> Modelo Universal
</label>
<select id="universal_model" name="universal_model">
{% set models = [
('facebook/nllb-200-distilled-600M', 'NLLB-200 Distilled 600M (Rápido / Default)'),
('facebook/nllb-200-distilled-1.3B', 'NLLB-200 Distilled 1.3B (Mejor Calidad / Lento)'),
('facebook/nllb-200-1.3B', 'NLLB-200 1.3B (Raw / Lento)'),
('facebook/nllb-200-3.3B', 'NLLB-200 3.3B (Máxima Calidad / Muy Lento / Requiere mucha RAM)')
] %}
{% for m_id, m_name in models %}
<option value="{{ m_id }}" {% if config.universal_model==m_id %}selected{% endif %}>{{ m_name }}
</option>
{% endfor %}
</select>
<small>Selecciona el modelo de traducción. Actualmente usando: <strong>{{ config.universal_model
}}</strong></small>
<div class="alert alert-warning" style="margin-top: 10px; font-size: 0.9em; display: none;"
id="model-warning">
<i class="fas fa-exclamation-triangle"></i> <strong>Atención:</strong> Cambiar el modelo eliminará todas
las traducciones existentes para regenerarlas con el nuevo modelo.
</div>
</div>
<script>
document.getElementById('universal_model').addEventListener('change', function () {
var current = "{{ config.universal_model }}";
var warning = document.getElementById('model-warning');
if (this.value !== current) {
warning.style.display = 'block';
} else {
warning.style.display = 'none';
}
});
</script>
<div class="form-group">
<label for="ct2_compute_type">
<i class="fas fa-microchip"></i> Tipo de Cuantización
</label>
<select id="ct2_compute_type" name="ct2_compute_type">
<option value="auto" {% if config.ct2_compute_type=='auto' %}selected{% endif %}>auto</option>
<option value="int8" {% if config.ct2_compute_type=='int8' %}selected{% endif %}>int8 (más rápido, menos
preciso)</option>
<option value="float16" {% if config.ct2_compute_type=='float16' %}selected{% endif %}>float16 (más
preciso)</option>
<option value="int8_float16" {% if config.ct2_compute_type=='int8_float16' %}selected{% endif %}>
int8_float16 (balance)</option>
</select>
<small>Requiere reiniciar el contenedor</small>
</div>
<div class="form-actions">
<a href="{{ url_for('config.config_home') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Volver
</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Guardar
</button>
</div>
</form>
</div>
<style>
.config-form-page h2 {
margin-bottom: 1.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.config-form {
max-width: 500px;
background: var(--card-bg, #fff);
padding: 1.5rem;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 500;
margin-bottom: 0.5rem;
}
.form-group input,
.form-group select {
width: 100%;
padding: 0.6rem 0.75rem;
border: 1px solid var(--border-color, #ddd);
border-radius: 8px;
font-size: 1rem;
background: var(--input-bg, #fff);
color: var(--text-color, #333);
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: var(--secondary-color, #6c63ff);
box-shadow: 0 0 0 3px rgba(108, 99, 255, 0.15);
}
.form-group small {
display: block;
margin-top: 0.35rem;
color: var(--text-muted, #666);
font-size: 0.8rem;
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 1.5rem;
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1rem;
border: none;
border-radius: 8px;
font-size: 0.9rem;
cursor: pointer;
text-decoration: none;
}
.btn-primary {
background: var(--secondary-color, #6c63ff);
color: #fff;
}
.btn-secondary {
background: #6c757d;
color: #fff;
}
.dark-mode .config-form {
background: var(--card-bg-dark, #1e1e1e);
}
.dark-mode .form-group input,
.dark-mode .form-group select {
background: #2a2a2a;
border-color: #444;
color: #eee;
}
</style>
{% endblock %}

View file

@ -0,0 +1,163 @@
{% extends "base.html" %}
{% block title %}Timeline: {{ conflict.name }}{% endblock %}
{% block content %}
<div style="margin-bottom: 2rem;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h2>
<a href="{{ url_for('conflicts.index') }}" style="text-decoration: none; color: inherit;">
<i class="fas fa-arrow-left" style="font-size: 1rem; vertical-align: middle;"></i>
</a>
Timeline: {{ conflict.name }}
</h2>
<span class="badge" style="font-size: 1rem;">{{ noticias|length }} eventos</span>
</div>
<p style="color: #666;">
Keywords: {{ conflict.keywords }}
</p>
</div>
{% if not noticias %}
<div class="card" style="text-align: center; padding: 3rem;">
<p>No se encontraron noticias recientes con las palabras clave especificadas.</p>
</div>
{% else %}
<div class="timeline">
{% for n in noticias %}
<div class="timeline-item">
<div class="timeline-date">
{% if n.fecha %}
<span class="date-day">{{ n.fecha.strftime('%d') }}</span>
<span class="date-month">{{ n.fecha.strftime('%b') }}</span>
<span class="date-year">{{ n.fecha.strftime('%Y') }}</span>
{% endif %}
</div>
<div class="timeline-marker"></div>
<div class="timeline-content card">
{% if n.imagen_url %}
<div class="timeline-img">
<img src="{{ n.imagen_url }}" loading="lazy" onerror="this.style.display='none'">
</div>
{% endif %}
<h3>
<a
href="{{ url_for('noticia.noticia', tr_id=n.tr_id if n.tr_id else None, id=n.id if not n.tr_id else None) }}">
{{ n.titulo }}
</a>
</h3>
<div class="noticia-meta">
{{ n.fuente_nombre }}
{% if n.pais %}| {{ n.pais }}{% endif %}
</div>
<p>{{ (n.resumen or '') | safe_html | truncate(150) }}</p>
</div>
</div>
{% endfor %}
</div>
{% endif %}
<style>
/* Timeline Styles */
.timeline {
position: relative;
max-width: 900px;
margin: 0 auto;
padding: 20px 0;
}
/* Vertical Line */
.timeline::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 80px;
/* Position of line */
width: 2px;
background: var(--border-color);
}
.timeline-item {
position: relative;
margin-bottom: 40px;
padding-left: 120px;
/* Space for date and line */
}
.timeline-date {
position: absolute;
left: 0;
top: 0;
width: 60px;
text-align: center;
line-height: 1.2;
}
.date-day {
display: block;
font-size: 1.5rem;
font-weight: bold;
color: var(--accent-color);
}
.date-month {
display: block;
font-size: 0.9rem;
text-transform: uppercase;
font-weight: bold;
}
.date-year {
display: block;
font-size: 0.8rem;
color: #888;
}
.timeline-marker {
position: absolute;
left: 74px;
/* On the line (80px - 6px radius) */
top: 10px;
width: 14px;
height: 14px;
background: var(--paper-color);
border: 3px solid var(--accent-color);
border-radius: 50%;
z-index: 1;
}
.timeline-content {
position: relative;
padding: 1.5rem;
border-left: 4px solid var(--accent-color);
}
.timeline-content h3 {
font-size: 1.3rem;
margin-top: 0;
}
.timeline-img img {
width: 100%;
height: 150px;
object-fit: cover;
border-radius: 4px;
margin-bottom: 10px;
}
/* Dark Mode Overrides */
.dark-mode .timeline::before {
background: #444;
}
.dark-mode .timeline-marker {
background: #1a1a2e;
}
</style>
{% endblock %}

View file

@ -0,0 +1,118 @@
{% extends "base.html" %}
{% block title %}Conflictos{% endblock %}
{% block content %}
<div class="row">
<div style="margin-bottom: 2rem;">
<h2><i class="fas fa-exclamation-triangle"></i> Conflictos Monitorizados</h2>
<p>Define temas o conflictos para generar líneas de tiempo automáticas basadas en palabras clave.</p>
</div>
<!-- Create Form -->
<div class="card" style="margin-bottom: 2rem;">
<h3>Crear Nuevo Conflicto</h3>
<form action="{{ url_for('conflicts.create') }}" method="POST">
<div class="filter-row">
<div class="filter-group" style="flex: 2;">
<label>Nombre del Conflicto</label>
<input type="text" name="name" placeholder="Ej: Camboya vs Tailandia" required>
</div>
<div class="filter-group" style="flex: 3;">
<label>Palabras Clave (separadas por coma)</label>
<input type="text" name="keywords" placeholder="Ej: Camboya, Tailandia, Preah Vihear" required>
</div>
<div class="filter-group">
<label>&nbsp;</label>
<button type="submit" class="btn btn-primary" style="width: 100%;">Crear</button>
</div>
</div>
<div style="margin-top: 10px;">
<label style="font-weight: 600; font-size: 0.9rem;">Descripción (Opcional)</label>
<input type="text" name="description" style="width: 100%;"
placeholder="Breve descripción del contexto...">
</div>
</form>
</div>
<!-- List -->
<div class="conflicts-grid">
{% for c in conflicts %}
<div class="card conflict-card">
<div class="conflict-header">
<h3>{{ c.name }}</h3>
<form action="{{ url_for('conflicts.delete', id=c.id) }}" method="POST"
onsubmit="return confirm('¿Eliminar este conflicto?');">
<button type="submit" class="btn-icon" title="Eliminar"><i class="fas fa-trash"></i></button>
</form>
</div>
<p class="conflict-desc">{{ c.description or 'Sin descripción' }}</p>
<div class="keyword-tags">
{% for k in c.keywords.split(',') %}
{% if k.strip() %}
<span class="badge">{{ k.strip() }}</span>
{% endif %}
{% endfor %}
</div>
<div style="margin-top: 1rem; text-align: right;">
<a href="{{ url_for('conflicts.timeline', id=c.id) }}" class="btn">
<i class="fas fa-stream"></i> Ver Línea de Tiempo
</a>
</div>
</div>
{% else %}
<p style="text-align: center; color: #666;">No hay conflictos definidos.</p>
{% endfor %}
</div>
</div>
<style>
.conflicts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 20px;
}
.conflict-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 10px;
}
.conflict-header h3 {
margin: 0;
font-size: 1.4rem;
color: var(--accent-color);
}
.conflict-desc {
color: #666;
font-size: 0.9rem;
margin-bottom: 15px;
min-height: 40px;
}
.keyword-tags {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.btn-icon {
background: none;
border: none;
color: #999;
cursor: pointer;
padding: 5px;
}
.btn-icon:hover {
color: #e74c3c;
}
.dark-mode .conflict-desc {
color: #aaa;
}
</style>
{% endblock %}

144
templates/dashboard.html Normal file
View file

@ -0,0 +1,144 @@
{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<div class="dashboard-grid">
<div class="stat-card">
<div class="stat-number">{{ stats.feeds_totales }}</div>
<div class="stat-label">Feeds Totales</div>
</div>
<div class="stat-card">
<div class="stat-number">{{ stats.noticias_totales }}</div>
<div class="stat-label">Noticias Totales</div>
</div>
<div class="stat-card">
<div class="stat-number">{{ stats.feeds_caidos }}</div>
<div class="stat-label">Feeds Caídos</div>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-4">
<div class="card">
<div class="card-header">
<h3>Gestión de Feeds RSS</h3>
</div>
<div class="card-body">
<p>
Exporta tu lista de feeds RSS o restaura/importa desde un archivo CSV.
Además, puedes ir al organizador avanzado de feeds para filtrarlos
por país, categoría y estado.
</p>
<div style="display:flex; flex-wrap:wrap; gap:10px; margin-bottom:10px;">
<a href="{{ url_for('feeds.list_feeds') }}" class="btn btn-secondary">
<i class="fas fa-list"></i> Ver / Gestionar Feeds
</a>
</div>
<div style="display:flex; flex-wrap:wrap; gap:8px; margin-bottom:15px;">
<a href="{{ url_for('feeds.list_feeds', estado='activos') }}" class="btn btn-small">
<i class="fas fa-check-circle"></i> Feeds activos
</a>
<a href="{{ url_for('feeds.list_feeds', estado='inactivos') }}" class="btn btn-small btn-danger">
<i class="fas fa-times-circle"></i> Feeds caídos/inactivos
</a>
<a href="{{ url_for('feeds.list_feeds', estado='errores') }}" class="btn btn-small btn-info">
<i class="fas fa-exclamation-triangle"></i> Feeds con errores
</a>
</div>
<hr style="margin: 15px 0; border: 0; border-top: 1px solid var(--border-color);">
<a href="{{ url_for('backup_feeds') }}" class="btn">
<i class="fas fa-download"></i> Exportar Feeds
</a>
<a href="{{ url_for('restore_feeds') }}" class="btn btn-info">
<i class="fas fa-upload"></i> Importar Feeds
</a>
</div>
</div>
</div>
<div class="col-md-6 mb-4">
<div class="card">
<div class="card-header">
<h3>Gestión de Fuentes URL</h3>
</div>
<div class="card-body">
<p>Exporta tu lista de fuentes URL o restaura/importa desde un archivo CSV.</p>
<a href="{{ url_for('backup_urls') }}" class="btn">
<i class="fas fa-download"></i> Exportar URLs
</a>
<a href="{{ url_for('restore_urls') }}" class="btn btn-info">
<i class="fas fa-upload"></i> Importar Fuentes URL
</a>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>Operaciones del Sistema</h3>
</div>
<div class="card-body">
<p>Genera o restaura una copia de seguridad completa de todas tus fuentes y noticias.</p>
<div style="display:flex; gap:10px; flex-wrap:wrap;">
<a href="{{ url_for('backup_completo') }}" class="btn btn-secondary">
<i class="fas fa-archive"></i> Backup Completo (.zip)
</a>
<a href="{{ url_for('restore_completo') }}" class="btn btn-info">
<i class="fas fa-upload"></i> Restaurar Backup (.zip)
</a>
</div>
</div>
</div>
{% if top_tags and top_tags|length > 0 %}
<div class="card">
<div class="card-header">
<h3>Top tags (últimas 24h)</h3>
</div>
<div class="card-body" style="padding:0;">
<table style="width:100%; border-collapse: collapse;">
<thead>
<tr style="background-color: rgba(0,0,0,0.05);">
<th style="padding: 12px 15px; text-align: left;">Tag</th>
<th style="padding: 12px 15px; text-align: left;">Tipo</th>
<th style="padding: 12px 15px; text-align: right;">Apariciones</th>
</tr>
</thead>
<tbody>
{% for t in top_tags %}
<tr>
<td style="padding: 12px 15px; border-top: 1px solid var(--border-color);">
{{ t.valor }}
</td>
<td style="padding: 12px 15px; border-top: 1px solid var(--border-color); text-transform: capitalize;">
{{ t.tipo }}
</td>
<td style="padding: 12px 15px; border-top: 1px solid var(--border-color); text-align: right;">
{{ t.apariciones }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="card">
<div class="card-header">
<h3>Top tags (últimas 24h)</h3>
</div>
<div class="card-body">
<p style="color: var(--text-color-light); margin: 0;">No hay tags para mostrar todavía.</p>
</div>
</div>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,409 @@
{% extends "base.html" %}
{% block title %}Descubrir Feeds RSS{% endblock %}
{% block content %}
<div class="card feed-detail-card"
style="padding: 40px; border-radius: 15px; background-color: #fdfdfd; box-shadow: 0 10px 30px rgba(0,0,0,0.05);">
<h1
style="font-family: var(--primary-font); font-weight: 700; margin-bottom: 30px; border-bottom: 2px solid var(--accent-color); display: inline-block; padding-bottom: 10px;">
<i class="fas fa-search"></i> Descubrir Feeds RSS
</h1>
<p style="margin-bottom: 30px; color: #666;">
Ingresa la URL de un sitio web y automáticamente descubriremos todos los feeds RSS disponibles.
</p>
<!-- Loading Overlay -->
<div id="searching-overlay"
style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(255,255,255,0.9); z-index: 1000; flex-direction: column; align-items: center; justify-content: center;">
<div class="spinner"
style="border: 4px solid #f3f3f3; border-top: 4px solid var(--accent-color); border-radius: 50%; width: 50px; height: 50px; animation: spin 1s linear infinite;">
</div>
<h3 style="margin-top: 20px; color: #333;">Analizando sitio web...</h3>
<p style="color: #666;">Buscando feeds RSS, esto puede tardar unos segundos.</p>
</div>
<!-- Discovery Form -->
<form action="{{ url_for('feeds.discover_feed') }}" method="post" class="form-grid" style="margin-bottom: 40px;"
onsubmit="document.getElementById('searching-overlay').style.display = 'flex';">
<div class="form-row">
<label for="source_url">URL del sitio web</label>
<input type="url" id="source_url" name="source_url" placeholder="https://ejemplo.com"
value="{{ source_url }}" required style="font-size: 16px;">
</div>
<div class="form-row" style="border: none; padding-top: 20px;">
<div></div>
<div class="form-actions">
<button class="btn btn-primary" type="submit">
<i class="fas fa-search"></i> Buscar Feeds
</button>
<a href="{{ url_for('feeds.list_feeds') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Volver
</a>
</div>
</div>
</form>
<!-- Results -->
{% if discovered_feeds %}
<hr style="margin: 40px 0; border: none; border-top: 1px solid #e0e0e0;">
{% set new_feeds_count = discovered_feeds | rejectattr('exists') | list | length %}
<h2 style="font-size: 24px; margin-bottom: 20px; color: #333;">
<i class="fas fa-rss"></i> Feeds Disponibles: <strong>{{ new_feeds_count }}</strong> <span
style="font-size: 16px; color: #777; font-weight: normal;">(de {{ discovered_feeds|length }} encontrados en
total)</span>
</h2>
<form action="{{ url_for('feeds.discover_and_add') }}" method="post">
<!-- Global Settings -->
<div class="form-grid"
style="background: #f8f9fa; padding: 20px; border-radius: 10px; margin-bottom: 30px; border: 1px solid #e0e0e0;">
<h3
style="grid-column: 1 / -1; font-size: 16px; margin-bottom: 15px; color: #555; text-transform: uppercase; font-weight: 700; letter-spacing: 0.5px;">
<i class="fas fa-sliders-h"></i> Configuración Masiva
</h3>
<div class="form-row">
<label for="global_categoria_id">Aplicar Categoría a todos:</label>
<div style="display: flex; gap: 10px;">
<select id="global_categoria_id" class="form-control">
<option value="">— Seleccionar —</option>
{% for c in categorias %}
<option value="{{ c.id }}">{{ c.nombre }}</option>
{% endfor %}
</select>
<button type="button" class="btn btn-secondary btn-sm" onclick="applyGlobalCategory()"
title="Aplicar a todos">
<i class="fas fa-arrow-down"></i>
</button>
</div>
</div>
<div class="form-row">
<label for="global_pais_id">Aplicar País a todos:</label>
<div style="display: flex; gap: 10px;">
<select id="global_pais_id" class="form-control">
<option value="">— Seleccionar —</option>
{% for p in paises %}
<option value="{{ p.id }}">{{ p.nombre }}</option>
{% endfor %}
</select>
<button type="button" class="btn btn-secondary btn-sm" onclick="applyGlobalCountry()"
title="Aplicar a todos">
<i class="fas fa-arrow-down"></i>
</button>
</div>
</div>
<div class="form-row">
<label for="global_idioma">Aplicar Idioma a todos:</label>
<div style="display: flex; gap: 10px;">
<input type="text" id="global_idioma" class="form-control" maxlength="5" placeholder="es" value="es"
style="width: 80px;">
<button type="button" class="btn btn-secondary btn-sm" onclick="applyGlobalLanguage()"
title="Aplicar a todos">
<i class="fas fa-arrow-down"></i>
</button>
</div>
</div>
</div>
<!-- Feed List -->
<div style="margin-bottom: 30px;">
{% for feed in discovered_feeds %}
<div class="feed-discovery-item" style="
background: {{ 'white' if feed.valid and not feed.exists else ('#f0f0f0' if feed.exists else '#fff5f5') }};
border: 1px solid {{ '#e0e0e0' if feed.valid else '#ffcdd2' }};
border-left: 5px solid {{ '#4CAF50' if feed.valid and not feed.exists else ('#9e9e9e' if feed.exists else '#ff5252') }};
border-radius: 8px;
padding: 20px;
margin-bottom: 15px;
display: grid;
grid-template-columns: 40px 1fr 280px auto;
gap: 20px;
align-items: start;
transition: all 0.2s ease;
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
opacity: {{ '0.8' if feed.exists else '1' }};
">
<!-- Checkbox -->
<div style="padding-top: 5px; display: flex; justify-content: center;">
{% if feed.exists %}
<i class="fas fa-check-circle" style="color: #4CAF50; font-size: 22px;"
title="Ya existe en la base de datos"></i>
{% elif feed.valid %}
<input type="checkbox" name="selected_feeds" value="{{ feed.url }}" id="feed_{{ loop.index }}"
checked style="width: 22px; height: 22px; cursor: pointer; border-radius: 4px;">
<input type="hidden" name="context_{{ feed.url }}" value="{{ feed.context_label }}">
{% else %}
<i class="fas fa-exclamation-triangle" style="color: #ff5252; font-size: 20px;"
title="{{ feed.error }}"></i>
{% endif %}
</div>
<!-- Feed Info -->
<div>
<label for="feed_{{ loop.index }}" style="cursor: pointer; display: block; margin-bottom: 8px;">
<strong
style="font-size: 18px; color: #{{ '555' if feed.exists else '333' }}; line-height: 1.3;">
{{ feed.title }}
</strong>
{% if feed.exists %}
<span class="badge"
style="background: #e0e0e0; color: #555; font-size: 11px; vertical-align: middle; margin-left: 8px;">YA
INSTALADO</span>
{% endif %}
</label>
{% if feed.description %}
<p style="color: #666; margin-bottom: 12px; font-size: 14px; line-height: 1.5;">
{{ feed.description[:250] }}{% if feed.description|length > 250 %}...{% endif %}
</p>
{% endif %}
<div style="font-size: 13px; color: #888; display: flex; flex-direction: column; gap: 6px;">
<div>
<i class="fas fa-link" style="width: 16px; text-align: center;"></i>
<a href="{{ feed.url }}" target="_blank"
style="color: #888; text-decoration: none; border-bottom: 1px dotted #ccc;">
{{ feed.url[:60] }}{% if feed.url|length > 60 %}...{% endif %}
</a>
</div>
{% if feed.context_label %}
<div style="color: #1976D2; font-weight: 500;">
<i class="fas fa-tag" style="width: 16px; text-align: center;"></i> Encontrado en: "{{
feed.context_label }}"
</div>
{% endif %}
{% if feed.valid %}
<div style="display: flex; gap: 15px; margin-top: 5px;">
{% if feed.type %}
<span class="badge"
style="background: #e3f2fd; color: #1565c0; padding: 2px 8px; border-radius: 4px;">
{{ feed.type|upper }}
</span>
{% endif %}
{% if feed.entry_count is defined %}
<span class="badge"
style="background: #f3e5f5; color: #7b1fa2; padding: 2px 8px; border-radius: 4px;">
{{ feed.entry_count }} items
</span>
{% endif %}
</div>
{% else %}
<div style="color: #d32f2f; margin-top: 5px;">
<i class="fas fa-info-circle"></i> Error: {{ feed.error }}
</div>
{% endif %}
</div>
</div>
<!-- Individual Configurations -->
{% if feed.valid %}
<div style="background: #fdfdfd; padding: 15px; border-radius: 8px; border: 1px solid #eee;">
<div style="margin-bottom: 10px;">
<label
style="font-size: 12px; font-weight: 600; color: #555; display: block; margin-bottom: 4px;">Categoría</label>
<select name="cat_{{ feed.url }}" class="item-category-select"
style="width: 100%; padding: 6px; border: 1px solid #ddd; border-radius: 4px; font-size: 13px;">
<option value="">— Seleccionar —</option>
{% for c in categorias %}
<option value="{{ c.id }}">{{ c.nombre }}</option>
{% endfor %}
</select>
</div>
<div style="margin-bottom: 10px;">
<label
style="font-size: 12px; font-weight: 600; color: #555; display: block; margin-bottom: 4px;">País</label>
<select name="country_{{ feed.url }}" class="item-country-select"
style="width: 100%; padding: 6px; border: 1px solid #ddd; border-radius: 4px; font-size: 13px;">
<option value="">— Seleccionar —</option>
{% for p in paises %}
<option value="{{ p.id }}">{{ p.nombre }}</option>
{% endfor %}
</select>
</div>
<div>
<label
style="font-size: 12px; font-weight: 600; color: #555; display: block; margin-bottom: 4px;">Idioma</label>
<input type="text" name="lang_{{ feed.url }}" class="item-language-input" value="es"
maxlength="5"
style="width: 100%; padding: 6px; border: 1px solid #ddd; border-radius: 4px; font-size: 13px;">
</div>
</div>
{% else %}
<div></div>
{% endif %}
<!-- Actions -->
<div style="display: flex; flex-direction: column; gap: 10px; justify-content: flex-start;">
{% if feed.valid %}
<button type="button" class="btn btn-primary btn-sm" onclick="addSingleFeed('{{ feed.url }}')"
style="white-space: nowrap; box-shadow: 0 2px 5px rgba(0,0,0,0.1);">
<i class="fas fa-plus"></i> Añadir
</button>
<a href="{{ feed.url }}" target="_blank" class="btn btn-outline-secondary btn-sm"
style="white-space: nowrap;">
<i class="fas fa-external-link-alt"></i> Ver XML
</a>
{% endif %}
</div>
</div>
{% endfor %}
</div>
<!-- Action Buttons -->
<div class="form-actions"
style="display: flex; gap: 15px; justify-content: flex-end; padding-top: 20px; border-top: 1px solid #e0e0e0; position: sticky; bottom: 0; background: white; z-index: 10; padding-bottom: 20px;">
<div style="margin-right: auto; align-self: center; color: #666; font-size: 14px;">
<span id="selected_count">{{ discovered_feeds|selectattr('valid')|list|length }}</span> feeds
seleccionados
</div>
<button type="button" class="btn btn-secondary" onclick="toggleAllFeeds(true)">
<i class="fas fa-check-square"></i> Todos
</button>
<button type="button" class="btn btn-secondary" onclick="toggleAllFeeds(false)">
<i class="fas fa-square"></i> Ninguno
</button>
<button class="btn btn-primary" type="submit"
style="padding: 10px 25px; font-weight: 600; box-shadow: 0 4px 10px rgba(0,0,0,0.1);">
<i class="fas fa-plus-circle"></i> AÑADIR SELECCIONADOS
</button>
</div>
</form>
{% endif %}
</div>
<script>
function toggleAllFeeds(select) {
const checkboxes = document.querySelectorAll('input[name="selected_feeds"]');
checkboxes.forEach(cb => {
cb.checked = select;
});
updateCount();
}
function addSingleFeed(url) {
// Collect specific values
const cat = document.querySelector(`select[name="cat_${url}"]`).value;
const country = document.querySelector(`select[name="country_${url}"]`).value;
const lang = document.querySelector(`input[name="lang_${url}"]`).value;
const context = document.querySelector(`input[name="context_${url}"]`) ? document.querySelector(`input[name="context_${url}"]`).value : '';
const formData = new FormData();
formData.append('selected_feeds', url);
formData.append(`cat_${url}`, cat);
formData.append(`country_${url}`, country);
formData.append(`lang_${url}`, lang);
if (context) formData.append(`context_${url}`, context);
const btn = event.target.closest('button');
const originalContent = btn.innerHTML;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> ...';
btn.disabled = true;
fetch('{{ url_for("feeds.discover_and_add") }}', {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest'
},
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
btn.innerHTML = '<i class="fas fa-check"></i> Añadido';
btn.classList.remove('btn-primary');
btn.classList.add('btn-success');
// Disable inputs for this row
document.querySelector(`select[name="cat_${url}"]`).disabled = true;
document.querySelector(`select[name="country_${url}"]`).disabled = true;
document.querySelector(`input[name="lang_${url}"]`).disabled = true;
} else {
btn.innerHTML = '<i class="fas fa-times"></i> Error';
btn.classList.remove('btn-primary');
btn.classList.add('btn-danger');
alert('No se pudo añadir: ' + (data.errors ? data.errors.join(', ') : 'Error desconocido'));
setTimeout(() => {
btn.innerHTML = originalContent;
btn.disabled = false;
btn.classList.remove('btn-danger');
btn.classList.add('btn-primary');
}, 3000);
}
})
.catch(error => {
console.error('Error:', error);
btn.innerHTML = originalContent;
btn.disabled = false;
alert('Error de conexión');
});
}
function updateCount() {
const count = document.querySelectorAll('input[name="selected_feeds"]:checked').length;
document.getElementById('selected_count').innerText = count;
}
// Update count on individual clicks
document.addEventListener('change', function (e) {
if (e.target.name === 'selected_feeds') {
updateCount();
}
});
// Mass Update Functions
function applyGlobalCategory() {
const val = document.getElementById('global_categoria_id').value;
document.querySelectorAll('.item-category-select').forEach(el => el.value = val);
}
function applyGlobalCountry() {
const val = document.getElementById('global_pais_id').value;
document.querySelectorAll('.item-country-select').forEach(el => el.value = val);
}
function applyGlobalLanguage() {
const val = document.getElementById('global_idioma').value;
document.querySelectorAll('.item-language-input').forEach(el => el.value = val);
}
// Add hover effect to feed items
document.addEventListener('DOMContentLoaded', function () {
const feedItems = document.querySelectorAll('.feed-discovery-item');
feedItems.forEach(item => {
item.addEventListener('mouseenter', function () {
this.style.boxShadow = '0 8px 20px rgba(0,0,0,0.08)';
this.style.transform = 'translateY(-2px)';
});
item.addEventListener('mouseleave', function () {
this.style.boxShadow = '0 2px 5px rgba(0,0,0,0.05)';
this.style.transform = 'translateY(0)';
});
});
updateCount();
});
</script>
<style>
.feed-discovery-item input[type="checkbox"] {
accent-color: var(--accent-color, #4CAF50);
}
.form-control {
border: 1px solid #ddd;
border-radius: 4px;
padding: 8px;
font-size: 14px;
}
.btn-sm {
padding: 5px 10px;
font-size: 12px;
}
</style>
{% endblock %}

87
templates/edit_feed.html Normal file
View file

@ -0,0 +1,87 @@
{% extends "base.html" %}
{% block title %}Editar Feed RSS{% endblock %}
{% block content %}
<div class="card feed-detail-card"
style="padding: 40px; border-radius: 15px; background-color: #fdfdfd; box-shadow: 0 10px 30px rgba(0,0,0,0.05);">
<h1
style="font-family: var(--primary-font); font-weight: 700; margin-bottom: 30px; border-bottom: 2px solid var(--accent-color); display: inline-block; padding-bottom: 10px;">
Editar Feed
</h1>
<p class="subtitle" style="margin-bottom: 30px; font-style: italic; color: #666;">
Modificando fuente: <strong>{{ feed.nombre }}</strong>
</p>
<form method="post" action="{{ url_for('feeds.edit_feed', feed_id=feed.id) }}" autocomplete="off" class="form-grid">
<div class="form-row">
<label for="nombre">Nombre del feed</label>
<input id="nombre" name="nombre" type="text" value="{{ feed.nombre }}" required>
</div>
<div class="form-row">
<label for="descripcion">Descripción</label>
<textarea id="descripcion" name="descripcion" rows="3">{{ feed.descripcion or '' }}</textarea>
</div>
<div class="form-row">
<label for="url">URL del RSS</label>
<input id="url" name="url" type="url" value="{{ feed.url }}" required>
</div>
<div class="form-row">
<label for="categoria_id">Categoría</label>
<select id="categoria_id" name="categoria_id">
<option value="">— Sin categoría —</option>
{% for c in categorias %}
<option value="{{ c.id }}" {% if c.id==feed.categoria_id %}selected{% endif %}>
{{ c.nombre }}
</option>
{% endfor %}
</select>
</div>
<div class="form-row">
<label for="pais_id">País</label>
<select id="pais_id" name="pais_id">
<option value="">— Global —</option>
{% for p in paises %}
<option value="{{ p.id }}" {% if p.id==feed.pais_id %}selected{% endif %}>
{{ p.nombre }}
</option>
{% endfor %}
</select>
</div>
<div class="form-row">
<label for="idioma">Idioma (2 letras)</label>
<input id="idioma" name="idioma" type="text" value="{{ feed.idioma or '' }}" maxlength="2">
</div>
<div class="form-row">
<div></div> <!-- Alignment -->
<div style="display: flex; align-items: center; gap: 15px;">
<input type="checkbox" id="activo" name="activo" {% if feed.activo %}checked{% endif %}
style="width: 24px; height: 24px; margin: 0;">
<label for="activo" style="margin: 0; text-align: left; font-size: 1.1rem;">Feed activo</label>
</div>
</div>
<div class="form-row" style="border: none; padding-top: 20px;">
<div></div> <!-- Alignment -->
<div class="form-actions">
<button class="btn btn-primary" type="submit">
<i class="fas fa-save"></i> Guardar Cambios
</button>
<a href="{{ url_for('feeds.list_feeds') }}" class="btn btn-secondary">
<i class="fas fa-times"></i> Cancelar
</a>
</div>
</div>
</form>
</div>
<div style="margin-top: 20px;">
<a href="{{ url_for('feeds.list_feeds') }}" class="top-link">← Volver al listado</a>
</div>
{% endblock %}

View file

@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block title %}Editar Fuente URL{% endblock %}
{% block content %}
<h1>Editar Fuente: {{ fuente.nombre }}</h1>
<div class="card">
<form action="{{ url_for('edit_url_source', url_id=fuente.id) }}" method="post">
<label for="nombre">Nombre</label>
<input type="text" id="nombre" name="nombre" value="{{ fuente.nombre }}" required>
<label for="url" style="margin-top:15px;">URL</label>
<input type="url" id="url" name="url" value="{{ fuente.url }}" required>
<label for="categoria_id" style="margin-top:15px;">Categoría</label>
<select id="categoria_id" name="categoria_id">
<option value="">— Sin categoría —</option>
{% for c in categorias %}
<option value="{{ c.id }}" {% if c.id == fuente.categoria_id %}selected{% endif %}>
{{ c.nombre }}
</option>
{% endfor %}
</select>
<label for="pais_id" style="margin-top:15px;">País</label>
<select id="pais_id" name="pais_id">
<option value="">— Sin país —</option>
{% for p in paises %}
<option value="{{ p.id }}" {% if p.id == fuente.pais_id %}selected{% endif %}>
{{ p.nombre }}
</option>
{% endfor %}
</select>
<label for="idioma" style="margin-top:15px;">Idioma (2 letras)</label>
<input id="idioma" name="idioma" value="{{ fuente.idioma }}" maxlength="2" required>
<div style="display:flex;justify-content:end;gap:10px;margin-top:20px;">
<a href="{{ url_for('manage_urls') }}" class="btn btn-secondary">Cancelar</a>
<button class="btn" type="submit">Actualizar</button>
</div>
</form>
</div>
{% endblock %}

114
templates/favoritos.html Normal file
View file

@ -0,0 +1,114 @@
{% extends "base.html" %}
{% block title %}Mis Favoritos{% endblock %}
{% block content %}
<div class="favoritos-page">
<h2><i class="fas fa-star"></i> Mis Favoritos</h2>
{% if noticias %}
<p class="favoritos-count">{{ noticias|length }} noticia{{ 's' if noticias|length > 1 else '' }} guardada{{ 's' if
noticias|length > 1 else '' }}</p>
<ul class="noticias-list">
{% for n in noticias %}
<li class="noticia-item">
{% if n.imagen_url %}
<div class="noticia-imagen">
<img src="{{ n.imagen_url }}" loading="lazy" onerror="this.parentElement.style.display='none'">
</div>
{% endif %}
<div class="noticia-texto">
<h3 class="m0">
<a href="{{ url_for('noticia.noticia', id=n.id) }}">
{{ n.titulo_trad or n.titulo }}
</a>
</h3>
<div class="noticia-meta">
{% if n.fecha %}
<i class="far fa-calendar-alt"></i>
{{ n.fecha.strftime('%d-%m-%Y %H:%M') if n.fecha else '' }}
{% endif %}
{% if n.fuente_nombre %} | {{ n.fuente_nombre }}{% endif %}
{% if n.pais %} | {{ n.pais|country_flag }} {{ n.pais }}{% endif %}
</div>
<p class="noticia-resumen">{{ (n.resumen_trad or n.resumen or '')[:200] }}...</p>
<button class="btn-remove-fav" onclick="removeFavorite('{{ n.id }}', this)" title="Quitar de favoritos">
<i class="fas fa-trash"></i> Quitar
</button>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<div class="empty-state">
<i class="far fa-star"></i>
<p>No tienes noticias guardadas.</p>
<a href="{{ url_for('home.home') }}" class="btn btn-dark">Ver noticias</a>
</div>
{% endif %}
</div>
<style>
.favoritos-page h2 {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.favoritos-count {
color: var(--text-muted, #666);
margin-bottom: 1.5rem;
}
.empty-state {
text-align: center;
padding: 3rem;
color: var(--text-muted, #666);
}
.empty-state i {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.btn-remove-fav {
margin-top: 0.5rem;
padding: 0.4rem 0.8rem;
background: transparent;
border: 1px solid #dc3545;
color: #dc3545;
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
transition: all 0.2s;
}
.btn-remove-fav:hover {
background: #dc3545;
color: #fff;
}
</style>
<script>
async function removeFavorite(noticiaId, btn) {
const response = await fetch(`/favoritos/toggle/${noticiaId}`, { method: 'POST' });
if (response.ok) {
btn.closest('.noticia-item').remove();
// Update count
const remaining = document.querySelectorAll('.noticia-item').length;
if (remaining === 0) {
location.reload();
} else {
document.querySelector('.favoritos-count').textContent =
`${remaining} noticia${remaining > 1 ? 's' : ''} guardada${remaining > 1 ? 's' : ''}`;
}
}
}
</script>
{% endblock %}

151
templates/feeds_list.html Normal file
View file

@ -0,0 +1,151 @@
{% extends "base.html" %}
{% block title %}Gestionar Feeds RSS{% endblock %}
{% block content %}
<div class="card feed-detail-card">
<div class="feed-header">
<h2>Lista de Feeds RSS</h2>
<div class="nav-actions" style="display:flex; gap:8px; align-items:center;">
<!-- 🔵 Exportar feeds CSV (con filtros aplicados) -->
<a href="#" id="export-btn" class="btn btn-small btn-secondary" onclick="exportFilteredFeeds(event)">
<i class="fas fa-download"></i> Exportar Feeds
</a>
<!-- 🟣 Importar feeds CSV -->
<a href="{{ url_for('backup.restore_feeds') }}" class="btn btn-small btn-secondary">
<i class="fas fa-upload"></i> Importar Feeds
</a>
<!-- 🟢 Añadir feed -->
<a href="{{ url_for('feeds.add_feed') }}" class="btn btn-small">
<i class="fas fa-plus"></i> Añadir Feed
</a>
</div>
</div>
<!-- Filtros avanzados -->
<div class="feed-body" style="padding: 15px 15px 0 15px;">
<form class="feed-filters" method="get" action="{{ url_for('feeds.list_feeds') }}" id="filter-form">
<div class="filter-row">
<div class="filter-group">
<label for="pais_id">País</label>
<select name="pais_id" id="pais_id" onchange="reloadTable()">
<option value="">Todos los países</option>
{% for p in paises %}
<option value="{{ p.id }}" {% if filtro_pais_id is not none and p.id==filtro_pais_id|int
%}selected{% endif %}>
{{ p.nombre }}
</option>
{% endfor %}
</select>
</div>
<div class="filter-group">
<label for="categoria_id">Categoría</label>
<select name="categoria_id" id="categoria_id" onchange="reloadTable()">
<option value="">Todas las categorías</option>
{% for c in categorias %}
<option value="{{ c.id }}" {% if filtro_categoria_id is not none and
c.id==filtro_categoria_id|int %}selected{% endif %}>
{{ c.nombre }}
</option>
{% endfor %}
</select>
</div>
<div class="filter-group">
<label for="estado">Estado</label>
<select name="estado" id="estado" onchange="reloadTable()">
<option value="" {% if not filtro_estado %}selected{% endif %}>Todos</option>
<option value="activos" {% if filtro_estado=="activos" %}selected{% endif %}>Activos</option>
<option value="inactivos" {% if filtro_estado=="inactivos" %}selected{% endif %}>Inactivos
</option>
<option value="errores" {% if filtro_estado=="errores" %}selected{% endif %}>Con errores
</option>
</select>
</div>
<div class="filter-group" style="flex: 0 0 auto; display:flex; gap:10px; align-self: flex-end;">
<!-- Button can be hidden if we fully rely on onchange, but useful for accessibility or clearing -->
<a href="{{ url_for('feeds.list_feeds') }}" class="btn btn-secondary">
Limpiar
</a>
</div>
</div>
</form>
</div>
<!-- Container for dynamic table -->
<div id="table-container">
{% include '_feeds_table.html' %}
</div>
</div>
<script>
async function reloadTable(urlOverride) {
const form = document.getElementById('filter-form');
const container = document.getElementById('table-container');
// Visual indicator
container.style.opacity = '0.5';
let url;
if (urlOverride) {
url = urlOverride;
} else {
const formData = new FormData(form);
const params = new URLSearchParams(formData);
url = `${form.action}?${params.toString()}`;
}
try {
const response = await fetch(url, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const html = await response.text();
container.innerHTML = html;
// Update URL without reload
window.history.pushState({}, '', url);
} catch (error) {
console.error('Error reloading table:', error);
alert('Error al actualizar la lista.');
} finally {
container.style.opacity = '1';
}
}
function handlePageClick(event, url) {
event.preventDefault();
reloadTable(url);
}
function exportFilteredFeeds(event) {
event.preventDefault();
// Capturar valores actuales de los filtros
const paisId = document.getElementById('pais_id').value;
const categoriaId = document.getElementById('categoria_id').value;
const estado = document.getElementById('estado').value;
// Construir URL con parámetros
const params = new URLSearchParams();
if (paisId) params.append('pais_id', paisId);
if (categoriaId) params.append('categoria_id', categoriaId);
if (estado) params.append('estado', estado);
const exportUrl = `/export_feeds_filtered?${params.toString()}`;
// Redirigir para descargar
window.location.href = exportUrl;
}
</script>
{% endblock %}

138
templates/index.html Executable file
View file

@ -0,0 +1,138 @@
{% extends "base.html" %}
{% block title %}Gestión de Feeds RSS{% endblock %}
{% block content %}
<h1>Gestión de Feeds RSS</h1>
<a href="/" class="top-link">← Volver a últimas noticias</a>
<div class="card">
<h2>Añadir un nuevo feed</h2>
<form action="/add" method="post" autocomplete="off">
<label for="nombre">Nombre del feed</label>
<input id="nombre" name="nombre" placeholder="Nombre del feed" required>
<label for="descripcion">Descripción</label>
<textarea id="descripcion" name="descripcion" placeholder="Breve descripción del feed" rows="2"></textarea>
<label for="url">URL del RSS</label>
<input id="url" name="url" placeholder="URL del RSS" required>
<label for="categoria_id">Categoría</label>
<select id="categoria_id" name="categoria_id" required>
<option value="">— Elige categoría —</option>
{% for cid, cnom in categorias %}
<option value="{{ cid }}">{{ cnom }}</option>
{% endfor %}
</select>
<label for="continente_id">Continente</label>
<select name="continente_id" id="continente_id" onchange="filtrarPaisesPorContinente()">
<option value="">— Elige continente —</option>
{% for coid, conom in continentes %}
<option value="{{ coid }}">{{ conom }}</option>
{% endfor %}
</select>
<label for="pais_id">País</label>
<select name="pais_id" id="pais_id">
<option value="">— N/A —</option>
{% for pid, pnom, contid in paises %}
<option value="{{ pid }}">{{ pnom }}</option>
{% endfor %}
</select>
<!-- Nuevo campo: idioma -->
<label for="idioma">Idioma</label>
<input id="idioma" name="idioma" maxlength="2" placeholder="Ej: es, en, fr">
<button class="btn" type="submit">Añadir</button>
<!-- Datos en JSON para el filtro dinámico de países -->
<script type="application/json" id="paises-data">{{ paises|tojson }}</script>
</form>
</div>
<div class="card">
<h2>Lista de Feeds</h2>
<a href="/backup_feeds" target="_blank" class="btn">⬇️ Descargar backup de feeds (CSV)</a>
<a href="/restore_feeds" class="btn" style="margin-left:10px;">🔄 Restaurar feeds desde backup</a>
<table>
<thead>
<tr>
<th>Nombre y descripción</th>
<th>URL</th>
<th>Categoría</th>
<th>País</th>
<th style="min-width: 80px;">Estado</th>
<th style="min-width: 60px;">Fallos</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
{% for id, nombre, descripcion, url, categoria_id, pais_id, activo, fallos, cat_nom, pais_nom in feeds %}
<tr>
<td>
<strong>{{ nombre }}</strong>
{% if descripcion %}
<div style="font-size:0.95em; color:#64748b;">{{ descripcion }}</div>
{% endif %}
</td>
<td><a href="{{ url }}" target="_blank">{{ url }}</a></td>
<td>{{ cat_nom or 'N/A' }}</td>
<td>{{ pais_nom or 'N/A' }}</td>
<td>
{% if not activo %}
<span class="badge-ko" title="Inactivo: {{ fallos }} fallos">KO</span>
{% elif fallos > 0 %}
<span class="badge-warn" title="{{ fallos }} fallos recientes">⚠️</span>
{% else %}
<span class="badge-ok">OK</span>
{% endif %}
</td>
<td>
{% if fallos > 0 %}
<span style="color:orange;">{{ fallos }}</span>
{% else %}
0
{% endif %}
</td>
<td class="actions">
<a href="/edit/{{ id }}">Editar</a> |
<a href="/delete/{{ id }}" onclick="return confirm('¿Seguro que quieres eliminar este feed?');">Eliminar</a>
{% if not activo %}
| <a href="/reactivar_feed/{{ id }}" style="color:#198754;" title="Reactivar feed">Reactivar</a>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="7">No hay feeds aún.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<a href="/" class="top-link">← Volver a últimas noticias</a>
<script>
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 = '— N/A —';
selectPais.appendChild(optionNA);
paises.forEach(([id, nombre, contId]) => {
if (!continenteId || contId == continenteId || contId == Number(continenteId)) {
const opt = document.createElement('option');
opt.value = id;
opt.textContent = nombre;
selectPais.appendChild(opt);
}
});
}
</script>
{% endblock %}

33
templates/login.html Normal file
View file

@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block title %}Iniciar Sesión - The Daily Feed{% endblock %}
{% block content %}
<div style="max-width: 450px; margin: 40px auto; padding: 30px; background: #f9f9f9; border-radius: 10px;">
<h2 style="text-align: center; margin-bottom: 30px;">Iniciar Sesión</h2>
<form method="post" action="{{ url_for('auth.login') }}">
<div style="margin-bottom: 20px;">
<label for="username" style="display: block; margin-bottom: 5px; font-weight: 500;">Usuario o Email</label>
<input type="text" id="username" name="username" required value="{{ username or '' }}"
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px;">
</div>
<div style="margin-bottom: 30px;">
<label for="password" style="display: block; margin-bottom: 5px; font-weight: 500;">Contraseña</label>
<input type="password" id="password" name="password" required
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px;">
</div>
<button type="submit"
style="width: 100%; padding: 12px; background: #6c63ff; color: white; border: none; border-radius: 5px; font-size: 16px; font-weight: 600; cursor: pointer;">
Iniciar sesión
</button>
</form>
<div style="text-align: center; margin-top: 20px;">
<p style="color: #666;">¿No tienes cuenta? <a href="{{ url_for('auth.register') }}"
style="color: #6c63ff; text-decoration: none; font-weight: 500;">Regístrate</a></p>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,143 @@
{% extends "base.html" %}
{% block title %}Informe: {{ pais.nombre }}{% endblock %}
{% block content %}
<header class="monitor-header">
<a href="{{ url_for('topics.monitor', days=days) }}" class="back-link">← Volver al Monitor</a>
<div class="header-content">
<h1>{{ pais.nombre }}</h1>
<p>Informe de Inteligencia (Últimos {{ days }} días)</p>
</div>
</header>
<div class="dashboard-layout">
<!-- Left Column: Top News -->
<section class="top-news-section">
<h2><i class="fas fa-newspaper"></i> Noticias de Alto Impacto</h2>
{% for n in news %}
<article class="impact-news-card">
<div class="impact-score">{{ n.impact_score }}</div>
<div class="content">
<h3><a href="{{ url_for('noticia.noticia', id=n.id) }}">{{ n.titulo }}</a></h3>
<div class="meta">
<span>{{ n.fecha.strftime('%d/%m %H:%M') }}</span> |
<span>{{ n.fuente_nombre }}</span>
</div>
<p>{{ n.resumen[:200] }}...</p>
</div>
</article>
{% else %}
<p>No se han detectado noticias de alto impacto recientemente.</p>
{% endfor %}
</section>
<!-- Right Column: Active Topics -->
<aside class="topics-sidebar">
<h2><i class="fas fa-tags"></i> Temas Activos</h2>
<ul class="topic-ranking">
{% for t in active_topics %}
<li>
<span class="topic-name">{{ t.name }}</span>
<span class="topic-vol">{{ t.topic_volume }} pts</span>
<div class="topic-bar"
style="width: {% if active_topics|length > 0 %}{{ (t.topic_volume / active_topics[0].topic_volume) * 100 }}{% else %}0{% endif %}%">
</div>
</li>
{% endfor %}
</ul>
</aside>
</div>
<style>
.dashboard-layout {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 40px;
margin-top: 30px;
}
@media (max-width: 768px) {
.dashboard-layout {
grid-template-columns: 1fr;
}
}
.monitor-header {
margin-bottom: 20px;
border-bottom: 2px solid #eee;
padding-bottom: 20px;
}
.impact-news-card {
display: flex;
gap: 20px;
background: white;
padding: 20px;
margin-bottom: 20px;
border-radius: 8px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
border-left: 4px solid #e74c3c;
}
.impact-score {
font-size: 1.5rem;
font-weight: bold;
color: #e74c3c;
min-width: 60px;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
background: #fdeaea;
border-radius: 50%;
height: 60px;
}
.impact-news-card h3 {
margin-top: 0;
}
.impact-news-card h3 a {
text-decoration: none;
color: #2c3e50;
}
.topic-ranking {
list-style: none;
padding: 0;
}
.topic-ranking li {
margin-bottom: 15px;
position: relative;
padding-bottom: 5px;
}
.topic-name {
display: block;
font-weight: bold;
z-index: 2;
position: relative;
}
.topic-vol {
float: right;
font-size: 0.9rem;
color: #666;
z-index: 2;
position: relative;
}
.topic-bar {
position: absolute;
bottom: 0;
left: 0;
height: 4px;
background: #3498db;
border-radius: 2px;
}
</style>
{% endblock %}

111
templates/monitor_list.html Normal file
View file

@ -0,0 +1,111 @@
{% extends "base.html" %}
{% block title %}Monitor de Impacto Global{% endblock %}
{% block content %}
<header>
<h1><i class="fas fa-globe"></i> Monitor de Impacto Global</h1>
<p class="subtitle">Análisis de relevancia por país en los últimos {{ days }} días.</p>
</header>
<div class="filters">
<a href="{{ url_for('topics.monitor', days=1) }}" class="btn {% if days==1 %}btn-active{% endif %}">24h</a>
<a href="{{ url_for('topics.monitor', days=3) }}" class="btn {% if days==3 %}btn-active{% endif %}">3 Días</a>
<a href="{{ url_for('topics.monitor', days=7) }}" class="btn {% if days==7 %}btn-active{% endif %}">Semana</a>
</div>
<div class="monitor-grid">
{% for c in countries %}
<div class="card country-card">
<div class="country-header">
<span class="flag-icon">{{ c.nombre[:2] }}</span> <!-- Placeholder for flag -->
<h2><a href="{{ url_for('topics.country_detail', pais_id=c.id, days=days) }}">{{ c.nombre }}</a></h2>
</div>
<div class="country-stats">
<div class="stat">
<span class="value">{{ c.total_impact }}</span>
<span class="label">Puntos de Impacto</span>
</div>
<div class="stat">
<span class="value">{{ c.news_count }}</span>
<span class="label">Noticias Relevantes</span>
</div>
</div>
<div class="heat-bar">
<!-- Simple visual bar based on impact (max approx 1000 for scaling) -->
<div class="bar-fill" style="width: {{ (c.total_impact / 1000) * 100 }}%; max-width: 100%;"></div>
</div>
</div>
{% else %}
<p>No hay datos de impacto suficientes aún. El sistema está analizando noticias...</p>
{% endfor %}
</div>
<style>
.monitor-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
margin-top: 20px;
}
.country-card {
padding: 20px;
border-left: 5px solid #3498db;
transition: transform 0.2s;
}
.country-card:hover {
transform: translateY(-5px);
}
.country-header h2 {
margin: 0;
font-size: 1.5rem;
}
.country-header a {
text-decoration: none;
color: inherit;
}
.country-stats {
display: flex;
justify-content: space-between;
margin: 15px 0;
}
.stat {
text-align: center;
}
.stat .value {
display: block;
font-size: 1.2rem;
font-weight: bold;
color: #2c3e50;
}
.stat .label {
font-size: 0.8rem;
color: #7f8c8d;
}
.heat-bar {
height: 6px;
background: #ecf0f1;
border-radius: 3px;
overflow: hidden;
}
.bar-fill {
height: 100%;
background: linear-gradient(90deg, #3498db, #e74c3c);
}
.btn-active {
background-color: #2c3e50;
color: white;
}
</style>
{% endblock %}

316
templates/noticia.html Normal file
View file

@ -0,0 +1,316 @@
{% extends "base.html" %}
{% block title %}
{{ dato.titulo_trad or dato.titulo_orig or 'Detalle de Noticia' }}
{% endblock %}
{% block content %}
{% set d = dato %}
{% if not d %}
<div class="card">
<p>No se encontró la noticia solicitada.</p>
</div>
{% else %}
<div class="card" id="main-article">
<div class="feed-header">
<h2 style="margin:0;">{{ d.titulo_trad or d.titulo_orig }}
{% if d.lang_to %}
<span class="badge">{{ d.lang_to|upper }}</span>
{% endif %}
</h2>
<div class="header-actions">
<a href="{{ url_for('pdf.export_noticia', noticia_id=d.noticia_id) }}" class="btn btn-small"
title="Exportar PDF" target="_blank">
<i class="fas fa-file-pdf"></i>
</a>
<button class="btn btn-small" onclick="toggleReadingMode()" title="Modo lectura">
<i class="fas fa-book-reader"></i>
</button>
{% if d.url %}
<a href="{{ d.url }}" target="_blank" class="btn btn-small">Ver fuente</a>
{% endif %}
</div>
</div>
<div class="feed-body">
<div class="noticia-meta">
{% if d.fecha %}
<i class="far fa-calendar-alt"></i>
{% if d.fecha is string %}
{{ d.fecha }}
{% else %}
{{ d.fecha.strftime('%d-%m-%Y %H:%M') }}
{% endif %}
{% endif %}
{% if d.fuente_nombre %} | <i class="fas fa-newspaper"></i> {{ d.fuente_nombre }}{% endif %}
{% if d.categoria %} | <i class="fas fa-tag"></i> {{ d.categoria }}{% endif %}
{% if d.pais %} | <i class="fas fa-globe"></i> {{ d.pais }}{% endif %}
</div>
{% if d.imagen_url %}
<div style="text-align:center;margin-bottom:16px;">
<img src="{{ d.imagen_url }}" alt="" loading="lazy" onerror="this.style.display='none';"
style="max-width: 100%; height: auto; border-radius: 8px;">
</div>
{% endif %}
{% if d.resumen_trad %}
<h3>Resumen (traducido)</h3>
<div>{{ d.resumen_trad|safe_html }}</div>
<hr>
{% endif %}
{% if d.resumen_orig %}
<h3>Resumen (original)</h3>
<div>{{ d.resumen_orig|safe_html }}</div>
{% endif %}
{% if tags %}
<div style="margin-top:12px;">
{% for t in tags %}
<span class="badge" title="{{ t.tipo }}">{{ t.valor }}</span>
{% endfor %}
</div>
{% endif %}
<!-- Share Buttons -->
<div class="share-section">
<span class="share-label">Compartir:</span>
<div class="share-buttons">
<a href="https://twitter.com/intent/tweet?text={{ (d.titulo_trad or d.titulo_orig)|urlencode }}&url={{ request.url|urlencode }}"
target="_blank" class="share-btn share-twitter" title="Twitter">
<i class="fab fa-twitter"></i>
</a>
<a href="https://wa.me/?text={{ (d.titulo_trad or d.titulo_orig)|urlencode }}%20{{ request.url|urlencode }}"
target="_blank" class="share-btn share-whatsapp" title="WhatsApp">
<i class="fab fa-whatsapp"></i>
</a>
<a href="https://t.me/share/url?url={{ request.url|urlencode }}&text={{ (d.titulo_trad or d.titulo_orig)|urlencode }}"
target="_blank" class="share-btn share-telegram" title="Telegram">
<i class="fab fa-telegram"></i>
</a>
<button class="share-btn share-copy" onclick="copyLink()" title="Copiar enlace">
<i class="fas fa-link"></i>
</button>
</div>
</div>
</div>
</div>
<style>
.share-section {
display: flex;
align-items: center;
gap: 1rem;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color, #eee);
}
.share-label {
font-size: 0.85rem;
color: var(--text-muted, #666);
font-weight: 500;
}
.share-buttons {
display: flex;
gap: 0.5rem;
}
.share-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
cursor: pointer;
text-decoration: none;
color: #fff;
font-size: 1rem;
transition: transform 0.2s, box-shadow 0.2s;
}
.share-btn:hover {
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.share-twitter {
background: #1DA1F2;
}
.share-whatsapp {
background: #25D366;
}
.share-telegram {
background: #0088cc;
}
.share-copy {
background: #111;
}
.dark-mode .share-copy {
background: #fff;
color: #111;
}
/* Reading Mode Styles */
.header-actions {
display: flex;
gap: 0.5rem;
}
body.reading-mode header,
body.reading-mode .main-nav,
body.reading-mode .share-section,
body.reading-mode .card:not(#main-article),
body.reading-mode .noticia-meta,
body.reading-mode .header-actions .btn:not([onclick*="Reading"]) {
display: none !important;
}
body.reading-mode {
background: #faf9f5;
}
body.reading-mode #main-article {
max-width: 700px;
margin: 2rem auto;
padding: 3rem;
font-size: 1.15rem;
line-height: 1.9;
box-shadow: none;
border: none;
}
body.reading-mode #main-article h2 {
font-size: 2.2rem;
line-height: 1.3;
margin-bottom: 1.5rem;
font-family: 'Playfair Display', Georgia, serif;
}
body.reading-mode #main-article img {
max-width: 100%;
height: auto;
border-radius: 8px;
}
body.reading-mode.dark-mode {
background: #111;
}
body.reading-mode.dark-mode #main-article {
background: #1a1a1a;
color: #e0e0e0;
}
/* Exit reading mode button */
.exit-reading-btn {
position: fixed;
top: 1rem;
right: 1rem;
background: #111;
color: #fff;
border: none;
padding: 0.75rem 1rem;
border-radius: 8px;
cursor: pointer;
font-size: 0.85rem;
z-index: 1000;
display: none;
}
body.reading-mode .exit-reading-btn {
display: flex;
align-items: center;
gap: 0.5rem;
}
</style>
<button class="exit-reading-btn" onclick="toggleReadingMode()">
<i class="fas fa-times"></i> Salir
</button>
<script>
function copyLink() {
navigator.clipboard.writeText(window.location.href).then(() => {
const btn = document.querySelector('.share-copy');
const icon = btn.querySelector('i');
icon.className = 'fas fa-check';
setTimeout(() => { icon.className = 'fas fa-link'; }, 2000);
});
}
function toggleReadingMode() {
document.body.classList.toggle('reading-mode');
// Scroll to top in reading mode
if (document.body.classList.contains('reading-mode')) {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}
// ESC key to exit reading mode
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && document.body.classList.contains('reading-mode')) {
toggleReadingMode();
}
});
</script>
{% if relacionadas %}
<div class="card" style="margin-top:16px;">
<div class="card-header">
<h3>Noticias relacionadas</h3>
</div>
<ul class="noticias-list">
{% for r in relacionadas %}
<li class="noticia-item">
{% if r.imagen_url %}
<div class="noticia-imagen"
style="width: 120px; height: 80px; flex-shrink: 0; overflow: hidden; border-radius: 4px;">
<img src="{{ r.imagen_url }}" loading="lazy" onerror="this.parentElement.style.display='none'"
style="width: 100%; height: 100%; object-fit: cover;">
</div>
{% endif %}
<div class="noticia-texto">
<h3 class="m0">
<a href="{{ url_for('noticia.noticia', tr_id=r.related_tr_id) if r.related_tr_id else r.url }}" {%
if not r.related_tr_id %}target="_blank" {% endif %}>
{{ r.titulo_trad or r.titulo }}
</a>
</h3>
<div class="noticia-meta">
{% if r.fecha %}
<i class="far fa-calendar-alt"></i>
{% if r.fecha is string %}
{{ r.fecha }}
{% else %}
{{ r.fecha.strftime('%d-%m-%Y %H:%M') }}
{% endif %}
{% endif %}
{% if r.fuente_nombre %} | {{ r.fuente_nombre }}{% endif %}
{% if r.score is defined %} | score {{ "%.3f"|format(r.score) }}{% endif %}
</div>
</div>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endif %}
{% endblock %}

550
templates/noticias.html Normal file
View file

@ -0,0 +1,550 @@
{% extends "base.html" %}
{% block title %}Últimas Noticias RSS{% endblock %}
{% block content %}
<div class="card" style="padding: 1.5rem;">
<!-- 🔥 CORREGIDO: url_for('home.home') -->
<form method="get" action="{{ url_for('home.home') }}" id="filter-form">
<input type="hidden" name="page" id="page" value="{{ page or 1 }}">
<input type="hidden" name="per_page" id="per_page" value="{{ per_page or 20 }}">
<input type="hidden" name="lang" id="lang" value="{{ (lang or 'es') }}">
{% if not use_tr %}
<input type="hidden" name="orig" id="orig" value="1">
{% else %}
<input type="hidden" name="orig" id="orig" value="">
{% endif %}
<input type="hidden" name="semantic" id="semantic" value="{{ '1' if use_semantic else '' }}">
<div class="filter-main-row" style="display: flex; gap: 10px; width: 100%;">
<div class="filter-search-box" style="flex: 1;">
<input type="search" name="q" id="q" placeholder="Buscar noticias..." value="{{ q or '' }}"
style="width: 100%; padding: 0.8rem 1rem; font-size: 1.1rem; border-radius: 8px; border: 1px solid var(--border-color);">
</div>
<div class="filter-actions" style="display: flex; gap: 5px;">
<button type="submit" class="btn" style="padding: 0.8rem 1.2rem; border-radius: 8px;" title="Buscar">
<i class="fas fa-search"></i>
</button>
<a href="{{ url_for('home.home') }}" class="btn btn-secondary"
style="padding: 0.8rem 1.2rem; border-radius: 8px;" title="Limpiar filtros">
<i class="fas fa-eraser"></i>
</a>
</div>
</div>
<div class="filter-row">
<div class="filter-group">
<label for="categoria_id">Categoría</label>
<select name="categoria_id" id="categoria_id">
<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 class="filter-group">
<label for="continente_id">Continente</label>
<select name="continente_id" id="continente_id">
<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 class="filter-group">
<label for="pais_id">País</label>
<select name="pais_id" id="pais_id">
<option value="">— Todos —</option>
{% for pais in paises %}
<option value="{{ pais.id }}" data-continente-id="{{ pais.continente_id }}" {% if pais_id==pais.id
%}selected{% endif %}>
{{ pais.nombre|country_flag }} {{ pais.nombre }}
</option>
{% endfor %}
</select>
</div>
<div class="filter-group">
<label for="fecha">Fecha</label>
<input type="date" name="fecha" id="fecha" value="{{ fecha_filtro or '' }}">
</div>
<div class="filter-group toggle-group">
<label class="switch" style="margin-bottom: 0;">
<input type="checkbox" id="semantic-toggle" {% if use_semantic %}checked{% endif %}>
<span class="slider"></span>
</label>
<span style="font-size: 0.85rem; font-weight: 600;">Búsqueda IA</span>
</div>
</div>
</form>
</div>
{% if session.get('user_id') and recent_searches_with_results and not q and page == 1 %}
<div class="search-history-home" style="margin-bottom: 3rem; padding: 0 10px;">
<h3 style="margin-bottom: 20px; color: var(--text-color); font-weight: 600; padding-left: 10px;">
<i class="fas fa-history"></i> Tu Actividad Reciente
</h3>
<div class="timeline-container">
{% for search in recent_searches_with_results %}
<div class="timeline-item" id="search-block-{{ search.id }}">
<div class="timeline-dot"></div>
<div class="timeline-content search-block-container">
<button onclick="confirmDeleteSearch('{{ search.id }}')" class="btn-delete-search"
title="Eliminar este bloque">
<i class="fas fa-times"></i>
</button>
{% set search_url = url_for('home.home', q=search.query, pais_id=search.pais_id,
categoria_id=search.categoria_id) %}
<a href="{{ search_url }}" class="search-block-link" style="text-decoration: none; color: inherit;">
<div class="card search-history-card">
<div class="timeline-header">
<div class="timeline-title">
{% if search.query %}
<span class="search-query">"{{ search.query }}"</span>
{% endif %}
{% if search.pais_nombre %}
<span class="search-tag"><i class="fas fa-globe-americas"></i> {{ search.pais_nombre
}}</span>
{% endif %}
{% if search.categoria_nombre %}
<span class="search-tag"><i class="fas fa-tag"></i> {{ search.categoria_nombre }}</span>
{% endif %}
{% if not search.query and not search.pais_nombre and not search.categoria_nombre %}
<span class="search-query">Búsqueda General</span>
{% endif %}
</div>
<div class="timeline-meta">
<span title="{{ search.searched_at.strftime('%d/%m/%Y %H:%M') }}">
{{ search.searched_at.strftime('%H:%M') }}
</span>
</div>
</div>
<div class="search-results-preview">
{% if search.noticias %}
<ul class="timeline-news-list">
{% for noticia in search.noticias %}
<li>
{% if noticia.traduccion_id %}
<a href="{{ url_for('noticia.noticia', tr_id=noticia.traduccion_id) }}"
class="result-tile-link">
{% else %}
<a href="{{ url_for('noticia.noticia', id=noticia.id) }}"
class="result-tile-link">
{% endif %}
<span class="result-title">
{{ noticia.titulo_traducido if noticia.tiene_traduccion else
noticia.titulo_original }}
</span>
<span class="result-source">{{ noticia.fuente_nombre }}</span>
</a>
</li>
{% endfor %}
</ul>
{% else %}
<p class="no-results">Sin resultados nuevos</p>
{% endif %}
</div>
<div class="timeline-footer">
<i class="far fa-newspaper"></i> {{ search.results_count }} resultados encontrados
<span class="view-more">Ver ahora <i class="fas fa-arrow-right"></i></span>
</div>
</div>
</a>
</div>
</div>
{% endfor %}
</div>
</div>
<style>
.timeline-container {
position: relative;
padding-left: 30px;
border-left: 2px solid rgba(108, 99, 255, 0.3);
/* Var accent-color opacity */
margin-left: 10px;
}
.timeline-item {
position: relative;
margin-bottom: 30px;
}
.timeline-dot {
position: absolute;
left: -37px;
top: 20px;
width: 12px;
height: 12px;
background: var(--accent-color, #6c63ff);
border-radius: 50%;
border: 2px solid var(--bg-color, #f4f6f8);
box-shadow: 0 0 0 4px rgba(108, 99, 255, 0.2);
}
.timeline-content {
position: relative;
}
.search-history-card {
padding: 0;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.05);
background: var(--card-bg, #fff);
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
}
.timeline-header {
padding: 15px 20px;
background: rgba(108, 99, 255, 0.05);
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
display: flex;
justify-content: space-between;
align-items: center;
}
.timeline-title {
font-weight: 600;
font-size: 1.1rem;
color: var(--text-color);
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.search-query {
color: var(--accent-color);
font-weight: 700;
}
.search-tag {
font-size: 0.85rem;
background: rgba(0, 0, 0, 0.05);
padding: 2px 8px;
border-radius: 4px;
font-weight: normal;
color: #666;
}
.timeline-meta {
font-size: 0.85rem;
color: #888;
white-space: nowrap;
margin-left: 10px;
}
.search-results-preview {
padding: 15px 20px;
}
.timeline-news-list {
list-style: none;
padding: 0;
margin: 0;
}
.timeline-news-list li {
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
.timeline-news-list li:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.result-title {
display: block;
font-size: 0.95rem;
font-weight: 500;
color: var(--text-color);
line-height: 1.4;
margin-bottom: 4px;
transition: color 0.2s;
}
.result-source {
font-size: 0.8rem;
color: #888;
}
.result-tile-link:hover .result-title {
color: var(--accent-color);
}
.timeline-footer {
padding: 10px 20px;
background: rgba(0, 0, 0, 0.02);
border-top: 1px solid rgba(0, 0, 0, 0.05);
font-size: 0.85rem;
color: #666;
display: flex;
justify-content: space-between;
align-items: center;
}
.view-more {
color: var(--accent-color);
font-weight: 500;
opacity: 0;
transition: opacity 0.2s;
}
.btn-delete-search {
position: absolute;
top: 10px;
right: 10px;
z-index: 10;
border: none;
background: #fff;
color: #ccc;
cursor: pointer;
transition: all 0.2s;
padding: 6px;
border-radius: 50%;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.search-block-container:hover .btn-delete-search {
color: #ff4d4d;
}
.timeline-item:hover .search-history-card {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08);
}
.timeline-item:hover .view-more {
opacity: 1;
}
/* Dark Mode Adjustments */
.dark-mode .timeline-container {
border-left-color: rgba(108, 99, 255, 0.2);
}
.dark-mode .timeline-dot {
border-color: #1a2635;
/* Dark bg */
}
.dark-mode .search-history-card {
background: #252e3e;
border-color: #333;
}
.dark-mode .timeline-header {
background: rgba(255, 255, 255, 0.03);
border-bottom-color: #333;
}
.dark-mode .search-tag {
background: rgba(255, 255, 255, 0.1);
color: #ccc;
}
.dark-mode .timeline-news-list li {
border-bottom-color: #333;
}
.dark-mode .timeline-footer {
background: rgba(0, 0, 0, 0.2);
border-top-color: #333;
}
.dark-mode .btn-delete-search {
background: #333;
color: #666;
}
.dark-mode .result-source {
color: #777;
}
.no-results {
text-align: center;
color: #888;
font-style: italic;
padding: 10px;
}
</style>
<script>
function confirmDeleteSearch(searchId) {
if (confirm('¿Eliminar este bloque del historial?')) {
fetch(`/delete_search/${searchId}`, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
const block = document.getElementById(`search-block-${searchId}`);
if (block) {
block.style.opacity = '0';
block.style.transform = 'scale(0.9)';
setTimeout(() => {
block.remove();
// If no blocks left, maybe hide the container?
const container = document.querySelector('.search-history-home div[style*="grid"]');
if (container && container.children.length === 0) {
document.querySelector('.search-history-home').remove();
}
}, 300);
}
} else {
alert('Error al eliminar: ' + (data.error || 'Desconocido'));
}
})
.catch(err => {
console.error('Error:', err);
alert('Error de conexión');
});
}
}
</script>
{% endif %}
<div id="noticias-container">
{% include '_noticias_list.html' %}
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
const form = document.getElementById('filter-form');
const continenteSelect = document.getElementById('continente_id');
const paisSelect = document.getElementById('pais_id');
const categoriaSelect = document.getElementById('categoria_id');
const fechaInput = document.getElementById('fecha');
const qInput = document.getElementById('q');
const pageInput = document.getElementById('page');
const origInput = document.getElementById('orig');
const langInput = document.getElementById('lang');
window.setPage = function (p) {
if (pageInput) pageInput.value = p;
};
function setPage1() { pageInput.value = 1; }
function filtrarPaises() {
const continenteId = continenteSelect.value;
for (let i = 1; i < paisSelect.options.length; i++) {
const option = paisSelect.options[i];
const paisContinenteId = option.getAttribute('data-continente-id');
option.style.display = (!continenteId || paisContinenteId === continenteId) ? '' : 'none';
}
const opcionSeleccionada = paisSelect.options[paisSelect.selectedIndex];
if (opcionSeleccionada && opcionSeleccionada.style.display === 'none') {
paisSelect.value = '';
}
}
async function cargarNoticiasFromURL(url) {
const container = document.getElementById('noticias-container');
// Ensure minimum height to prevent collapse and scroll jump
container.style.minHeight = container.offsetHeight + 'px';
container.style.opacity = '0.5';
try {
const response = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
const html = await response.text();
container.innerHTML = html;
} catch (error) {
console.error('Error al filtrar noticias:', error);
container.innerHTML = '<p style="color:var(--error-color); text-align:center;">Error al cargar las noticias.</p>';
} finally {
container.style.opacity = '1';
// Optional: remove minHeight if you want it to shrink back, but keeping it is often safer until next interaction
// container.style.minHeight = '';
}
}
window.cargarNoticias = async function (keepPage) {
if (!keepPage) setPage1();
const formData = new FormData(form);
const params = new URLSearchParams(formData);
const newUrl = `${form.action}?${params.toString()}`;
await cargarNoticiasFromURL(newUrl);
window.history.pushState({ path: newUrl }, '', newUrl);
};
form.addEventListener('submit', function (e) {
e.preventDefault();
cargarNoticias(false);
});
const toggleOrig = document.getElementById('toggle-orig');
const toggleTr = document.getElementById('toggle-tr');
if (toggleOrig) {
toggleOrig.addEventListener('click', function (e) {
e.preventDefault();
origInput.value = '1';
cargarNoticias(false);
});
}
if (toggleTr) {
toggleTr.addEventListener('click', function (e) {
e.preventDefault();
origInput.value = '';
if (!langInput.value) langInput.value = 'es';
cargarNoticias(false);
});
}
continenteSelect.addEventListener('change', function () {
filtrarPaises();
cargarNoticias(false);
});
paisSelect.addEventListener('change', function () {
cargarNoticias(false);
});
categoriaSelect.addEventListener('change', function () {
cargarNoticias(false);
});
fechaInput.addEventListener('change', function () {
cargarNoticias(false);
});
let qTimer = null;
qInput.addEventListener('input', function () {
if (qTimer) clearTimeout(qTimer);
qTimer = setTimeout(() => {
cargarNoticias(false);
}, 450);
});
const semanticToggle = document.getElementById('semantic-toggle');
const semanticInput = document.getElementById('semantic');
if (semanticToggle) {
semanticToggle.addEventListener('change', function () {
semanticInput.value = this.checked ? '1' : '';
cargarNoticias(false);
});
}
filtrarPaises();
window.addEventListener('popstate', function (e) {
const url = (e.state && e.state.path) ? e.state.path : window.location.href;
cargarNoticiasFromURL(url);
});
});
</script>
{% endblock %}

View file

@ -0,0 +1,271 @@
{% extends "base.html" %}
{% block title %}{{ parrilla.nombre }}{% endblock %}
{% block content %}
<div class="container">
<div class="page-header">
<div class="header-content">
<h1>🎬 {{ parrilla.nombre }}</h1>
<span class="badge {% if parrilla.activo %}badge-success{% else %}badge-secondary{% endif %}">
{% if parrilla.activo %}Activa{% else %}Inactiva{% endif %}
</span>
</div>
<div class="header-actions">
<button onclick="generarVideo({{ parrilla.id }})" class="btn btn-primary">▶ Generar Video Ahora</button>
<a href="{{ url_for('parrillas.index') }}" class="btn btn-secondary">Volver</a>
</div>
</div>
<div class="grid-layout">
<!-- Sidebar Info -->
<div class="sidebar">
<div class="info-card">
<h3>Configuración</h3>
<p class="desc">{{ parrilla.descripcion }}</p>
<ul class="info-list">
<li><strong>Tipo:</strong> {{ parrilla.tipo_filtro }}</li>
{% if parrilla.pais_nombre %}
<li><strong>País:</strong> {{ parrilla.pais_nombre }}</li>
{% endif %}
{% if parrilla.categoria_nombre %}
<li><strong>Categoría:</strong> {{ parrilla.categoria_nombre }}</li>
{% endif %}
<li><strong>Máx. Noticias:</strong> {{ parrilla.max_noticias }}</li>
<li><strong>Frecuencia:</strong> {{ parrilla.frecuencia }}</li>
<li><strong>Idioma:</strong> {{ parrilla.idioma_voz }}</li>
<li><strong>Subtítulos:</strong> {% if parrilla.include_subtitles %}Sí{% else %}No{% endif %}</li>
<li><strong>Última Gen:</strong> {{ parrilla.ultima_generacion or 'Nunca' }}</li>
</ul>
</div>
<div class="preview-card">
<h3>📰 Preview Contenido</h3>
<div id="preview-list">Cargando noticias...</div>
</div>
</div>
<!-- Main Content -->
<div class="main-content">
<h2>Videos Generados</h2>
{% if videos %}
<div class="videos-list">
{% for video in videos %}
<div class="video-item status-{{ video.status }}">
<div class="video-info">
<h4>{{ video.titulo }}</h4>
<span class="date">{{ video.fecha_generacion }}</span>
<span class="status-badge {{ video.status }}">{{ video.status }}</span>
{% if video.error_message %}
<p class="error-msg">{{ video.error_message }}</p>
{% endif %}
</div>
<div class="video-actions">
{% if video.status == 'completed' %}
<audio controls style="height: 30px; margin-right: 10px;">
<source src="{{ url_for('parrillas.serve_file', video_id=video.id, filename='audio.wav') }}"
type="audio/wav">
Tu navegador no soporta audio.
</audio>
<a href="{{ url_for('parrillas.serve_file', video_id=video.id, filename='script.txt') }}"
target="_blank" class="btn btn-sm btn-outline">📝 Ver Script</a>
<a href="{{ url_for('parrillas.serve_file', video_id=video.id, filename='audio.wav') }}"
download class="btn btn-sm btn-outline">📥 Bajar Audio</a>
{% endif %}
<button onclick="verLog({{ video.id }})" class="btn btn-sm btn-outline">📜 Ver Log</button>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<p>No se han generado videos todavía.</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Log Modal -->
<div id="logModal" class="modal"
style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.5); z-index:1000;">
<div class="modal-content"
style="background:var(--paper-color); margin:10% auto; padding:20px; width:80%; max-width:800px; border-radius:10px; position:relative;">
<span onclick="document.getElementById('logModal').style.display='none'"
style="position:absolute; top:10px; right:15px; cursor:pointer; font-size:24px;">&times;</span>
<h3>Log de Generación</h3>
<pre id="logContent"
style="background:#1e1e1e; color:#d4d4d4; padding:15px; border-radius:5px; overflow:auto; max-height:500px; font-family:monospace;"></pre>
</div>
</div>
<style>
.grid-layout {
display: grid;
grid-template-columns: 350px 1fr;
gap: 30px;
margin-top: 20px;
}
.info-card,
.preview-card {
background: var(--paper-color);
padding: 20px;
border-radius: 12px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.info-card h3,
.preview-card h3 {
margin-top: 0;
border-bottom: 2px solid var(--accent-color);
padding-bottom: 10px;
margin-bottom: 15px;
}
.info-list {
list-style: none;
padding: 0;
}
.info-list li {
padding: 8px 0;
border-bottom: 1px solid var(--border-color);
}
.videos-list {
display: flex;
flex-direction: column;
gap: 15px;
}
.video-item {
background: var(--paper-color);
padding: 15px;
border-radius: 8px;
display: flex;
justify-content: space-between;
align-items: center;
border-left: 4px solid transparent;
}
.video-item.status-completed {
border-left-color: #28a745;
}
.video-item.status-error {
border-left-color: #dc3545;
}
.video-item.status-processing {
border-left-color: #ffc107;
}
.status-badge {
padding: 2px 8px;
border-radius: 4px;
font-size: 0.8em;
text-transform: uppercase;
font-weight: bold;
}
.status-badge.completed {
background: #d4edda;
color: #155724;
}
.status-badge.error {
background: #f8d7da;
color: #721c24;
}
.status-badge.processing {
background: #fff3cd;
color: #856404;
}
.preview-item {
padding: 10px;
background: rgba(0, 0, 0, 0.02);
margin-bottom: 10px;
border-radius: 6px;
font-size: 0.9em;
}
.btn-sm {
padding: 4px 8px;
font-size: 0.85em;
}
@media (max-width: 768px) {
.grid-layout {
grid-template-columns: 1fr;
}
}
</style>
<script>
async function loadPreview() {
try {
const res = await fetch(`/parrillas/api/{{ parrilla.id }}/preview`);
const data = await res.json();
const container = document.getElementById('preview-list');
if (data.noticias && data.noticias.length > 0) {
container.innerHTML = data.noticias.map(n => `
<div class="preview-item">
<strong>${n.titulo_trad || n.titulo}</strong><br>
<small>${n.fecha}</small>
</div>
`).join('');
} else {
container.innerHTML = '<p>No hay noticias recientes que coincidan con los filtros.</p>';
}
} catch (e) {
console.error(e);
document.getElementById('preview-list').innerHTML = 'Error cargando preview.';
}
}
async function generarVideo(id) {
if (!confirm('¿Generar nuevo video?')) return;
try {
const res = await fetch(`/parrillas/api/${id}/generar`, { method: 'POST' });
const data = await res.json();
if (data.success) {
location.reload();
} else {
alert('Error: ' + data.message);
}
} catch (e) {
alert('Error de conexión');
}
}
async function verLog(id) {
const modal = document.getElementById('logModal');
const content = document.getElementById('logContent');
content.textContent = "Cargando log...";
modal.style.display = 'block';
try {
const res = await fetch(`/parrillas/files/${id}/generation.log`);
if (res.ok) {
const text = await res.text();
content.textContent = text || "Log vacío.";
} else {
content.textContent = "No se pudo cargar el log. (Posiblemente el video es antiguo o falló antes de crear logs).";
}
} catch (e) {
content.textContent = "Error de conexión al cargar log.";
}
}
// Init
loadPreview();
</script>
{% endblock %}
```

View file

@ -0,0 +1,237 @@
{% extends "base.html" %}
{% block title %}Nueva Parrilla{% endblock %}
{% block content %}
<div class="container">
<div class="page-header">
<h1>🎬 Nueva Parrilla de Video</h1>
<p>Configura un resumen automático de noticias</p>
</div>
<div class="form-card">
<form method="POST" action="{{ url_for('parrillas.nueva') }}">
<div class="form-group">
<label for="nombre">Nombre de la Parrilla*</label>
<input type="text" id="nombre" name="nombre" required placeholder="Ej: Noticias de Bulgaria"
class="form-control">
</div>
<div class="form-group">
<label for="descripcion">Descripción</label>
<textarea id="descripcion" name="descripcion" rows="3" class="form-control"
placeholder="Descripción opcional..."></textarea>
</div>
<hr>
<h3>Filtros de Contenido</h3>
<div class="form-group">
<label for="tipo_filtro">Tipo de Filtro*</label>
<select id="tipo_filtro" name="tipo_filtro" class="form-control" onchange="toggleFiltros()">
<option value="pais">Por País</option>
<option value="categoria">Por Categoría</option>
<option value="entidad">Por Persona/Organización</option>
<option value="custom">Personalizado</option>
</select>
</div>
<div id="filtro_pais" class="filtro-section">
<div class="form-group">
<label for="pais_id">País</label>
<select id="pais_id" name="pais_id" class="form-control">
<option value="">Seleccionar País...</option>
{% for p in paises %}
<option value="{{ p.id }}">{{ p.nombre }}</option>
{% endfor %}
</select>
</div>
</div>
<div id="filtro_categoria" class="filtro-section" style="display:none">
<div class="form-group">
<label for="categoria_id">Categoría</label>
<select id="categoria_id" name="categoria_id" class="form-control">
<option value="">Seleccionar Categoría...</option>
{% for c in categorias %}
<option value="{{ c.id }}">{{ c.nombre }}</option>
{% endfor %}
</select>
</div>
</div>
<div id="filtro_entidad" class="filtro-section" style="display:none">
<div class="form-row">
<div class="form-group col-md-8">
<label for="entidad_nombre">Nombre Entidad</label>
<input type="text" id="entidad_nombre" name="entidad_nombre" class="form-control"
placeholder="Ej: Donald Trump">
</div>
<div class="form-group col-md-4">
<label for="entidad_tipo">Tipo</label>
<select id="entidad_tipo" name="entidad_tipo" class="form-control">
<option value="persona">Persona</option>
<option value="organizacion">Organización</option>
</select>
</div>
</div>
</div>
<hr>
<h3>Configuración de Generación</h3>
<div class="form-row">
<div class="form-group col-md-6">
<label for="max_noticias">Máx. Noticias</label>
<input type="number" id="max_noticias" name="max_noticias" value="5" min="1" max="20"
class="form-control">
</div>
<div class="form-group col-md-6">
<label for="duracion_maxima">Duración Máx. (segundos)</label>
<input type="number" id="duracion_maxima" name="duracion_maxima" value="180" class="form-control">
</div>
</div>
<div class="form-row">
<div class="form-group col-md-6">
<label for="idioma_voz">Idioma Voz</label>
<select id="idioma_voz" name="idioma_voz" class="form-control">
<option value="es">Español</option>
<option value="en">Inglés</option>
</select>
</div>
<div class="form-group col-md-6">
<label for="frecuencia">Frecuencia</label>
<select id="frecuencia" name="frecuencia" class="form-control">
<option value="manual">Manual</option>
<option value="daily">Diaria</option>
<option value="weekly">Semanal</option>
</select>
</div>
</div>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" name="include_images" checked> Incluir Imágenes
</label>
<label>
<input type="checkbox" name="include_subtitles" checked> Generar Subtítulos
</label>
<label>
<input type="checkbox" name="activo" checked> Activa
</label>
</div>
<div class="form-actions">
<a href="{{ url_for('parrillas.index') }}" class="btn btn-secondary">Cancelar</a>
<button type="submit" class="btn btn-primary">Crear Parrilla</button>
</div>
</form>
</div>
</div>
<style>
.form-card {
background: var(--paper-color);
padding: 30px;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
max-width: 800px;
margin: 0 auto;
}
.form-group {
margin-bottom: 20px;
}
.form-row {
display: flex;
gap: 20px;
}
.col-md-6 {
flex: 0 0 calc(50% - 10px);
}
.col-md-8 {
flex: 0 0 calc(66.66% - 10px);
}
.col-md-4 {
flex: 0 0 calc(33.33% - 10px);
}
label {
display: block;
margin-bottom: 8px;
font-weight: 500;
}
.form-control {
width: 100%;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: rgba(255, 255, 255, 0.9);
font-size: 1rem;
}
.checkbox-group {
display: flex;
gap: 20px;
margin-top: 10px;
}
.form-actions {
margin-top: 30px;
display: flex;
justify-content: flex-end;
gap: 15px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
text-decoration: none;
transition: all 0.2s;
}
.btn-primary {
background: var(--accent-color);
color: white;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn:hover {
opacity: 0.9;
transform: translateY(-1px);
}
hr {
margin: 30px 0;
border: none;
border-top: 1px solid var(--border-color);
}
</style>
<script>
function toggleFiltros() {
const tipo = document.getElementById('tipo_filtro').value;
document.querySelectorAll('.filtro-section').forEach(el => el.style.display = 'none');
if (tipo === 'pais') document.getElementById('filtro_pais').style.display = 'block';
if (tipo === 'categoria') document.getElementById('filtro_categoria').style.display = 'block';
if (tipo === 'entidad') document.getElementById('filtro_entidad').style.display = 'block';
}
// Init
toggleFiltros();
</script>
{% endblock %}

View file

@ -0,0 +1,245 @@
{% extends "base.html" %}
{% block title %}Parrillas de Videos{% endblock %}
{% block content %}
<div class="container">
<div class="page-header">
<h1>🎬 Parrillas de Videos Automatizados</h1>
<p>Genera videos automáticos de noticias filtradas por país, categoría o entidad</p>
<a href="{{ url_for('parrillas.nueva') }}" class="btn btn-primary"> Nueva Parrill</a>
</div>
{% if parrillas %}
<div class="parrillas-grid">
{% for p in parrillas %}
<div class="parrilla-card">
<div class="card-header">
<h3>{{ p.nombre }}</h3>
<span class="badge {% if p.activo %}badge-success{% else %}badge-secondary{% endif %}">
{% if p.activo %}Activa{% else %}Inactiva{% endif %}
</span>
</div>
<div class="card-body">
<p class="description">{{ p.descripcion or 'Sin descripción' }}</p>
<div class="metadatos">
<div class="meta-item">
<strong>Tipo:</strong> {{ p.tipo_filtro }}
</div>
{% if p.pais_nombre %}
<div class="meta-item">
<strong>País:</strong> {{ p.pais_nombre }}
</div>
{% endif %}
{% if p.categoria_nombre %}
<div class="meta-item">
<strong>Categoría:</strong> {{ p.categoria_nombre }}
</div>
{% endif %}
{% if p.entidad_nombre %}
<div class="meta-item">
<strong>Entidad:</strong> {{ p.entidad_nombre }} ({{ p.entidad_tipo }})
</div>
{% endif %}
<div class="meta-item">
<strong>Videos generados:</strong> {{ p.total_videos }}
</div>
<div class="meta-item">
<strong>Frecuencia:</strong> {{ p.frecuencia }}
</div>
</div>
</div>
<div class="card-footer">
<a href="{{ url_for('parrillas.ver', id=p.id) }}" class="btn btn-secondary">Ver Detalles</a>
<button onclick="toggleParrilla({{ p.id }})" class="btn btn-warning">
{% if p.activo %}Desactivar{% else %}Activar{% endif %}
</button>
<button onclick="generarVideo({{ p.id }})" class="btn btn-success">Generar Video</button>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<p>No hay parrillas configuradas.</p>
<a href="{{ url_for('parrillas.nueva') }}" class="btn btn-primary">Crear tu primera parrilla</a>
</div>
{% endif %}
</div>
<style>
.parrillas-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 20px;
margin-top: 30px;
}
.parrilla-card {
background: var(--paper-color);
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: transform 0.2s;
}
.parrilla-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.card-header {
background: var(--accent-color);
color: white;
padding: 15px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header h3 {
margin: 0;
font-size: 1.2em;
}
.badge {
padding: 4px 12px;
border-radius: 12px;
font-size: 0.85em;
font-weight: bold;
}
.badge-success {
background: #28a745;
}
.badge-secondary {
background: #6c757d;
}
.card-body {
padding: 20px;
}
.description {
color: var(--text-secondary);
margin-bottom: 15px;
font-style: italic;
}
.metadatos {
display: grid;
gap: 8px;
}
.meta-item {
font-size: 0.9em;
padding: 6px;
background: rgba(0, 0, 0, 0.03);
border-radius: 4px;
}
.card-footer {
padding: 15px 20px;
border-top: 1px solid var(--border-color);
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
text-decoration: none;
display: inline-block;
}
.btn-primary {
background: var(--accent-color);
color: white;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-warning {
background: #ffc107;
color: black;
}
.btn-success {
background: #28a745;
color: white;
}
.btn:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-secondary);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 15px;
}
.page-header h1 {
margin: 0;
}
.page-header p {
color: var(--text-secondary);
margin: 5px 0;
}
</style>
<script>
async function toggleParrilla(id) {
try {
const res = await fetch(`/parrillas/api/${id}/toggle`, { method: 'POST' });
const data = await res.json();
if (data.success) {
location.reload();
} else {
alert('Error al cambiar estado');
}
} catch (e) {
alert('Error: ' + e.message);
}
}
async function generarVideo(id) {
if (!confirm('¿Generar nuevo video para esta parrilla?')) return;
try {
const res = await fetch(`/parrillas/api/${id}/generar`, { method: 'POST' });
const data = await res.json();
if (data.success) {
alert('Video en cola para generación!');
} else {
alert('Error: ' + (data.error || 'Unknown error'));
}
} catch (e) {
alert('Error: ' + e.message);
}
}
</script>
{% endblock %}

View file

@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>{{ titulo }}</title>
<style>
body {
font-family: 'Helvetica', 'Arial', sans-serif;
line-height: 1.6;
color: #333;
max-width: 800px;
margin: 0 auto;
}
h1 {
color: #2c3e50;
font-size: 24px;
margin-bottom: 10px;
border-bottom: 2px solid #3498db;
padding-bottom: 10px;
}
.meta {
color: #7f8c8d;
font-size: 12px;
margin-bottom: 20px;
font-style: italic;
}
.meta span {
margin-right: 15px;
}
.image-container {
text-align: center;
margin-bottom: 20px;
}
img {
max-width: 100%;
max-height: 300px;
border-radius: 4px;
}
.content {
font-size: 14px;
text-align: justify;
}
.footer {
margin-top: 30px;
padding-top: 10px;
border-top: 1px solid #eee;
font-size: 10px;
color: #999;
text-align: center;
}
a {
color: #3498db;
text-decoration: none;
}
</style>
</head>
<body>
<h1>{{ titulo }}</h1>
<div class="meta">
<span>📅 {{ fecha }}</span>
<span>📰 {{ fuente }}</span>
{% if categoria %}<span>🏷️ {{ categoria }}</span>{% endif %}
</div>
{% if imagen_url %}
<div class="image-container">
<img src="{{ imagen_url }}" alt="Imagen noticia">
</div>
{% endif %}
<div class="content">
{{ resumen | safe }}
</div>
<div class="footer">
<p>Generado por The Daily Feed - <a href="{{ url }}">Leer original</a></p>
</div>
</body>
</html>

50
templates/register.html Normal file
View file

@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %}Registrarse - The Daily Feed{% endblock %}
{% block content %}
<div style="max-width: 500px; margin: 40px auto; padding: 30px; background: #f9f9f9; border-radius: 10px;">
<h2 style="text-align: center; margin-bottom: 30px;">Crear Cuenta</h2>
<form method="post" action="{{ url_for('auth.register') }}">
<div style="margin-bottom: 20px;">
<label for="username" style="display: block; margin-bottom: 5px; font-weight: 500;">Nombre de usuario
*</label>
<input type="text" id="username" name="username" required minlength="3" maxlength="50"
value="{{ username or '' }}"
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px;">
<small style="color: #666; font-size: 12px;">Mínimo 3 caracteres. Solo letras, números, - y _</small>
</div>
<div style="margin-bottom: 20px;">
<label for="email" style="display: block; margin-bottom: 5px; font-weight: 500;">Email *</label>
<input type="email" id="email" name="email" required value="{{ email or '' }}"
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px;">
</div>
<div style="margin-bottom: 20px;">
<label for="password" style="display: block; margin-bottom: 5px; font-weight: 500;">Contraseña *</label>
<input type="password" id="password" name="password" required minlength="6" maxlength="128"
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px;">
<small style="color: #666; font-size: 12px;">Mínimo 6 caracteres</small>
</div>
<div style="margin-bottom: 30px;">
<label for="password_confirm" style="display: block; margin-bottom: 5px; font-weight: 500;">Confirmar
contraseña *</label>
<input type="password" id="password_confirm" name="password_confirm" required minlength="6" maxlength="128"
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px;">
</div>
<button type="submit"
style="width: 100%; padding: 12px; background: #6c63ff; color: white; border: none; border-radius: 5px; font-size: 16px; font-weight: 600; cursor: pointer;">
Crear cuenta
</button>
</form>
<div style="text-align: center; margin-top: 20px;">
<p style="color: #666;">¿Ya tienes cuenta? <a href="{{ url_for('auth.login') }}"
style="color: #6c63ff; text-decoration: none; font-weight: 500;">Inicia sesión</a></p>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block title %}Restaurar Backup Completo{% endblock %}
{% block content %}
<div class="card">
<div class="card-header">
<h3>Restaurar Backup Completo</h3>
</div>
<div class="card-body">
<p>
Sube un archivo <strong>.zip</strong> generado desde
<em>"Backup Completo (.zip)"</em> en el dashboard.
</p>
<div
style="background: #fff3cd; border: 1px solid #ffeeba; padding: 10px 12px; border-radius: 8px; margin: 15px 0;">
<strong>⚠ Atención:</strong>
<ul style="margin: 8px 0 0 18px; padding: 0;">
<li>Se <strong>vaciarán</strong> las tablas <code>feeds</code> y <code>fuentes_url</code>.</li>
<li>Los datos de esos CSV se volverán a cargar desde el backup.</li>
<li>No se tocan noticias, traducciones ni tags.</li>
</ul>
</div>
<form action="{{ url_for('restore_completo') }}" method="post" enctype="multipart/form-data">
<div class="form-group" style="margin-bottom: 15px;">
<label for="backup_file"><strong>Archivo .zip de backup completo</strong></label>
<input type="file" id="backup_file" name="backup_file" required style="display:block; margin-top:8px;">
</div>
<div style="margin-top: 20px; display:flex; gap:10px;">
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">Cancelar</a>
<button type="submit" class="btn btn-danger">
<i class="fas fa-exclamation-triangle"></i>
Restaurar desde backup
</button>
</div>
</form>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block title %}Restaurar Feeds{% endblock %}
{% block content %}
<h1>Restaurar Feeds</h1>
<div class="card">
<form method="post" enctype="multipart/form-data" action="{{ url_for('backup.restore_feeds') }}">
<label>Archivo CSV</label>
<input type="file" name="file" required>
<button class="btn" style="margin-top:15px;width:100%;">Restaurar</button>
</form>
</div>
<div class="card">
<h3>Formato esperado</h3>
<code>id,nombre,descripcion,url,categoria_id,categoria,pais_id,pais,idioma,activo,fallos</code>
</div>
{% endblock %}

View file

@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}Importar Fuentes URL{% endblock %}
{% block content %}
<h1>Importar Fuentes URL</h1>
<div class="card">
<form method="post" enctype="multipart/form-data" action="{{ url_for('restore_urls') }}">
<label>Archivo CSV:</label>
<input type="file" name="file" required>
<button class="btn" style="margin-top:15px;">Importar</button>
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">Cancelar</a>
</form>
</div>
{% endblock %}

156
templates/resumen.html Normal file
View file

@ -0,0 +1,156 @@
{% extends "base.html" %}
{% block title %}Resumen Diario - {{ current_date.strftime('%d/%m/%Y') }}{% endblock %}
{% block content %}
<div class="summary-header">
<div class="date-nav">
<a href="{{ url_for('resumen.diario', date=prev_date) }}" class="btn btn-small btn-secondary">
<i class="fas fa-chevron-left"></i> Anterior
</a>
<h1>Resumen Diario <small>{{ current_date.strftime('%d/%m/%Y') }}</small></h1>
{% if next_date %}
<a href="{{ url_for('resumen.diario', date=next_date) }}" class="btn btn-small btn-secondary">
Siguiente <i class="fas fa-chevron-right"></i>
</a>
{% else %}
<span style="width: 100px;"></span>
{% endif %}
</div>
</div>
{% if not noticias_by_cat %}
<div class="card" style="text-align: center; padding: 40px;">
<i class="far fa-newspaper" style="font-size: 3rem; color: #ccc; margin-bottom: 20px;"></i>
<h3>No hay noticias para este día</h3>
<p>Prueba navegando a días anteriores.</p>
</div>
{% else %}
<div class="summary-grid">
{% for categoria, noticias in noticias_by_cat.items() %}
<div class="category-block card">
<h2 class="category-title">{{ categoria }}</h2>
<ul class="summary-list">
{% for n in noticias %}
<li class="summary-item">
{% if n.imagen_url %}
<div class="summary-img">
<img src="{{ n.imagen_url }}" loading="lazy" onerror="this.style.display='none'">
</div>
{% endif %}
<div class="summary-content">
<h3><a href="{{ url_for('noticia.detalle_noticia', noticia_id=n.id) }}">{{ n.titulo }}</a></h3>
<div class="meta">
{{ n.time_str }} | {{ n.fuente }}
</div>
</div>
</li>
{% endfor %}
</ul>
<div style="text-align: center; margin-top: 15px;">
<a href="{{ url_for('home.home', categoria_id=noticias[0].categoria_id, fecha=current_date) }}"
class="btn btn-small btn-outline">Ver más de {{ categoria }}</a>
</div>
</div>
{% endfor %}
</div>
{% endif %}
<style>
.summary-header {
text-align: center;
margin-bottom: 30px;
}
.date-nav {
display: flex;
justify-content: space-between;
align-items: center;
max-width: 800px;
margin: 0 auto;
}
.date-nav h1 {
margin: 0;
font-family: var(--primary-font);
}
.date-nav h1 small {
display: block;
font-size: 0.5em;
color: #777;
font-family: var(--secondary-font);
margin-top: 5px;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 20px;
}
.category-title {
border-bottom: 2px solid var(--accent-color);
padding-bottom: 10px;
margin-top: 0;
margin-bottom: 20px;
font-family: var(--primary-font);
}
.summary-list {
list-style: none;
padding: 0;
margin: 0;
}
.summary-item {
display: flex;
gap: 15px;
margin-bottom: 20px;
border-bottom: 1px dotted var(--border-color);
padding-bottom: 20px;
}
.summary-item:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.summary-img {
width: 80px;
height: 80px;
flex-shrink: 0;
border-radius: 4px;
overflow: hidden;
}
.summary-img img {
width: 100%;
height: 100%;
object-fit: cover;
}
.summary-content h3 {
margin: 0 0 5px 0;
font-size: 1.1rem;
line-height: 1.3;
}
.summary-content h3 a {
text-decoration: none;
color: var(--text-color);
}
.summary-content h3 a:hover {
color: var(--accent-color);
}
.meta {
font-size: 0.8rem;
color: #888;
}
</style>
{% endblock %}

22
templates/scrape_url.html Normal file
View file

@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}Procesar Fuente URL{% endblock %}
{% block content %}
<h1>Procesar noticias de una Fuente</h1>
<div class="card">
<form method="post" action="{{ url_for('scrape_url') }}">
<label for="source_id">Fuente:</label>
<select id="source_id" name="source_id" required>
<option value="">— Selecciona —</option>
{% for f in fuentes %}
<option value="{{ f.id }}">{{ f.nombre }}</option>
{% endfor %}
</select>
<button class="btn" style="margin-top:15px;">Procesar</button>
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,82 @@
{% extends "base.html" %}
{% block title %}Historial de Búsquedas - {{ user.username }}{% endblock %}
{% block content %}
<div style="max-width: 900px; margin: 20px auto;">
<h2 style="margin-bottom: 20px;">
<i class="fas fa-history"></i> Historial de Búsquedas
<small style="color: #666; font-size: 16px; font-weight: normal;">({{ total }} búsquedas)</small>
</h2>
{% if searches %}
<div style="background: #f9f9f9; padding: 20px; border-radius: 10px;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="border-bottom: 2px solid #ddd;">
<th style="padding: 12px; text-align: left;">Búsqueda</th>
<th style="padding: 12px; text-align: center; width: 120px;">Resultados</th>
<th style="padding: 12px; text-align: right; width: 180px;">Fecha y Hora</th>
</tr>
</thead>
<tbody>
{% for search in searches %}
<tr style="border-bottom: 1px solid #eee;">
<td style="padding: 12px;">
<a href="/api/search?q={{ search.query | urlencode }}"
style="color: #6c63ff; text-decoration: none; font-weight: 500;"
title="Click para buscar nuevamente">
{{ search.query }}
</a>
</td>
<td style="padding: 12px; text-align: center;">
<span style="background: #e7e7ff; padding: 4px 10px; border-radius: 12px; font-size: 14px;">
{{ search.results_count }}
</span>
</td>
<td style="padding: 12px; text-align: right; color: #666; font-size: 14px;">
{{ search.searched_at.strftime('%d/%m/%Y %H:%M:%S') }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Pagination -->
{% if total_pages > 1 %}
<div style="margin-top: 30px; text-align: center;">
<div style="display: inline-flex; gap: 5px; align-items: center;">
{% if page > 1 %}
<a href="?page={{ page - 1 }}"
style="padding: 8px 12px; background: #6c63ff; color: white; text-decoration: none; border-radius: 5px;">
← Anterior
</a>
{% endif %}
<span style="padding: 8px 16px; color: #666;">
Página {{ page }} de {{ total_pages }}
</span>
{% if page < total_pages %} <a href="?page={{ page + 1 }}"
style="padding: 8px 12px; background: #6c63ff; color: white; text-decoration: none; border-radius: 5px;">
Siguiente →
</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
{% else %}
<div style="text-align: center; padding: 40px; background: #f9f9f9; border-radius: 10px;">
<i class="fas fa-search" style="font-size: 48px; color: #ccc; margin-bottom: 15px;"></i>
<p style="color: #666; font-size: 18px;">No tienes búsquedas guardadas todavía</p>
</div>
{% endif %}
<div style="margin-top: 20px; text-align: center;">
<a href="{{ url_for('account.index') }}" style="color: #6c63ff; text-decoration: none; font-weight: 500;">
← Volver a Tu Cuenta
</a>
</div>
</div>
{% endblock %}

729
templates/stats.html Normal file
View file

@ -0,0 +1,729 @@
{% extends "base.html" %}
{% block title %}Estadísticas de Actividad{% endblock %}
{% block content %}
<div class="stats-header">
<h1><i class="fas fa-chart-line"></i> Estadísticas</h1>
<p>Actividad de noticias y distribución por categorías</p>
</div>
<!-- Live Stats Banner (Moved from Config) -->
<div class="stats-banner">
<div class="stat-item">
<div class="stat-value">{{ translations_per_min }}</div>
<div class="stat-label">trad/min</div>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<div class="stat-value {% if pending_count > 100 %}stat-warning{% endif %}">{{ pending_count }}</div>
<div class="stat-label">pendientes</div>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<div class="stat-value stat-processing">{{ processing_count }}</div>
<div class="stat-label">procesando</div>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<div class="stat-value">{{ traducciones_count }}</div>
<div class="stat-label">completadas</div>
</div>
{% if error_count > 0 %}
<div class="stat-divider"></div>
<div class="stat-item">
<div class="stat-value stat-error">{{ error_count }}</div>
<div class="stat-label">errores</div>
</div>
{% endif %}
</div>
<!-- Big Stats Section (Moved from Config) -->
<div class="big-stats">
<div class="big-stat">
<div class="big-number">{{ noticias_count|default(0) }}</div>
<div class="big-label">Noticias</div>
</div>
<div class="big-stat">
<div class="big-number">{{ traducciones_count|default(0) }}</div>
<div class="big-label">Traducidas</div>
</div>
<div class="big-stat highlight">
{% set percent = ((traducciones_count / noticias_count * 100) if noticias_count > 0 else 0)|round(1) %}
<div class="big-number">{{ percent }}%</div>
<div class="big-label">Completado</div>
</div>
<div class="big-stat">
<div class="big-number">{{ noticias_hoy|default(0) }}</div>
<div class="big-label">Noticias Hoy</div>
</div>
<div class="big-stat">
<div class="big-number">{{ noticias_ultima_hora|default(0) }}</div>
<div class="big-label">Última Hora</div>
</div>
</div>
<div class="stats-grid">
<div class="card stats-card full-width">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:15px;">
<h3 style="margin:0;">Actividad</h3>
<div class="btn-group" role="group">
<button type="button" class="btn btn-outline-primary btn-sm"
onclick="updateActivityChart('1h', this)">1h</button>
<button type="button" class="btn btn-outline-primary btn-sm"
onclick="updateActivityChart('8h', this)">8h</button>
<button type="button" class="btn btn-outline-primary btn-sm"
onclick="updateActivityChart('24h', this)">24h</button>
<button type="button" class="btn btn-outline-primary btn-sm"
onclick="updateActivityChart('7d', this)">7d</button>
<button type="button" class="btn btn-outline-primary btn-sm active"
onclick="updateActivityChart('30d', this)">30d</button>
</div>
</div>
<div class="chart-container">
<canvas id="activityChart"></canvas>
</div>
</div>
<div class="card stats-card">
<h3>Noticias por Categoría</h3>
<div class="chart-container">
<canvas id="categoryChart"></canvas>
</div>
</div>
<div class="card stats-card">
<h3>Noticias por País</h3>
<div class="chart-container">
<canvas id="countryChart"></canvas>
</div>
</div>
<!-- Translations Section -->
<div class="card stats-card full-width">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:15px;">
<h3 style="margin:0;">Ritmo de Traducción (TPM)</h3>
<div class="btn-group" role="group">
<button type="button" class="btn btn-outline-primary btn-sm active"
onclick="updateRateChart('1h', this)">1h</button>
<button type="button" class="btn btn-outline-primary btn-sm"
onclick="updateRateChart('8h', this)">8h</button>
<button type="button" class="btn btn-outline-primary btn-sm"
onclick="updateRateChart('24h', this)">24h</button>
<button type="button" class="btn btn-outline-primary btn-sm"
onclick="updateRateChart('7d', this)">7d</button>
</div>
</div>
<div class="chart-container">
<canvas id="rateChart"></canvas>
</div>
</div>
<div class="card stats-card">
<h3>Traducciones Diarias</h3>
<div class="chart-container">
<canvas id="translationActivityChart"></canvas>
</div>
</div>
<div class="card stats-card">
<h3>Idiomas de Origen</h3>
<div class="chart-container">
<canvas id="langChart"></canvas>
</div>
</div>
<div class="card stats-card">
<h3>Información del Sistema</h3>
<ul class="system-stats" id="system-info-list">
<li><strong>CPU:</strong> <span id="cpu-load">Cargando...</span></li>
<li><strong>Núcleos:</strong> <span id="cpu-cores">-</span></li>
<li><strong>GPU:</strong> <span id="gpu-name">-</span></li>
<li><strong>GPU Temp:</strong> <span id="gpu-temp">-</span></li>
<li><strong>GPU Util:</strong> <span id="gpu-util">-</span></li>
<li><strong>GPU Mem:</strong> <span id="gpu-mem">-</span></li>
<li><strong>Uptime:</strong> <span id="system-uptime">-</span></li>
<li class="last-update">Actualizado: <span id="last-update-time">-</span></li>
</ul>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
// Activity Chart
let activityChart = null;
window.updateActivityChart = function (range, btn) {
// Update active button state
if (btn) {
// Find buttons specifically for this chart
const parent = btn.closest('.btn-group');
if (parent) {
parent.querySelectorAll('button').forEach(b => b.classList.remove('active'));
}
btn.classList.add('active');
}
fetch(`/stats/api/activity?range=${range}`)
.then(response => response.json())
.then(data => {
const ctx = document.getElementById('activityChart').getContext('2d');
if (activityChart) {
activityChart.destroy();
}
// Dynamic title
let titleText = "Noticias Recientes";
if (range === '1h') titleText = "Última Hora";
if (range === '8h') titleText = "Últimas 8 Horas";
if (range === '24h') titleText = "Últimas 24 Horas";
if (range === '7d') titleText = "Últimos 7 Días";
if (range === '30d') titleText = "Últimos 30 Días";
// Create gradient
const gradient = ctx.createLinearGradient(0, 0, 0, 400);
gradient.addColorStop(0, 'rgba(52, 152, 219, 0.5)'); // Start color
gradient.addColorStop(1, 'rgba(52, 152, 219, 0.0)'); // End color
activityChart = new Chart(ctx, {
type: 'line',
data: {
labels: data.labels,
datasets: [{
label: 'Noticias Ingestadas',
data: data.data,
borderColor: '#3498db',
backgroundColor: gradient,
borderWidth: 3,
tension: 0.4, // Smooth curves
fill: true,
pointRadius: 0,
pointHoverRadius: 6,
pointHitRadius: 10
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
legend: { display: false },
title: {
display: true,
text: titleText,
font: { size: 16, weight: 'normal' },
padding: { bottom: 20 }
},
tooltip: {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
titleColor: '#2c3e50',
bodyColor: '#2c3e50',
borderColor: '#e0e0e0',
borderWidth: 1,
padding: 10,
displayColors: false
}
},
scales: {
x: {
grid: { display: false }
},
y: {
beginAtZero: true,
border: { display: false },
grid: {
borderDash: [5, 5],
color: '#f0f0f0'
},
title: {
display: true,
text: 'Miles de noticias'
}
}
}
}
});
});
};
// Initial load (30d)
updateActivityChart('30d');
// Category Chart
fetch('/stats/api/categories')
.then(response => response.json())
.then(data => {
const ctx = document.getElementById('categoryChart').getContext('2d');
new Chart(ctx, {
type: 'doughnut',
data: {
labels: data.labels,
datasets: [{
data: data.data,
backgroundColor: [
'#4facfe', '#00f2fe', '#43e97b', '#38f9d7', '#fa709a',
'#fee140', '#667eea', '#764ba2', '#ff9a9e', '#a18cd1'
],
borderWidth: 0,
hoverOffset: 10
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '75%', // Thinner ring
plugins: {
legend: {
position: 'right',
labels: {
usePointStyle: true,
padding: 20,
font: { size: 12 }
}
}
}
}
});
});
// Country Chart
fetch('/stats/api/countries')
.then(response => response.json())
.then(data => {
const ctx = document.getElementById('countryChart').getContext('2d');
// Gradient for bars
const gradient = ctx.createLinearGradient(0, 0, 0, 400);
gradient.addColorStop(0, '#1abc9c');
gradient.addColorStop(1, '#16a085');
new Chart(ctx, {
type: 'bar',
data: {
labels: data.labels,
datasets: [{
label: 'Noticias',
data: data.data,
backgroundColor: gradient,
borderRadius: 4, // Rounded bars
borderSkipped: false,
barPercentage: 0.6,
categoryPercentage: 0.8
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
titleColor: '#2c3e50',
bodyColor: '#2c3e50',
borderColor: '#e0e0e0',
borderWidth: 1,
displayColors: false
}
},
scales: {
x: {
grid: { display: false }
},
y: {
beginAtZero: true,
border: { display: false },
grid: {
borderDash: [5, 5],
color: '#f0f0f0'
}
}
}
}
});
});
// --- NEW TRANSLATION CHARTS ---
// Translation Rate (TPM)
let rateChart = null;
window.updateRateChart = function (range, btn) {
// Update active button state
if (btn) {
document.querySelectorAll('.btn-group button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
}
fetch(`/stats/api/translations/rate?range=${range}`)
.then(response => response.json())
.then(data => {
const ctx = document.getElementById('rateChart').getContext('2d');
if (rateChart) {
rateChart.destroy();
}
// Gradient for rate chart
const gradient = ctx.createLinearGradient(0, 0, 0, 400);
gradient.addColorStop(0, 'rgba(230, 126, 34, 0.5)');
gradient.addColorStop(1, 'rgba(230, 126, 34, 0.0)');
rateChart = new Chart(ctx, {
type: 'line',
data: {
labels: data.labels,
datasets: [{
label: 'Traducciones',
data: data.data,
borderColor: '#e67e22',
backgroundColor: gradient,
borderWidth: 3,
tension: 0.4,
fill: true,
pointRadius: 0,
pointHoverRadius: 6,
pointHitRadius: 10
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
legend: { display: false },
title: {
display: true,
text: `Traducciones - Última(s) ${range}`,
font: { size: 16, weight: 'normal' },
padding: { bottom: 20 }
},
tooltip: {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
titleColor: '#2c3e50',
bodyColor: '#2c3e50',
borderColor: '#e0e0e0',
borderWidth: 1,
padding: 10,
displayColors: false
}
},
scales: {
x: { grid: { display: false } },
y: {
beginAtZero: true,
border: { display: false },
grid: {
borderDash: [5, 5],
color: '#f0f0f0'
}
}
}
}
});
});
};
// Initial load (1h)
updateRateChart('1h');
// Translation Activity
fetch('/stats/api/translations/activity')
.then(response => response.json())
.then(data => {
const ctx = document.getElementById('translationActivityChart').getContext('2d');
// Gradient
const gradient = ctx.createLinearGradient(0, 0, 0, 400);
gradient.addColorStop(0, '#9b59b6');
gradient.addColorStop(1, '#8e44ad');
new Chart(ctx, {
type: 'bar',
data: {
labels: data.labels,
datasets: [{
label: 'Traducciones Diarias',
data: data.data,
backgroundColor: gradient,
borderRadius: 4,
barPercentage: 0.6,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
titleColor: '#2c3e50',
bodyColor: '#2c3e50',
borderColor: '#e0e0e0',
borderWidth: 1,
displayColors: false
}
},
scales: {
x: { grid: { display: false } },
y: {
beginAtZero: true,
border: { display: false },
grid: {
borderDash: [5, 5],
color: '#f0f0f0'
}
}
}
}
});
});
// Translation Languages
fetch('/stats/api/translations/languages')
.then(response => response.json())
.then(data => {
const ctx = document.getElementById('langChart').getContext('2d');
new Chart(ctx, {
type: 'pie',
data: {
labels: data.labels,
datasets: [{
data: data.data,
backgroundColor: [
'#34495e', '#16a085', '#27ae60', '#2980b9', '#8e44ad',
'#2c3e50', '#f1c40f', '#e67e22', '#e74c3c', '#ecf0f1'
],
borderWidth: 0,
hoverOffset: 10
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right',
labels: {
usePointStyle: true,
padding: 20,
font: { size: 12 }
}
},
title: {
display: true,
text: 'Idioma de Origen (Top 10)',
padding: { bottom: 10 }
}
}
}
});
});
// System Info Polling
function updateSystemInfo() {
fetch('/stats/api/system/info')
.then(res => res.json())
.then(data => {
document.getElementById('cpu-load').textContent = data.cpu ? data.cpu.load : 'N/A';
document.getElementById('cpu-cores').textContent = data.cpu ? data.cpu.cores : '-';
document.getElementById('system-uptime').textContent = data.uptime;
document.getElementById('last-update-time').textContent = data.timestamp;
if (data.gpu) {
document.getElementById('gpu-name').textContent = data.gpu.name;
document.getElementById('gpu-temp').textContent = data.gpu.temp;
document.getElementById('gpu-util').textContent = data.gpu.util;
document.getElementById('gpu-mem').textContent = data.gpu.mem;
} else {
document.getElementById('gpu-name').textContent = 'No detectada';
}
})
.catch(err => console.error("Error polling system info", err));
}
updateSystemInfo();
setInterval(updateSystemInfo, 10000);
});
</script>
<style>
.stats-header {
text-align: center;
margin-bottom: 30px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 20px;
}
.full-width {
grid-column: 1 / -1;
}
.stats-card {
padding: 20px;
min-height: 300px;
display: flex;
flex-direction: column;
}
.chart-container {
flex: 1;
position: relative;
min-height: 250px;
}
.system-stats {
list-style: none;
padding: 0;
}
.system-stats li {
padding: 10px 0;
border-bottom: 1px solid var(--border-color);
}
.system-stats li:last-child {
border-bottom: none;
}
/* Stats Banner Styles */
.stats-banner {
display: flex;
align-items: center;
justify-content: center;
gap: 1.5rem;
padding: 1rem 1.5rem;
background: #111;
border-radius: 8px;
margin-bottom: 2rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.stat-item {
text-align: center;
}
.stat-value {
font-size: 1.75rem;
font-weight: 700;
color: #fff;
font-family: 'Poppins', sans-serif;
line-height: 1;
}
.stat-value.stat-warning {
color: #ffc107;
}
.stat-value.stat-processing {
color: #17a2b8;
}
.stat-value.stat-error {
color: #dc3545;
}
.stat-label {
font-size: 0.7rem;
color: rgba(255, 255, 255, 0.6);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: 0.25rem;
}
.stat-divider {
width: 1px;
height: 40px;
background: rgba(255, 255, 255, 0.15);
}
.last-update {
font-size: 0.75rem;
color: #888;
font-style: italic;
margin-top: auto;
padding-top: 10px;
border-top: 1px dashed var(--border-color);
}
.dark-mode .stats-banner {
background: #000;
border: 1px solid #333;
}
@media (max-width: 600px) {
.stats-banner {
flex-wrap: wrap;
gap: 1rem;
}
.stat-divider {
display: none;
}
.stat-item {
flex: 0 0 45%;
}
}
/* Big Stats */
.big-stats {
display: flex;
gap: 2rem;
margin: 1rem 0 2rem 0;
padding: 1rem 0;
border-top: 1px solid var(--border-color, #eee);
border-bottom: 1px solid var(--border-color, #eee);
}
.big-stat {
text-align: center;
flex: 1;
}
.big-stat .big-number {
font-size: 2.5rem;
font-weight: 800;
color: #111;
font-family: 'Poppins', sans-serif;
line-height: 1;
}
.big-stat .big-label {
font-size: 0.75rem;
color: var(--text-muted, #666);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: 0.35rem;
}
.big-stat.highlight .big-number {
color: #28a745;
}
.dark-mode .big-stat .big-number {
color: #fff;
}
@media (max-width: 600px) {
.big-stats {
flex-direction: column;
gap: 1rem;
}
}
</style>
{% endblock %}
```

View file

@ -0,0 +1,697 @@
{% extends "base.html" %}
{% block title %}Monitor de Entidades{% endblock %}
{% block content %}
<div class="monitor-layout">
<!-- PANEL IZQUIERDO: FILTROS -->
<aside class="monitor-sidebar card">
<div class="sidebar-header">
<h3><i class="fas fa-filter"></i> Filtros</h3>
</div>
<div class="filter-section">
<label class="filter-label"><i class="fas fa-globe"></i> País</label>
<div class="custom-dropdown big-dropdown" id="countryDropdown">
<input type="text" id="countrySearch" class="form-control big-search" placeholder="Buscar país..."
value="Global" readonly>
<div class="dropdown-results static-results" id="dropdownResults"></div>
</div>
</div>
<div class="filter-section">
<label class="filter-label"><i class="far fa-calendar-alt"></i> Fecha</label>
<input type="date" id="datePicker" class="form-control big-date" placeholder="Seleccionar fecha">
</div>
<div class="filter-actions content-center">
<button id="clearFilters" class="btn btn-secondary full-btn">
<i class="fas fa-times-circle"></i> Limpiar Filtros
</button>
</div>
<div class="active-filters-summary">
<span class="badge" id="timeRangeBadge">Global | Últimos 30 días</span>
</div>
</aside>
<!-- PANEL DERECHO: CONTENIDO Y TABS -->
<main class="monitor-main">
<!-- TABS DE NAVEGACIÓN -->
<div class="monitor-tabs">
<button class="monitor-tab active" onclick="switchTab('personas')">
<i class="fas fa-user-tie"></i> Personas
</button>
<button class="monitor-tab" onclick="switchTab('organizaciones')">
<i class="fas fa-building"></i> Organizaciones
</button>
<button class="monitor-tab" onclick="switchTab('lugares')">
<i class="fas fa-globe-americas"></i> Lugares
</button>
</div>
<!-- CONTENIDO TABS -->
<div class="tab-content-container">
<!-- TAB: PERSONAS -->
<div id="tab-personas" class="tab-pane active fade-in">
<div class="card stats-card full-height">
<!-- Eliminado header redundante, ya está en el tab -->
<div class="entities-container">
<!-- Lista a la izquierda -->
<div class="list-box scroll-y">
<div class="list-header-row">
<span>Entidad</span>
<span>Menciones</span>
</div>
<ol class="entity-list" id="peopleList"></ol>
</div>
</div>
</div>
</div>
<!-- TAB: ORGANIZACIONES -->
<div id="tab-organizaciones" class="tab-pane fade-in">
<div class="card stats-card full-height">
<div class="entities-container">
<div class="list-box scroll-y">
<div class="list-header-row">
<span>Organización</span>
<span>Menciones</span>
</div>
<ol class="entity-list" id="orgsList"></ol>
</div>
</div>
</div>
</div>
<!-- TAB: LUGARES -->
<div id="tab-lugares" class="tab-pane fade-in">
<div class="card stats-card full-height">
<div class="entities-container">
<div class="list-box scroll-y">
<div class="list-header-row">
<span>Lugar</span>
<span>Menciones</span>
</div>
<ol class="entity-list" id="placesList"></ol>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
// --- TABS LOGIC ---
function switchTab(tabName) {
// Hide all tabs
document.querySelectorAll('.tab-pane').forEach(el => {
el.classList.remove('active');
el.style.display = 'none'; // Force hide
});
document.querySelectorAll('.monitor-tab').forEach(el => el.classList.remove('active'));
// Show selected
const selected = document.getElementById('tab-' + tabName);
if (selected) {
selected.classList.add('active');
selected.style.display = 'block'; // Force show
}
// Update button
const btns = document.querySelectorAll('.monitor-tab');
btns.forEach(btn => {
if (btn.innerHTML.toLowerCase().includes(tabName)) {
btn.classList.add('active');
}
});
}
// --- ORIGINAL LOGIC ADAPTED ---
// Charts removed per user request
function updateAll(country, date) {
loadPeople(country, date);
loadOrgs(country);
loadPlaces(country);
}
function loadPeople(country, date) {
let url = '/stats/api/entities/people';
const params = [];
if (country && country !== 'global') params.push(`country=${encodeURIComponent(country)}`);
if (date) params.push(`date=${encodeURIComponent(date)}`);
if (params.length > 0) url += '?' + params.join('&');
const badge = document.getElementById('timeRangeBadge');
if (badge) {
const countryText = (country && country !== 'global') ? country.toUpperCase() : 'GLOBAL';
const dateText = date ? date : 'Últimos 30 días';
badge.textContent = `${countryText} | ${dateText}`;
}
const list = document.getElementById('peopleList');
if (list) list.innerHTML = '<div class="loading-spinner">Cargando...</div>';
fetch(url)
.then(response => {
if (!response.ok) throw new Error('Error en la carga');
return response.json();
})
.then(data => {
if (list) list.innerHTML = '';
if (!data.labels || data.labels.length === 0) {
if (list) list.innerHTML = '<div class="no-data"><i class="fas fa-info-circle"></i> No hay datos</div>';
return;
}
// Render List
if (list) {
data.labels.forEach((name, index) => {
const li = document.createElement('li');
const imgUrl = data.images && data.images[index] ? data.images[index] : null;
const summary = data.summaries && data.summaries[index] ? data.summaries[index] : null;
let imgHtml = (imgUrl && imgUrl !== "NO_IMAGE")
? `<img src="${imgUrl}" class="entity-avatar" alt="${name}">`
: `<div class="entity-avatar-placeholder">${name.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase()}</div>`;
let tooltipHtml = summary ? `<div class="entity-tooltip"><strong>${name}</strong><hr>${summary}</div>` : '';
li.innerHTML = `
<div class="entity-item-wrapper" style="display: flex; align-items: center; gap: 12px; flex: 1;">
${imgHtml}
<span class="entity-name">${name}</span>
${tooltipHtml}
</div>
<span class="entity-count-badge">${data.data[index]}</span>
`;
list.appendChild(li);
});
}
})
.catch(err => {
console.error(err);
if (list) list.innerHTML = '<div class="error-msg">Error loaded data</div>';
});
}
function loadOrgs(country) {
let url = '/stats/api/entities/orgs';
if (country && country !== 'global') url += `?country=${encodeURIComponent(country)}`;
const list = document.getElementById('orgsList');
if (list) list.innerHTML = '<li>Cargando...</li>';
fetch(url)
.then(response => response.json())
.then(data => {
if (list) {
list.innerHTML = '';
if (!data.labels || data.labels.length === 0) {
list.innerHTML = '<li class="no-data">No hay datos</li>';
return;
}
data.labels.forEach((name, index) => {
const li = document.createElement('li');
let imgUrl = data.images && data.images[index];
const summary = data.summaries && data.summaries[index];
let imgHtml = (imgUrl && imgUrl !== "NO_IMAGE")
? `<img src="${imgUrl}" class="entity-avatar" alt="${name}">`
: `<div class="entity-avatar-placeholder">${name.substring(0, 2).toUpperCase()}</div>`;
let tooltipHtml = summary ? `<div class="entity-tooltip"><strong>${name}</strong><hr>${summary}</div>` : '';
li.innerHTML = `
<div class="entity-item-wrapper" style="display: flex; align-items: center; width: 100%; gap: 12px;">
${imgHtml}
<span class="entity-name">${name}</span>
${tooltipHtml}
</div>
<span class="entity-count-badge">${data.data[index]}</span>
`;
list.appendChild(li);
});
}
})
.catch(err => { });
}
function loadPlaces(country) {
let url = '/stats/api/entities/places';
if (country && country !== 'global') url += `?country=${encodeURIComponent(country)}`;
const list = document.getElementById('placesList');
if (list) list.innerHTML = '<li>Cargando...</li>';
fetch(url)
.then(response => response.json())
.then(data => {
if (list) {
list.innerHTML = '';
if (!data.labels || data.labels.length === 0) {
list.innerHTML = '<li class="no-data">No hay datos</li>';
return;
}
data.labels.forEach((name, index) => {
const li = document.createElement('li');
let imgUrl = data.images && data.images[index];
const summary = data.summaries && data.summaries[index];
let imgHtml = (imgUrl && imgUrl !== "NO_IMAGE")
? `<img src="${imgUrl}" class="entity-avatar" alt="${name}">`
: `<div class="entity-avatar-placeholder">${name.substring(0, 2).toUpperCase()}</div>`;
let tooltipHtml = summary ? `<div class="entity-tooltip"><strong>${name}</strong><hr>${summary}</div>` : '';
li.innerHTML = `
<div class="entity-item-wrapper" style="display: flex; align-items: center; width: 100%; gap: 12px;">
${imgHtml}
<span class="entity-name">${name}</span>
${tooltipHtml}
</div>
<span class="entity-count-badge">${data.data[index]}</span>
`;
list.appendChild(li);
});
}
})
.catch(err => { });
}
// --- FILTERS & DROPDOWN LOGIC ---
document.getElementById('clearFilters').addEventListener('click', function () {
const cs = document.getElementById('countrySearch');
if (cs) {
cs.value = 'Global';
cs.setAttribute('readonly', 'readonly');
}
document.getElementById('datePicker').value = '';
renderResults(''); // Reset db
updateAll('global', null);
});
document.getElementById('datePicker').addEventListener('change', function (e) {
const currentCountry = document.getElementById('countrySearch').value;
const country = currentCountry === 'Global' ? 'global' : currentCountry;
updateAll(country, e.target.value);
});
function selectCountry(displayText, value) {
countrySearch.value = displayText;
// dropdownResults is now always visible or handled via CSS, lets just update query
const currentDate = document.getElementById('datePicker').value;
updateAll(value, currentDate || null);
}
// Updated Country Logic
let allCountries = [];
const countrySearch = document.getElementById('countrySearch');
const dropdownResults = document.getElementById('dropdownResults');
fetch('/stats/api/countries/list')
.then(res => res.json())
.then(data => {
allCountries = data;
renderResults(''); // Render initial full list
})
.catch(err => console.error('Error loading countries:', err));
function renderResults(filterText) {
const filter = filterText || '';
dropdownResults.innerHTML = '';
if (!filter || 'global'.includes(filter.toLowerCase())) {
const globalItem = document.createElement('div');
globalItem.className = 'dropdown-item';
globalItem.innerHTML = '🌍 <strong>Global</strong>';
globalItem.onclick = () => selectCountry('Global', 'global');
dropdownResults.appendChild(globalItem);
}
const filtered = allCountries.filter(c =>
!filter || c.name.toLowerCase().includes(filter.toLowerCase())
);
filtered.forEach(c => {
const item = document.createElement('div');
item.className = 'dropdown-item';
item.innerHTML = `<span class="flag">${c.flag}</span> <span class="name">${c.name}</span>`;
item.onclick = () => selectCountry(c.name, c.name);
dropdownResults.appendChild(item);
});
}
countrySearch.addEventListener('input', (e) => {
countrySearch.removeAttribute('readonly');
renderResults(e.target.value);
});
countrySearch.addEventListener('click', () => {
countrySearch.removeAttribute('readonly');
countrySearch.value = '';
countrySearch.focus();
renderResults('');
});
// Initial
switchTab('personas'); // Default tab
updateAll('global', null);
// Mouse Tooltips
document.addEventListener('mousemove', function (e) {
const tooltips = document.querySelectorAll('.entity-tooltip');
if (tooltips.length === 0) return;
tooltips.forEach(tooltip => {
const style = window.getComputedStyle(tooltip);
if (tooltip.style.visibility === 'visible' || style.visibility === 'visible') {
let x = e.clientX + 20;
let y = e.clientY - 40;
if (x + 400 > window.innerWidth) x = window.innerWidth - 420;
if (y + 300 > window.innerHeight) y = window.innerHeight - 310;
tooltip.style.left = x + 'px';
tooltip.style.top = y + 'px';
}
});
});
</script>
<style>
/* LAYOUT MACRO */
.monitor-layout {
display: flex;
gap: 20px;
align-items: flex-start;
padding-bottom: 40px;
min-height: 85vh;
}
.monitor-sidebar {
width: 320px;
flex-shrink: 0;
background: var(--bg-card);
padding: 20px;
border-radius: 12px;
position: sticky;
top: 20px;
height: auto;
max-height: 90vh;
display: flex;
flex-direction: column;
border: 1px solid var(--border-color);
}
/* FIX: Moved out of nesting */
.entity-item-wrapper img {
max-width: none;
/* Reset global limits */
}
.entity-avatar {
width: 50px !important;
height: 50px !important;
min-width: 50px !important;
max-width: 50px !important;
flex-shrink: 0 !important;
border-radius: 50%;
object-fit: cover;
border: 2px solid var(--border-color);
display: block;
margin-right: 15px;
}
.entity-item-wrapper {
display: flex;
align-items: center;
width: 100%;
overflow: hidden;
}
.monitor-main {
flex: 1;
display: flex;
flex-direction: column;
gap: 15px;
min-width: 0;
/* Prevent flex overflow */
}
/* SIDEBAR STYLES */
.sidebar-header h3 {
color: var(--text-primary);
margin-top: 0;
border-bottom: 2px solid var(--border-color);
padding-bottom: 15px;
margin-bottom: 20px;
font-size: 1.4em;
}
.filter-section {
margin-bottom: 25px;
}
.filter-label {
display: block;
margin-bottom: 10px;
color: var(--text-secondary);
font-weight: 600;
font-size: 0.9em;
}
.big-search {
font-size: 1.1em;
padding: 12px;
border: 2px solid var(--border-color);
background: var(--bg-input);
}
.big-date {
font-size: 1em;
padding: 10px;
}
.custom-dropdown.big-dropdown {
width: 100%;
position: relative;
}
.dropdown-results.static-results {
position: relative;
/* In flow, not floating */
border: 1px solid var(--border-color);
background: var(--bg-body);
max-height: 400px;
/* TALLER */
overflow-y: auto;
margin-top: 5px;
display: block;
/* Always visible or toggleable */
border-radius: 8px;
}
.dropdown-item {
padding: 12px 15px;
border-bottom: 1px solid var(--border-color);
font-size: 1.05em;
}
.dropdown-item:last-child {
border-bottom: none;
}
.filter-actions {
margin-top: auto;
padding-top: 20px;
}
.full-btn {
width: 100%;
justify-content: center;
padding: 12px;
}
.active-filters-summary {
margin-top: 20px;
text-align: center;
}
/* TABS STYLES */
.monitor-tabs {
display: flex;
gap: 10px;
border-bottom: 2px solid var(--border-color);
padding-bottom: 0px;
}
.monitor-tab {
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 1.1em;
padding: 12px 25px;
cursor: pointer;
font-weight: 600;
position: relative;
transition: all 0.2s;
border-radius: 8px 8px 0 0;
}
.monitor-tab:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
.monitor-tab.active {
color: var(--accent-color);
background: var(--bg-card);
border-bottom: 3px solid var(--accent-color);
}
/* CONTENT AREAS */
.tab-pane {
display: none;
}
.tab-pane.active {
display: block;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.full-height {
min-height: 700px;
padding: 0;
overflow: hidden;
}
.entities-container {
display: block;
height: 700px;
width: 100%;
}
.list-box {
width: 100%;
height: 100%;
border-right: none;
overflow-y: auto;
padding: 0;
background: var(--bg-card);
}
.scroll-y {
overflow-y: auto;
}
.chart-box {
flex: 1;
padding: 20px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-body);
/* Slightly darker for contrast */
}
.list-header-row {
position: sticky;
top: 0;
background: var(--bg-header);
padding: 10px 15px;
display: flex;
justify-content: space-between;
font-weight: 700;
border-bottom: 2px solid var(--border-color);
z-index: 10;
}
.entity-list {
display: flex;
flex-direction: column;
/* Force single column list */
row-gap: 0;
}
.entity-list li {
padding: 12px 15px;
margin: 0;
border-bottom: 1px solid var(--border-color);
transition: background 0.1s;
}
.entity-list li:hover {
background: var(--bg-hover);
}
.entity-count-badge {
background: var(--accent-color);
color: #fff;
padding: 4px 8px;
border-radius: 12px;
font-size: 0.85em;
font-weight: 700;
}
/* UTILITIES */
.entity-tooltip {
position: fixed;
visibility: hidden;
z-index: 9999;
background: var(--paper-color);
border: 1px solid var(--accent-color);
padding: 15px;
border-radius: 8px;
width: 300px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
}
/* RESPONSIVE */
@media (max-width: 1100px) {
.monitor-layout {
flex-direction: column;
}
.monitor-sidebar {
width: 100%;
position: static;
max-height: none;
}
.entities-container.row-layout {
flex-direction: column;
height: auto;
}
.list-box {
flex: none;
border-right: none;
height: 500px;
border-bottom: 1px solid var(--border-color);
}
.chart-box {
height: 400px;
}
}
</style>
{% endblock %}

109
templates/traducciones.html Normal file
View file

@ -0,0 +1,109 @@
{% extends "base.html" %}
{% block title %}Últimas Traducciones{% endblock %}
{% block content %}
<div class="card">
<h2><i class="fas fa-language" style="color: var(--accent-color); margin-right: 10px;"></i>Últimas Traducciones</h2>
<p style="color: #666; margin-bottom: 20px;">
Mostrando las {{ traducciones|length }} traducciones más recientes de un total de {{ total }}.
</p>
</div>
<div id="traducciones-grid" style="margin-top: 20px;">
{% for t in traducciones %}
<article class="noticia-card">
{% if t.imagen %}
<img src="{{ t.imagen }}" alt="{{ t.titulo_trad }}" onerror="this.src='/static/placeholder.svg'; this.onerror=null;">
{% else %}
<img src="/static/placeholder.svg" alt="Sin imagen">
{% endif %}
<div class="noticia-meta">
<span class="lang-badge">{{ t.lang_from|upper }} → {{ t.lang_to|upper }}</span>
{% if t.categoria_nombre %}
<span class="category-badge">{{ t.categoria_nombre }}</span>
{% endif %}
{% if t.pais_nombre %}
• {{ t.pais_nombre }}
{% endif %}
</div>
<h3>
<a href="{{ t.link }}" target="_blank" rel="noopener">{{ t.titulo_trad or 'Sin título' }}</a>
</h3>
<p class="noticia-summary">
{{ t.resumen_trad[:300] }}{% if t.resumen_trad|length > 300 %}...{% endif %}
</p>
<div class="noticia-footer">
<small>
<i class="fas fa-rss"></i> {{ t.feed_nombre or 'Desconocido' }}
<i class="fas fa-clock"></i> {{ t.updated_at|format_date if t.updated_at else 'Fecha desconocida' }}
</small>
</div>
</article>
{% else %}
<div class="card" style="text-align: center; padding: 40px;">
<i class="fas fa-inbox fa-3x" style="color: #ccc; margin-bottom: 20px;"></i>
<p>No hay traducciones disponibles aún.</p>
</div>
{% endfor %}
</div>
{% if total_pages > 1 %}
<div class="pagination" style="margin-top: 30px; text-align: center;">
{% if page > 1 %}
<a href="?page={{ page - 1 }}&per_page={{ per_page }}" class="btn btn-secondary">
<i class="fas fa-chevron-left"></i> Anterior
</a>
{% endif %}
<span style="margin: 0 20px;">Página {{ page }} de {{ total_pages }}</span>
{% if page < total_pages %}
<a href="?page={{ page + 1 }}&per_page={{ per_page }}" class="btn">
Siguiente <i class="fas fa-chevron-right"></i>
</a>
{% endif %}
</div>
{% endif %}
<style>
#traducciones-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 25px;
}
.lang-badge {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 3px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: bold;
}
.category-badge {
background: var(--accent-color);
color: white;
padding: 3px 8px;
border-radius: 4px;
font-size: 0.75rem;
margin-left: 8px;
}
.noticia-footer {
margin-top: 15px;
padding-top: 10px;
border-top: 1px solid var(--border-color);
color: #888;
font-size: 0.85rem;
}
.pagination .btn {
margin: 0 5px;
}
</style>
{% endblock %}

79
templates/urls_list.html Normal file
View file

@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block title %}Fuentes URL{% endblock %}
{% block content %}
<div class="card feed-detail-card">
<div class="feed-header">
<h2>Lista de Fuentes URL ({{ fuentes|length }})</h2>
<div class="nav-actions">
<a href="{{ url_for('urls.add_url_source') }}" class="btn btn-small">
<i class="fas fa-plus"></i> Añadir URL
</a>
</div>
</div>
<div class="feed-body" style="padding: 0;">
<table style="width:100%; border-collapse: collapse;">
<thead>
<tr style="background-color: rgba(0,0,0,0.05);">
<th style="padding: 12px 15px; text-align: left;">Nombre</th>
<th style="padding: 12px 15px; text-align: left;">Categoría</th>
<th style="padding: 12px 15px; text-align: left;">País</th>
<th style="padding: 12px 15px; text-align: left;">Idioma</th>
<th style="padding: 12px 15px; text-align: center;">Noticias</th>
<th style="padding: 12px 15px; text-align: left;">Estado</th>
<th style="padding: 12px 15px; text-align: left;">Últ. Chequeo</th>
</tr>
</thead>
<tbody>
{% for f in fuentes %}
<tr>
<td style="padding:12px 15px;border-top:1px solid var(--border-color);">
<a href="{{ f.url }}" target="_blank" title="{{ f.url }}">{{ f.nombre }}</a>
</td>
<td style="padding:12px 15px;border-top:1px solid var(--border-color);">
{{ f.categoria or "N/A" }}
</td>
<td style="padding:12px 15px;border-top:1px solid var(--border-color);">
{{ f.pais or "Global" }}
</td>
<td style="padding:12px 15px;border-top:1px solid var(--border-color);">
{{ f.idioma or "n/a" }}
</td>
<td style="padding:12px 15px;border-top:1px solid var(--border-color); text-align:center;">
<span class="badge"
style="background: rgba(52, 152, 219, 0.1); color: #3498db; padding: 2px 8px; border-radius: 10px;">
{{ f.noticias_count or 0 }}
</span>
</td>
<td style="padding:12px 15px;border-top:1px solid var(--border-color);">
{% if f.last_status == 'OK' %}
<span class="status-badge"
style="background:#28a745; color:white; padding:4px 8px; border-radius:4px; font-size:0.85em;">OK</span>
{% elif f.last_status %}
<span class="status-badge"
style="background:#dc3545; color:white; padding:4px 8px; border-radius:4px; font-size:0.85em; cursor:help;"
title="{{ f.status_message }} (Code: {{ f.last_http_code }})">
{{ f.last_status }}
</span>
{% else %}
<span class="status-badge"
style="background:#6c757d; color:white; padding:4px 8px; border-radius:4px; font-size:0.85em;">Pendiente</span>
{% endif %}
</td>
<td style="padding:12px 15px;border-top:1px solid var(--border-color);">
{{ f.last_check.strftime('%d/%m %H:%M') if f.last_check else "-" }}
</td>
</tr>
{% else %}
<tr>
<td colspan="6" style="padding:20px;text-align:center;">
No hay fuentes URL registradas.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}