muchas cosas
This commit is contained in:
parent
e3936fd4cf
commit
eff656adea
20 changed files with 697 additions and 51 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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 "{newsModal.valor}"</>
|
||||
: <>Noticias sobre "{newsModal.valor}"</>}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue