update:español por defecto

This commit is contained in:
jlimolina 2025-10-10 19:50:54 +02:00
parent da4c59a0e1
commit 046a5ff369
6 changed files with 381 additions and 119 deletions

58
.env
View file

@ -1,7 +1,59 @@
# Variables para la base de datos # =========================
# Base de datos
# =========================
DB_NAME=rss DB_NAME=rss
DB_USER=rss DB_USER=rss
DB_PASS=lalalilo DB_PASS=lalalilo
# DB_HOST y DB_PORT los inyecta docker-compose (DB_HOST=db).
# Si ejecutas la app fuera de Docker, puedes descomentar:
# DB_HOST=localhost
# DB_PORT=5432
# =========================
# Flask / Web
# =========================
# ¡Pon aquí una clave larga y aleatoria!
SECRET_KEY=CAMBIA_ESTA_CLAVE_POR_ALGO_LARGO_Y_ALEATORIO
# Idioma por defecto de la web y traducción activada por defecto
DEFAULT_LANG=es
DEFAULT_TRANSLATION_LANG=es
WEB_TRANSLATED_DEFAULT=1
# Paginación por defecto (app.py limita entre 10 y 100)
NEWS_PER_PAGE=20
# =========================
# Ingesta / Scheduler
# =========================
RSS_MAX_WORKERS=20
RSS_FEED_TIMEOUT=30
RSS_MAX_FAILURES=5
# =========================
# Worker de traducción (NLLB 1.3B)
# =========================
TARGET_LANGS=es
TRANSLATOR_BATCH=4
ENQUEUE=200
TRANSLATOR_SLEEP_IDLE=5
# Límites de tokens (equilibrio calidad/VRAM para 12 GB)
MAX_SRC_TOKENS=512
MAX_NEW_TOKENS=256
# Beams (más calidad en títulos)
NUM_BEAMS_TITLE=3
NUM_BEAMS_BODY=2
# Modelo y dispositivo
UNIVERSAL_MODEL=facebook/nllb-200-1.3B
DEVICE=cuda
# =========================
# Runtime (estabilidad/VRAM)
# =========================
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True,max_split_size_mb:64,garbage_collection_threshold:0.9
TOKENIZERS_PARALLELISM=false
PYTHONUNBUFFERED=1
# Variable para Flask
SECRET_KEY=genera_una_clave_aleatoria_larga_aqui

132
app.py
View file

@ -1,6 +1,5 @@
import os import os
import sys import sys
import hashlib
import csv import csv
import math import math
from io import StringIO, BytesIO from io import StringIO, BytesIO
@ -12,7 +11,7 @@ from contextlib import contextmanager
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from tqdm import tqdm from tqdm import tqdm
from flask import Flask, render_template, request, redirect, url_for, Response, flash from flask import Flask, render_template, request, redirect, url_for, Response, flash, make_response
import psycopg2 import psycopg2
import psycopg2.extras import psycopg2.extras
import psycopg2.pool import psycopg2.pool
@ -41,6 +40,11 @@ MAX_FALLOS = int(os.environ.get("RSS_MAX_FAILURES", 5))
# Tamaño de página configurable (límite en 10100 por seguridad) # Tamaño de página configurable (límite en 10100 por seguridad)
NEWS_PER_PAGE = int(os.environ.get("NEWS_PER_PAGE", 20)) NEWS_PER_PAGE = int(os.environ.get("NEWS_PER_PAGE", 20))
# Idioma/traducción por defecto
DEFAULT_TRANSLATION_LANG = os.environ.get("DEFAULT_TRANSLATION_LANG", "es").strip().lower()
DEFAULT_LANG = os.environ.get("DEFAULT_LANG", DEFAULT_TRANSLATION_LANG).strip().lower()
WEB_TRANSLATED_DEFAULT = os.environ.get("WEB_TRANSLATED_DEFAULT", "1").strip().lower() in ("1", "true", "yes")
db_pool = None db_pool = None
try: try:
db_pool = psycopg2.pool.SimpleConnectionPool(minconn=1, maxconn=10, **DB_CONFIG) db_pool = psycopg2.pool.SimpleConnectionPool(minconn=1, maxconn=10, **DB_CONFIG)
@ -75,7 +79,12 @@ def shutdown_hooks():
def safe_html(text): def safe_html(text):
if not text: if not text:
return "" return ""
return bleach.clean(text, tags={'a', 'b', 'strong', 'i', 'em', 'p', 'br'}, attributes={'a': ['href', 'title']}, strip=True) return bleach.clean(
text,
tags={'a', 'b', 'strong', 'i', 'em', 'p', 'br'},
attributes={'a': ['href', 'title']},
strip=True
)
def _get_form_dependencies(cursor): def _get_form_dependencies(cursor):
cursor.execute("SELECT id, nombre FROM categorias ORDER BY nombre") cursor.execute("SELECT id, nombre FROM categorias ORDER BY nombre")
@ -84,13 +93,32 @@ def _get_form_dependencies(cursor):
paises = cursor.fetchall() paises = cursor.fetchall()
return categorias, paises return categorias, paises
def _build_news_query(args, *, count=False, limit=None, offset=None): def _get_lang_and_flags():
"""
Determina el idioma preferido y si se debe usar traducción por defecto.
Permite forzar original con ?orig=1 y cambiar idioma con ?lang=xx (se guarda en cookie).
"""
qlang = request.args.get("lang", "").strip().lower()
cookie_lang = (request.cookies.get("lang") or "").strip().lower()
lang = qlang or cookie_lang or DEFAULT_LANG or "es"
force_orig = request.args.get("orig") == "1"
use_translation = (not force_orig) and WEB_TRANSLATED_DEFAULT
return lang, use_translation, bool(qlang)
def _build_news_query(args, *, count=False, limit=None, offset=None, lang="es", use_translation=True):
""" """
Construye la consulta SQL y los parámetros basados en los argumentos de la petición. Construye la consulta SQL y los parámetros basados en los argumentos de la petición.
Si count=True => SELECT COUNT(*) Si count=True => SELECT COUNT(*)
Si count=False => SELECT columnas con ORDER + LIMIT/OFFSET Si count=False => SELECT columnas con ORDER + LIMIT/OFFSET.
Integra traducciones vía LEFT JOIN LATERAL cuando use_translation=True (status='done', lang_to=lang).
""" """
sql_params = [] # Para controlar orden de parámetros según apariciones de %s:
select_rank_params = []
from_params = []
where_params = []
tail_params = []
conditions = [] conditions = []
q = args.get("q", "").strip() q = args.get("q", "").strip()
@ -99,6 +127,7 @@ def _build_news_query(args, *, count=False, limit=None, offset=None):
pais_id = args.get("pais_id") pais_id = args.get("pais_id")
fecha_filtro = args.get("fecha") fecha_filtro = args.get("fecha")
# FROM base
sql_from = """ sql_from = """
FROM noticias n FROM noticias n
LEFT JOIN categorias c ON n.categoria_id = c.id LEFT JOIN categorias c ON n.categoria_id = c.id
@ -106,64 +135,93 @@ def _build_news_query(args, *, count=False, limit=None, offset=None):
LEFT JOIN continentes co ON p.continente_id = co.id LEFT JOIN continentes co ON p.continente_id = co.id
""" """
# LEFT JOIN LATERAL traducción (solo en SELECT de página; el conteo no la necesita)
if (not count) and use_translation:
sql_from += """
LEFT JOIN LATERAL (
SELECT titulo_trad, resumen_trad
FROM traducciones
WHERE traducciones.noticia_id = n.id
AND traducciones.lang_to = %s
AND traducciones.status = 'done'
ORDER BY id DESC
LIMIT 1
) t ON TRUE
"""
from_params.append(lang)
# WHERE dinámico # WHERE dinámico
if q: if q:
# plainto_tsquery para tolerar espacios/acentos # Buscar por relevancia en el tsvector de la noticia original
conditions.append("n.tsv @@ plainto_tsquery('spanish', %s)") conditions.append("n.tsv @@ plainto_tsquery('spanish', %s)")
sql_params.append(q) where_params.append(q)
if cat_id: if cat_id:
conditions.append("n.categoria_id = %s") conditions.append("n.categoria_id = %s")
sql_params.append(cat_id) where_params.append(cat_id)
if pais_id: if pais_id:
conditions.append("n.pais_id = %s") conditions.append("n.pais_id = %s")
sql_params.append(pais_id) where_params.append(pais_id)
elif cont_id: elif cont_id:
conditions.append("p.continente_id = %s") conditions.append("p.continente_id = %s")
sql_params.append(cont_id) where_params.append(cont_id)
if fecha_filtro: if fecha_filtro:
try: try:
fecha_obj = datetime.strptime(fecha_filtro, '%Y-%m-%d') fecha_obj = datetime.strptime(fecha_filtro, '%Y-%m-%d')
conditions.append("n.fecha::date = %s") conditions.append("n.fecha::date = %s")
sql_params.append(fecha_obj.date()) where_params.append(fecha_obj.date())
except ValueError: except ValueError:
flash("Formato de fecha no válido. Use AAAA-MM-DD.", "error") flash("Formato de fecha no válido. Use AAAA-MM-DD.", "error")
where_clause = " WHERE " + " AND ".join(conditions) if conditions else "" where_clause = " WHERE " + " AND ".join(conditions) if conditions else ""
if count: if count:
# Conteo total # Conteo total (sin necesidad de traducciones)
sql_count = "SELECT COUNT(*) " + sql_from + where_clause sql_count = "SELECT COUNT(*) " + sql_from + where_clause
sql_params = from_params + where_params # from_params estará vacío en count
return sql_count, sql_params return sql_count, sql_params
# Selección de columnas para página # Selección de columnas para página
if use_translation:
select_cols = """ select_cols = """
SELECT n.fecha, n.titulo, n.resumen, n.url, n.imagen_url, n.fuente_nombre, SELECT n.fecha,
c.nombre AS categoria, p.nombre AS pais, co.nombre AS continente COALESCE(t.titulo_trad, n.titulo) AS titulo,
COALESCE(t.resumen_trad, n.resumen) AS resumen,
n.url, n.imagen_url, n.fuente_nombre,
c.nombre AS categoria, p.nombre AS pais, co.nombre AS continente,
(t.titulo_trad IS NOT NULL OR t.resumen_trad IS NOT NULL) AS usa_trad
""" """
else:
select_cols = """
SELECT n.fecha, n.titulo, n.resumen,
n.url, n.imagen_url, n.fuente_nombre,
c.nombre AS categoria, p.nombre AS pais, co.nombre AS continente,
FALSE AS usa_trad
"""
order_clause = " ORDER BY n.fecha DESC NULLS LAST" order_clause = " ORDER BY n.fecha DESC NULLS LAST"
if q: if q:
# Ranking por relevancia cuando hay búsqueda # Ranking por relevancia (primer placeholder)
select_cols = select_cols.replace( select_cols = select_cols.replace(
"SELECT", "SELECT",
"SELECT ts_rank(n.tsv, plainto_tsquery('spanish', %s)) AS rank," "SELECT ts_rank(n.tsv, plainto_tsquery('spanish', %s)) AS rank,"
) )
# El %s de rank va al principio de los params de SELECT select_rank_params.append(q)
sql_params = [q] + sql_params
order_clause = " ORDER BY rank DESC, n.fecha DESC NULLS LAST" order_clause = " ORDER BY rank DESC, n.fecha DESC NULLS LAST"
# Paginación # Paginación
if limit is not None: if limit is not None:
order_clause += " LIMIT %s" order_clause += " LIMIT %s"
sql_params.append(limit) tail_params.append(limit)
if offset is not None: if offset is not None:
order_clause += " OFFSET %s" order_clause += " OFFSET %s"
sql_params.append(offset) tail_params.append(offset)
sql_page = select_cols + sql_from + where_clause + order_clause sql_page = select_cols + sql_from + where_clause + order_clause
sql_params = select_rank_params + from_params + where_params + tail_params
return sql_page, sql_params return sql_page, sql_params
@app.route("/") @app.route("/")
@ -177,6 +235,9 @@ def home():
pais_id = request.args.get("pais_id") pais_id = request.args.get("pais_id")
fecha_filtro = request.args.get("fecha") fecha_filtro = request.args.get("fecha")
# Preferencias idioma/uso de traducción
lang, use_tr, set_cookie = _get_lang_and_flags()
# Paginación # Paginación
page = request.args.get("page", default=1, type=int) page = request.args.get("page", default=1, type=int)
per_page = request.args.get("per_page", default=NEWS_PER_PAGE, type=int) per_page = request.args.get("per_page", default=NEWS_PER_PAGE, type=int)
@ -202,14 +263,23 @@ def home():
cursor.execute("SELECT id, nombre, continente_id FROM paises ORDER BY nombre") cursor.execute("SELECT id, nombre, continente_id FROM paises ORDER BY nombre")
paises = cursor.fetchall() paises = cursor.fetchall()
# 1) Conteo total # 1) Conteo total (no requiere join de traducciones)
sql_count, params_count = _build_news_query(request.args, count=True) sql_count, params_count = _build_news_query(
request.args, count=True, lang=lang, use_translation=use_tr
)
cursor.execute(sql_count, tuple(params_count)) cursor.execute(sql_count, tuple(params_count))
total_results = cursor.fetchone()[0] or 0 total_results = cursor.fetchone()[0] or 0
total_pages = math.ceil(total_results / per_page) if total_results else 0 total_pages = math.ceil(total_results / per_page) if total_results else 0
# 2) Página actual # 2) Página actual (con COALESCE a traducción si procede)
sql_page, params_page = _build_news_query(request.args, count=False, limit=per_page, offset=offset) sql_page, params_page = _build_news_query(
request.args,
count=False,
limit=per_page,
offset=offset,
lang=lang,
use_translation=use_tr
)
cursor.execute(sql_page, tuple(params_page)) cursor.execute(sql_page, tuple(params_page))
noticias = cursor.fetchall() noticias = cursor.fetchall()
@ -221,15 +291,23 @@ def home():
noticias=noticias, categorias=categorias, continentes=continentes, paises=paises, noticias=noticias, categorias=categorias, continentes=continentes, paises=paises,
cat_id=int(cat_id) if cat_id else None, cont_id=int(cont_id) if cont_id else None, cat_id=int(cat_id) if cat_id else None, cont_id=int(cont_id) if cont_id else None,
pais_id=int(pais_id) if pais_id else None, fecha_filtro=fecha_filtro, q=q, pais_id=int(pais_id) if pais_id else None, fecha_filtro=fecha_filtro, q=q,
page=page, per_page=per_page, total_pages=total_pages, total_results=total_results page=page, per_page=per_page, total_pages=total_pages, total_results=total_results,
lang=lang, use_tr=use_tr
) )
# Respuesta parcial para AJAX # Respuesta parcial para AJAX
if request.headers.get('X-Requested-With') == 'XMLHttpRequest': if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return render_template('_noticias_list.html', **ctx) resp = make_response(render_template('_noticias_list.html', **ctx))
if set_cookie:
resp.set_cookie("lang", lang, max_age=60*60*24*365)
return resp
# Render completo # Render completo
return render_template("noticias.html", **ctx) html = render_template("noticias.html", **ctx)
resp = make_response(html)
if set_cookie:
resp.set_cookie("lang", lang, max_age=60*60*24*365)
return resp
@app.route("/dashboard") @app.route("/dashboard")
def dashboard(): def dashboard():

View file

@ -33,7 +33,12 @@ services:
- DB_USER=${DB_USER} - DB_USER=${DB_USER}
- DB_PASS=${DB_PASS} - DB_PASS=${DB_PASS}
- SECRET_KEY=${SECRET_KEY} - SECRET_KEY=${SECRET_KEY}
# - NEWS_PER_PAGE=20 # opcional # Opcionales UI
# - NEWS_PER_PAGE=20
# Mostrar traducciones por defecto en la web
- WEB_TRANSLATED_DEFAULT=1
- DEFAULT_LANG=es
- TRANSLATION_PREFERRED_LANGS=es
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@ -75,11 +80,11 @@ services:
# --- Worker --- # --- Worker ---
- TARGET_LANGS=es - TARGET_LANGS=es
- TRANSLATOR_BATCH=4 # 1.3B: más seguro en 12 GB (sube a 4 si ves VRAM libre) - TRANSLATOR_BATCH=4 # estable con 1.3B en 12 GB; ajusta si cambia la VRAM disponible
- ENQUEUE=200 - ENQUEUE=200
- TRANSLATOR_SLEEP_IDLE=5 - TRANSLATOR_SLEEP_IDLE=5
# Tokens (equilibrio calidad/VRAM) # Tokens (equilibrio calidad/VRAM ~<7GB)
- MAX_SRC_TOKENS=512 - MAX_SRC_TOKENS=512
- MAX_NEW_TOKENS=256 - MAX_NEW_TOKENS=256

