muchas cosas

This commit is contained in:
jlimolina 2026-08-11 02:37:48 +02:00
parent e3936fd4cf
commit eff656adea
20 changed files with 697 additions and 51 deletions

View file

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { api } from '../services/api'
import { Cpu, Play, Square, Settings, RefreshCw, Loader2, Server, Wifi, Plus, Trash2, Key } from 'lucide-react'
import { Cpu, Play, Square, Settings, RefreshCw, Loader2, Server, Wifi, Plus, Trash2, Key, TrendingUp } from 'lucide-react'
interface WorkerStatus {
type: string
@ -9,6 +9,15 @@ interface WorkerStatus {
running: number
}
interface TranslationStats {
translations_last_1min: number
translations_last_5min: number
rate_per_second: number
rate_per_minute: number
active_workers: number
remote_workers_online: number
}
interface RemoteWorker {
id: number
name: string
@ -21,6 +30,7 @@ interface RemoteWorker {
export function AdminWorkers() {
const [status, setStatus] = useState<WorkerStatus | null>(null)
const [tStats, setTStats] = useState<TranslationStats | null>(null)
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [configType, setConfigType] = useState<'cpu' | 'gpu'>('cpu')
@ -40,6 +50,9 @@ export function AdminWorkers() {
setStatus(res.data)
setConfigType(res.data.type)
setConfigWorkers(res.data.workers)
const statsRes = await api.get('/admin/workers/stats')
setTStats(statsRes.data)
} catch (err: any) {
console.error('Error fetching status:', err)
} finally {
@ -294,7 +307,32 @@ export function AdminWorkers() {
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<span className="font-medium">Workers activos</span>
<span className="text-lg font-bold">{status?.running || 0}</span>
<span className="text-lg font-bold">{tStats?.active_workers ?? status?.running ?? 0}</span>
</div>
<div className="rounded-lg border border-primary-200 dark:border-primary-800 p-4">
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-primary-600" />
Rendimiento de Traducción
</h3>
<div className="grid grid-cols-3 gap-3 text-center">
<div className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div className="text-xl font-bold text-primary-600">{tStats?.rate_per_second ?? 0}</div>
<div className="text-xs text-gray-500">noticias/seg</div>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div className="text-xl font-bold text-primary-600">{tStats?.rate_per_minute ?? 0}</div>
<div className="text-xs text-gray-500">noticias/min</div>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div className="text-xl font-bold text-primary-600">{tStats?.translations_last_1min ?? 0}</div>
<div className="text-xs text-gray-500">últ. 1 min</div>
</div>
</div>
<div className="text-xs text-gray-500 mt-2">
{tStats?.translations_last_5min ?? 0} traducidas en los últimos 5 min ·{' '}
{tStats?.remote_workers_online ?? 0} remotos online
</div>
</div>
<div className="flex gap-3 pt-2">

View file

@ -10,15 +10,18 @@ export function Feeds() {
const [filtroActivo, setFiltroActivo] = useState('')
const [filtroCategoria, setFiltroCategoria] = useState('')
const [filtroPais, setFiltroPais] = useState('')
const [page, setPage] = useState(1)
const fileInputRef = useRef<HTMLInputElement>(null)
const [importResult, setImportResult] = useState<{ imported: number; skipped: number; failed: number; message: string } | null>(null)
const { data: feedsData, isLoading } = useQuery({
queryKey: ['feeds', filtroActivo, filtroCategoria, filtroPais],
queryKey: ['feeds', filtroActivo, filtroCategoria, filtroPais, page],
queryFn: () => apiService.getFeeds({
activo: filtroActivo || undefined,
categoria_id: filtroCategoria || undefined,
pais_id: filtroPais || undefined,
page,
per_page: 50,
}),
})
@ -165,7 +168,7 @@ export function Feeds() {
<div className="flex flex-wrap gap-4">
<select
value={filtroActivo}
onChange={(e) => setFiltroActivo(e.target.value)}
onChange={(e) => { setFiltroActivo(e.target.value); setPage(1) }}
className="input w-auto"
>
<option value="">Todos</option>
@ -174,7 +177,7 @@ export function Feeds() {
</select>
<select
value={filtroCategoria}
onChange={(e) => setFiltroCategoria(e.target.value)}
onChange={(e) => { setFiltroCategoria(e.target.value); setPage(1) }}
className="input w-auto"
>
<option value="">Todas las categorías</option>
@ -184,7 +187,7 @@ export function Feeds() {
</select>
<select
value={filtroPais}
onChange={(e) => setFiltroPais(e.target.value)}
onChange={(e) => { setFiltroPais(e.target.value); setPage(1) }}
className="input w-auto"
>
<option value="">Todos los países</option>
@ -365,6 +368,30 @@ export function Feeds() {
</table>
</div>
)}
{!isLoading && feedsData && feedsData.total_pages > 1 && (
<div className="mt-6 flex flex-col sm:flex-row items-center justify-between gap-4 text-sm text-gray-600 dark:text-gray-400 bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
<div>
Página <span className="font-semibold text-gray-900 dark:text-white">{page}</span> de <span className="font-semibold text-gray-900 dark:text-white">{feedsData.total_pages}</span>
</div>
<div className="flex gap-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page <= 1}
className="px-4 py-2 border rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed dark:border-gray-600 dark:hover:bg-gray-700 transition-colors"
>
Anterior
</button>
<button
onClick={() => setPage(p => Math.min(feedsData.total_pages, p + 1))}
disabled={page >= feedsData.total_pages}
className="px-4 py-2 border rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed dark:border-gray-600 dark:hover:bg-gray-700 transition-colors"
>
Siguiente
</button>
</div>
</div>
)}
</div>
)
}

View file

@ -1,9 +1,9 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link, useSearchParams } from 'react-router-dom'
import { formatDistanceToNow } from 'date-fns'
import { es } from 'date-fns/locale'
import { apiService } from '../services/api'
import { apiService, api } from '../services/api'
import { Search, Globe, Newspaper, Filter } from 'lucide-react'
export function Home() {
@ -13,6 +13,15 @@ export function Home() {
const categoryId = searchParams.get('category_id') || ''
const countryId = searchParams.get('country_id') || ''
const translatedOnly = searchParams.get('translated_only') === 'true'
const [suggestions, setSuggestions] = useState<string[]>([])
// Load the user's most frequent search terms (dynamic tags) when logged in
useEffect(() => {
if (!localStorage.getItem('token')) return
api.get('/search/suggestions')
.then((res) => setSuggestions(res.data.terms || []))
.catch(() => {})
}, [])
const { data: categories } = useQuery({
queryKey: ['categories'],
@ -39,6 +48,10 @@ export function Home() {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const query = formData.get('q')
// Record the search for frequency-based suggestions (only when logged in)
if (localStorage.getItem('token') && query) {
api.post('/searchlog', { q: String(query) }).catch(() => {})
}
setSearchParams({
q: query as string,
page: '1',
@ -83,8 +96,12 @@ export function Home() {
name="q"
defaultValue={q}
placeholder="Buscar noticias..."
list="user-search-suggestions"
className="input pl-10 w-full"
/>
<datalist id="user-search-suggestions">
{suggestions.map((s) => <option key={s} value={s} />)}
</datalist>
<button type="submit" className="btn-primary">
Buscar
</button>

View file

@ -9,6 +9,9 @@ export function Login() {
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState('')
const [successMessage, setSuccessMessage] = useState('')
const [sessionExpired] = useState<boolean>(
() => new URLSearchParams(window.location.search).get('expired') === '1',
)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
@ -55,6 +58,12 @@ export function Login() {
</div>
)}
{sessionExpired && !successMessage && (
<div className="bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded mb-4 text-center">
Tu sesión ha expirado. Inicia sesión de nuevo.
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
{!isLogin && (
<>

View file

@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../services/api'
import { WikiTooltip } from '../components/ui/WikiTooltip'
@ -32,6 +33,15 @@ const TIPOS = [
{ value: 'tema', label: '📰 Tema', color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' },
]
const TIMELINE_WINDOWS: { key: string; label: string; title: string; offset: number }[] = [
{ key: '0', label: 'Hoy', title: 'hoy', offset: 0 },
{ key: '1', label: '1d', title: 'hace 1 día', offset: 1 },
{ key: '2', label: '2d', title: 'hace 2 días', offset: 2 },
{ key: '3', label: '3d', title: 'hace 3 días', offset: 3 },
{ key: '4', label: '4d', title: 'hace 4 días', offset: 4 },
{ key: '5', label: '5d', title: 'hace 5 días', offset: 5 },
]
function getTipoInfo(tipo: string) {
return TIPOS.find(t => t.value === tipo) || TIPOS[3]
}
@ -52,6 +62,14 @@ export function Populares() {
const [saving, setSaving] = useState(false)
const [successMsg, setSuccessMsg] = useState('')
// News-by-entity modal state
const [newsModal, setNewsModal] = useState<Entity | null>(null)
const [newsModalMode, setNewsModalMode] = useState<'news' | 'timeline'>('news')
const [timelineLabel, setTimelineLabel] = useState('últimas 24 horas')
const [entityNews, setEntityNews] = useState<any[]>([])
const [newsLoading, setNewsLoading] = useState(false)
const [newsTotal, setNewsTotal] = useState(0)
// Form state for configure modal
const [newTipo, setNewTipo] = useState('')
const [aliasInput, setAliasInput] = useState('')
@ -108,6 +126,50 @@ export function Populares() {
setSuccessMsg('')
}
const openNews = async (entity: Entity) => {
setNewsModalMode('news')
setNewsModal(entity)
setNewsLoading(true)
setEntityNews([])
setNewsTotal(0)
try {
const params = new URLSearchParams()
params.append('valor', entity.valor)
params.append('tipo', entity.tipo)
params.append('per_page', '30')
const res = await api.get(`/entities/news?${params}`)
setEntityNews(res.data.news || [])
setNewsTotal(res.data.total || 0)
} catch (err) {
console.error(err)
} finally {
setNewsLoading(false)
}
}
const openTimeline = async (entity: Entity, win: typeof TIMELINE_WINDOWS[number]) => {
setTimelineLabel(win.title)
setNewsModalMode('timeline')
setNewsModal(entity)
setNewsLoading(true)
setEntityNews([])
setNewsTotal(0)
try {
const params = new URLSearchParams()
params.append('valor', entity.valor)
params.append('tipo', entity.tipo)
params.append('day_offset', String(win.offset))
params.append('per_page', '50')
const res = await api.get(`/entities/news?${params}`)
setEntityNews(res.data.news || [])
setNewsTotal(res.data.total || 0)
} catch (err) {
console.error(err)
} finally {
setNewsLoading(false)
}
}
const handleSaveTipo = async () => {
if (!configModal || newTipo === configModal.entity.tipo) return
setSaving(true)
@ -351,13 +413,36 @@ export function Populares() {
</span>
</td>
<td className="px-4 py-3 text-center">
<button
onClick={() => openConfig(entity)}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Configurar entidad"
>
⚙️ Configurar
</button>
<div className="flex flex-col items-center gap-1">
<div className="flex items-center justify-center gap-1">
<button
onClick={() => openNews(entity)}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Ver noticias donde se menciona"
>
🔍 Buscar
</button>
<button
onClick={() => openConfig(entity)}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Configurar entidad"
>
⚙️ Configurar
</button>
</div>
<div className="flex flex-wrap items-center justify-center gap-1">
{TIMELINE_WINDOWS.map((win) => (
<button
key={win.key}
onClick={() => openTimeline(entity, win)}
className="inline-flex items-center px-1.5 py-0.5 text-[10px] rounded bg-blue-600 text-white hover:bg-blue-700 transition-colors"
title={win.title}
>
{win.label}
</button>
))}
</div>
</div>
</td>
</tr>
)
@ -519,6 +604,129 @@ export function Populares() {
</div>
</div>
)}
{/* News-by-entity Modal */}
{newsModal && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4" onClick={() => setNewsModal(null)}>
<div
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-2xl max-h-[85vh] overflow-hidden flex flex-col"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between px-6 py-4 border-b dark:border-gray-700">
<div>
<h2 className="text-lg font-bold">
{newsModalMode === 'timeline'
? <>📈 Timeline de &quot;{newsModal.valor}&quot;</>
: <>Noticias sobre &quot;{newsModal.valor}&quot;</>}
</h2>
<p className="text-sm text-gray-500 mt-0.5">
{newsModalMode === 'timeline'
? `Últimas ${Math.min(entityNews.length, 50)} noticias · ${timelineLabel} (${getTipoInfo(newsModal.tipo).label})`
: `${newsTotal} noticias encontradas (${getTipoInfo(newsModal.tipo).label})`}
</p>
</div>
<button
onClick={() => setNewsModal(null)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-xl leading-none"
>
✕
</button>
</div>
<div className="p-4 overflow-y-auto">
{newsLoading ? (
<div className="text-center py-10 text-gray-400">Cargando...</div>
) : entityNews.length === 0 ? (
<div className="text-center py-10 text-gray-400">
{newsModalMode === 'timeline'
? `No hay noticias de esta entidad para ${timelineLabel}.`
: 'No hay noticias que mencionen a esta entidad.'}
</div>
) : newsModalMode === 'timeline' ? (
<ol className="relative border-l-2 border-blue-200 dark:border-blue-800 ml-2 space-y-4">
{entityNews.map((n) => (
<li key={n.id} className="ml-4 relative">
<span className="absolute -left-[21px] top-2 h-3 w-3 rounded-full bg-blue-500 ring-2 ring-blue-100 dark:ring-blue-900" />
<div className="flex items-baseline gap-2">
{n.fecha && (
<span className="text-[11px] font-semibold text-blue-600 dark:text-blue-400 shrink-0 tabular-nums">
{new Date(n.fecha).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' })}
</span>
)}
{n.fecha && (
<span className="text-[10px] text-gray-400 shrink-0 tabular-nums">
{new Date(n.fecha).toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit', year: '2-digit' })}
</span>
)}
</div>
<Link
to={`/news/${n.id}`}
onClick={() => setNewsModal(null)}
className="block p-3 mt-1 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border border-gray-100 dark:border-gray-700"
>
<div className="flex items-start gap-3">
{n.imagen_url && (
<img
src={n.imagen_url}
alt=""
className="w-20 h-14 object-cover rounded-md shrink-0"
onError={(e) => (e.currentTarget.style.display = 'none')}
/>
)}
<div className="min-w-0">
<div className="font-medium text-sm line-clamp-2">
{n.title_translated || n.titulo}
</div>
<p className="text-xs text-gray-500 mt-1 line-clamp-2">
{n.summary_translated || n.resumen}
</p>
</div>
</div>
</Link>
</li>
))}
</ol>
) : (
<ul className="space-y-2">
{entityNews.map((n) => (
<li key={n.id}>
<Link
to={`/news/${n.id}`}
onClick={() => setNewsModal(null)}
className="block p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border border-gray-100 dark:border-gray-700"
>
<div className="flex items-start gap-3">
{n.imagen_url && (
<img
src={n.imagen_url}
alt=""
className="w-20 h-14 object-cover rounded-md shrink-0"
onError={(e) => (e.currentTarget.style.display = 'none')}
/>
)}
<div className="min-w-0">
<div className="font-medium text-sm line-clamp-2">
{n.title_translated || n.titulo}
</div>
<p className="text-xs text-gray-500 mt-1 line-clamp-2">
{n.summary_translated || n.resumen}
</p>
{n.fecha && (
<span className="text-[11px] text-gray-400 mt-1 block">
{new Date(n.fecha).toLocaleDateString('es-ES')}
</span>
)}
</div>
</div>
</Link>
</li>
))}
</ul>
)}
</div>
</div>
</div>
)}
</div>
)
}

View file

@ -1,7 +1,7 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { useSearchParams, Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { apiService, News, Category, Country } from '../services/api'
import { apiService, api, News, Category, Country } from '../services/api'
import { Search as SearchIcon, Filter } from 'lucide-react'
export function Search() {
@ -10,6 +10,14 @@ export function Search() {
const lang = searchParams.get('lang') || ''
const categoria = searchParams.get('categoria') || ''
const pais = searchParams.get('pais') || ''
const [suggestions, setSuggestions] = useState<string[]>([])
useEffect(() => {
if (!localStorage.getItem('token')) return
api.get('/search/suggestions')
.then((res) => setSuggestions(res.data.terms || []))
.catch(() => {})
}, [])
const { data: categorias } = useQuery({
queryKey: ['categories'],
@ -34,7 +42,12 @@ export function Search() {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const newParams: Record<string, string> = {}
if (formData.get('q')) newParams.q = formData.get('q') as string
if (formData.get('q')) {
newParams.q = formData.get('q') as string
if (localStorage.getItem('token')) {
api.post('/searchlog', { q: formData.get('q') }).catch(() => {})
}
}
if (formData.get('lang')) newParams.lang = formData.get('lang') as string
if (formData.get('categoria')) newParams.categoria = formData.get('categoria') as string
if (formData.get('pais')) newParams.pais = formData.get('pais') as string
@ -58,8 +71,12 @@ export function Search() {
name="q"
defaultValue={q}
placeholder="Palabras clave..."
list="user-search-suggestions"
className="input w-full"
/>
<datalist id="user-search-suggestions">
{suggestions.map((s) => <option key={s} value={s} />)}
</datalist>
</div>
<div className="w-32">

View file

@ -17,6 +17,25 @@ api.interceptors.request.use((config) => {
return config
})
// If the session token expires (or is invalid), clear it and ask to log in again
// instead of failing with confusing "Invalid token" errors on admin actions.
api.interceptors.response.use(
(response) => response,
(error) => {
const status = error?.response?.status
const url = error?.config?.url || ''
const isAuthCall = url.includes('/auth/login') || url.includes('/auth/register')
if (status === 401 && !isAuthCall) {
localStorage.removeItem('token')
localStorage.removeItem('user')
if (!window.location.pathname.startsWith('/login')) {
window.location.href = '/login?expired=1'
}
}
return Promise.reject(error)
},
)
export interface News {
id: string
titulo: string