View file

@ -8,11 +8,27 @@
</a> </a>
</div> </div>
{% endif %} {% endif %}
<div class="noticia-texto"> <div class="noticia-texto">
<h3><a href="{{ noticia.url }}" target="_blank" rel="noopener noreferrer">{{ noticia.titulo }}</a></h3> <h3 style="margin:0 0 6px 0;">
<a href="{{ noticia.url }}" target="_blank" rel="noopener noreferrer">{{ noticia.titulo }}</a>
{% if use_tr %}
<span class="badge" title="Mostrando traducciones por defecto" style="margin-left:8px;">Traducido</span>
{% endif %}
</h3>
<div class="noticia-meta"> <div class="noticia-meta">
<span><i class="far fa-calendar-alt"></i> <span>
{{ noticia.fecha.strftime('%d-%m-%Y %H:%M') if noticia.fecha else 'N/D' }} <i class="far fa-calendar-alt"></i>
{% if noticia.fecha %}
{% if noticia.fecha is string %}
{{ noticia.fecha }}
{% else %}
{{ noticia.fecha.strftime('%d-%m-%Y %H:%M') }}
{% endif %}
{% else %}
N/D
{% endif %}
</span> </span>
{% if noticia.fuente_nombre %} {% if noticia.fuente_nombre %}
| <span><i class="fas fa-newspaper"></i> <strong>{{ noticia.fuente_nombre }}</strong></span> | <span><i class="fas fa-newspaper"></i> <strong>{{ noticia.fuente_nombre }}</strong></span>
@ -26,14 +42,15 @@
</div> </div>
<div class="resumen-container"> <div class="resumen-container">
{% set resumen_txt = noticia.resumen | safe_html %}
<div class="resumen-corto"> <div class="resumen-corto">
{{ noticia.resumen | safe_html | truncate(280, True) }} {{ resumen_txt | truncate(280, True) }}
</div> </div>
<div class="resumen-completo" style="display:none;"> <div class="resumen-completo" style="display:none;">
{{ noticia.resumen | safe_html }} {{ resumen_txt }}
</div> </div>
{% if noticia.resumen and noticia.resumen|length > 280 %} {% if noticia.resumen and noticia.resumen|length > 280 %}
<button class="ver-mas-btn">Ver más</button> <button class="ver-mas-btn" type="button">Ver más</button>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@ -57,6 +74,7 @@
{% if total_pages and total_pages > 1 %} {% if total_pages and total_pages > 1 %}
<nav class="pagination" aria-label="Paginación de noticias" style="margin-top:15px;"> <nav class="pagination" aria-label="Paginación de noticias" style="margin-top:15px;">
{% set current = page %} {% set current = page %}
{# Anterior #} {# Anterior #}
{% if current > 1 %} {% if current > 1 %}
<a href="#" class="page-link" data-page="{{ current - 1 }}">&laquo; Anterior</a> <a href="#" class="page-link" data-page="{{ current - 1 }}">&laquo; Anterior</a>
@ -91,3 +109,29 @@
</nav> </nav>
{% endif %} {% endif %}
{# Toggle "Ver más / Ver menos" con delegación; se liga una sola vez #}
<script>
(function () {
if (window.__noticiasToggleBound) return;
window.__noticiasToggleBound = true;
const container = document.getElementById('noticias-container') || document;
container.addEventListener('click', function (e) {
const btn = e.target.closest('.ver-mas-btn');
if (!btn) return;
const wrap = btn.closest('.resumen-container');
if (!wrap) return;
const corto = wrap.querySelector('.resumen-corto');
const completo = wrap.querySelector('.resumen-completo');
if (!corto || !completo) return;
const expanded = completo.style.display === 'block';
completo.style.display = expanded ? 'none' : 'block';
corto.style.display = expanded ? 'block' : 'none';
btn.textContent = expanded ? 'Ver más' : 'Ver menos';
});
})();
</script>

View file

@ -11,6 +11,29 @@
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<!-- Estilos mínimos para chapa "Traducido" y (opcional) switch -->
<style>
.badge{
display:inline-block; font-size:.75rem; line-height:1;
padding:.35rem .5rem; border-radius:.5rem;
background: var(--secondary-color, #6c63ff); color:#fff; vertical-align: middle;
margin-left:.4rem;
}
/* Toggle switch opcional (si lo usas en alguna vista) */
.switch { position: relative; display: inline-block; width: 42px; height: 22px; vertical-align: middle; }
.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> </head>
<body> <body>
<div class="container"> <div class="container">
@ -41,13 +64,14 @@
</div> </div>
<script> <script>
// Toggle "Ver más / Ver menos" en resúmenes
document.addEventListener('click', function(event) { document.addEventListener('click', function(event) {
if (event.target.classList.contains('ver-mas-btn')) { if (event.target.classList.contains('ver-mas-btn')) {
const container = event.target.closest('.resumen-container'); const container = event.target.closest('.resumen-container');
const corto = container.querySelector('.resumen-corto'); const corto = container.querySelector('.resumen-corto');
const completo = container.querySelector('.resumen-completo'); const completo = container.querySelector('.resumen-completo');
if (completo.style.display === 'none') { if (completo.style.display === 'none' || completo.style.display === '') {
corto.style.display = 'none'; corto.style.display = 'none';
completo.style.display = 'block'; completo.style.display = 'block';
event.target.textContent = 'Ver menos'; event.target.textContent = 'Ver menos';
@ -61,3 +85,4 @@
</script> </script>
</body> </body>
</html> </html>

View file

@ -10,6 +10,14 @@
<input type="hidden" name="page" id="page" value="{{ page or 1 }}"> <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="per_page" id="per_page" value="{{ per_page or 20 }}">
<!-- Idioma/traducción por defecto -->
<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 %}
<div class="filter-main-row"> <div class="filter-main-row">
<div class="filter-search-box"> <div class="filter-search-box">
<label for="q">Buscar por palabra clave</label> <label for="q">Buscar por palabra clave</label>
@ -60,7 +68,22 @@
</form> </form>
</div> </div>
<div id="noticias-container"> <!-- Barra de estado de traducción -->
<div class="card" style="margin-top: 16px; padding: 12px;">
{% if use_tr %}
<div class="alert alert-info" style="margin:0;">
Mostrando <strong>traducciones</strong> a <strong>{{ (lang or 'es')|upper }}</strong>.
<a href="#" id="toggle-orig" class="ms-2">Ver originales</a>
</div>
{% else %}
<div class="alert alert-secondary" style="margin:0;">
Mostrando <strong>texto original</strong>.
<a href="#" id="toggle-tr" class="ms-2">Usar traducciones</a>
</div>
{% endif %}
</div>
<div id="noticias-container" style="margin-top:16px;">
{# El parcial incluye la lista + la paginación #} {# El parcial incluye la lista + la paginación #}
{% include '_noticias_list.html' %} {% include '_noticias_list.html' %}
</div> </div>
@ -71,6 +94,8 @@ document.addEventListener('DOMContentLoaded', function() {
const continenteSelect = document.getElementById('continente_id'); const continenteSelect = document.getElementById('continente_id');
const paisSelect = document.getElementById('pais_id'); const paisSelect = document.getElementById('pais_id');
const pageInput = document.getElementById('page'); const pageInput = document.getElementById('page');
const origInput = document.getElementById('orig');
const langInput = document.getElementById('lang');
function filtrarPaises() { function filtrarPaises() {
const continenteId = continenteSelect.value; const continenteId = continenteSelect.value;
@ -119,13 +144,46 @@ document.addEventListener('DOMContentLoaded', function() {
// Clic en enlaces de paginación (delegación) // Clic en enlaces de paginación (delegación)
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
const link = e.target.closest('a.page-link'); const link = e.target.closest('a.page-link');
if (link && link.dataset.page) { if (link) {
// soporta data-page o parseo del href
let nextPage = link.dataset.page;
if (!nextPage) {
try {
const u = new URL(link.href);
nextPage = u.searchParams.get('page');
} catch(_) {}
}
if (nextPage) {
e.preventDefault(); e.preventDefault();
pageInput.value = link.dataset.page; pageInput.value = nextPage;
cargarNoticias(true); cargarNoticias(true);
} }
}
}); });
// Conmutar traducciones <-> originales manteniendo filtros
const toggleOrig = document.getElementById('toggle-orig');
const toggleTr = document.getElementById('toggle-tr');
if (toggleOrig) {
toggleOrig.addEventListener('click', function(e) {
e.preventDefault();
// orig=1 fuerza originales
origInput.value = '1';
cargarNoticias(false);
});
}
if (toggleTr) {
toggleTr.addEventListener('click', function(e) {
e.preventDefault();
// orig vacío => usar traducciones
origInput.value = '';
// asegúrate de tener lang (por si el back usa default)
if (!langInput.value) langInput.value = 'es';
cargarNoticias(false);
});
}
// Inicializaciones // Inicializaciones
continenteSelect.addEventListener('change', function() { continenteSelect.addEventListener('change', function() {
filtrarPaises(); filtrarPaises();