This commit is contained in:
jlimolina 2026-08-28 21:10:02 +02:00
parent 3991237685
commit a01050bf85
46 changed files with 3636 additions and 652 deletions

View file

@ -1,6 +1,6 @@
import { Outlet, Link, useNavigate } from 'react-router-dom'
import { Search, Rss, BarChart3, Home as HomeIcon, Heart, User, Flame, Settings, Users, Tags, Database, Server, TrendingUp, Bell } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Search, Rss, BarChart3, Home as HomeIcon, Heart, User, Flame, Settings, Users, Tags, Database, Server, TrendingUp, Bell, X, Menu } from 'lucide-react'
import { useEffect, useState, useRef } from 'react'
import { apiService } from '../../services/api'
export function Layout() {

View file

@ -0,0 +1,166 @@
import { Search, Rss, Globe, AlertTriangle, Inbox } from 'lucide-react'
interface EmptyStateProps {
icon?: 'search' | 'rss' | 'globe' | 'alert' | 'inbox'
title: string
description: string
action?: {
label: string
onClick: () => void
}
className?: string
}
export function EmptyState({
icon = 'search',
title,
description,
action,
className = ''
}: EmptyStateProps) {
const icons = {
search: Search,
rss: Rss,
globe: Globe,
alert: AlertTriangle,
inbox: Inbox,
}
const Icon = icons[icon]
return (
<div className={`flex flex-col items-center justify-center py-16 px-4 text-center ${className}`}>
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mb-4">
<Icon className="w-8 h-8 text-gray-400" />
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">{title}</h3>
<p className="text-gray-500 max-w-md mb-6">{description}</p>
{action && (
<button
onClick={action.onClick}
className="btn-primary"
>
{action.label}
</button>
)}
</div>
)
}
export function NoResultsFound({
query,
onClearFilters
}: {
query?: string
onClearFilters?: () => void
}) {
return (
<EmptyState
icon="search"
title="No se encontraron resultados"
description={query
? `No hay noticias que coincidan con "${query}". Intenta con otros términos de búsqueda.`
: 'No hay noticias que coincidan con los filtros seleccionados.'}
action={onClearFilters ? {
label: 'Limpiar filtros',
onClick: onClearFilters
} : undefined}
/>
)
}
export function NoNewsYet({
onAddFeed
}: {
onAddFeed?: () => void
}) {
return (
<EmptyState
icon="rss"
title="No hay noticias todavía"
description="Aún no se han agregado fuentes RSS. Agrega tu primera fuente para empezar a ver noticias."
action={onAddFeed ? {
label: 'Agregar fuente RSS',
onClick: onAddFeed
} : undefined}
/>
)
}
export function NoFeedsConfigured({
onAddFeed
}: {
onAddFeed?: () => void
}) {
return (
<EmptyState
icon="rss"
title="No hay fuentes configuradas"
description="No se han agregado fuentes RSS. Agrega tu primera fuente para empezar a recibir noticias."
action={onAddFeed ? {
label: 'Agregar fuente RSS',
onClick: onAddFeed
} : undefined}
/>
)
}
export function NoSearchResults({
query,
onSearch
}: {
query?: string
onSearch?: () => void
}) {
return (
<EmptyState
icon="search"
title="Sin resultados"
description={query
? `No se encontraron resultados para "${query}". Prueba con términos más generales.`
: 'Ingresa un término de búsqueda para encontrar noticias.'}
action={onSearch ? {
label: 'Nueva búsqueda',
onClick: onSearch
} : undefined}
/>
)
}
export function OfflineState({
onRetry
}: {
onRetry?: () => void
}) {
return (
<EmptyState
icon="alert"
title="Sin conexión"
description="No se puede conectar al servidor. Verifica tu conexión a internet e intenta de nuevo."
action={onRetry ? {
label: 'Reintentar',
onClick: onRetry
} : undefined}
/>
)
}
export function ErrorState({
message = 'Ha ocurrido un error inesperado',
onRetry
}: {
message?: string
onRetry?: () => void
}) {
return (
<EmptyState
icon="alert"
title="Error"
description={message}
action={onRetry ? {
label: 'Reintentar',
onClick: onRetry
} : undefined}
/>
)
}

View file

@ -0,0 +1,110 @@
export function SkeletonNewsCard() {
return (
<div className="card animate-pulse">
<div className="w-full h-48 bg-gray-200 rounded-t-xl"></div>
<div className="p-4 space-y-3">
<div className="flex items-center gap-2">
<div className="h-4 w-24 bg-gray-200 rounded"></div>
<div className="h-4 w-16 bg-gray-200 rounded"></div>
</div>
<div className="h-5 w-3/4 bg-gray-200 rounded"></div>
<div className="h-4 w-full bg-gray-200 rounded"></div>
<div className="h-4 w-5/6 bg-gray-200 rounded"></div>
<div className="h-4 w-1/2 bg-gray-200 rounded"></div>
</div>
</div>
)
}
interface SkeletonNewsListProps {
count?: number
}
export function SkeletonNewsList({ count = 6 }: SkeletonNewsListProps) {
return (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: count }).map((_, i) => (
<SkeletonNewsCard key={i} />
))}
</div>
)
}
export function SkeletonFeedCard() {
return (
<div className="card p-4 animate-pulse">
<div className="flex items-center gap-4">
<div className="h-10 w-10 bg-gray-200 rounded-lg"></div>
<div className="flex-1 space-y-2">
<div className="h-5 w-1/3 bg-gray-200 rounded"></div>
<div className="h-4 w-1/2 bg-gray-200 rounded"></div>
</div>
<div className="h-6 w-20 bg-gray-200 rounded"></div>
</div>
</div>
)
}
interface SkeletonFeedListProps {
count?: number
}
export function SkeletonFeedList({ count = 5 }: SkeletonFeedListProps) {
return (
<div className="space-y-3">
{Array.from({ length: count }).map((_, i) => (
<SkeletonFeedCard key={i} />
))}
</div>
)
}
export function SkeletonEntityCard() {
return (
<div className="card p-4 animate-pulse">
<div className="flex items-center gap-3">
<div className="h-12 w-12 bg-gray-200 rounded-full"></div>
<div className="flex-1 space-y-2">
<div className="h-5 w-1/3 bg-gray-200 rounded"></div>
<div className="h-3 w-1/4 bg-gray-200 rounded"></div>
</div>
</div>
</div>
)
}
interface SkeletonEntityListProps {
count?: number
}
export function SkeletonEntityList({ count = 4 }: SkeletonEntityListProps) {
return (
<div className="space-y-3">
{Array.from({ length: count }).map((_, i) => (
<SkeletonEntityCard key={i} />
))}
</div>
)
}
export function SkeletonStats() {
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="card p-6 animate-pulse">
<div className="h-4 w-1/3 bg-gray-200 rounded mb-2"></div>
<div className="h-8 w-1/2 bg-gray-200 rounded"></div>
</div>
))}
</div>
)
}
export function SkeletonChart() {
return (
<div className="card p-6 animate-pulse">
<div className="h-4 w-1/4 bg-gray-200 rounded mb-4"></div>
<div className="h-64 bg-gray-100 rounded"></div>
</div>
)
}

View file

@ -1,4 +1,4 @@
import React, { ReactNode } from 'react';
import React, { ReactNode, useRef, useEffect } from 'react';
import { ExternalLink } from 'lucide-react';
interface WikiTooltipProps {
@ -14,19 +14,43 @@ export function WikiTooltip({ children, summary, imagePath, wikiUrl, name }: Wik
return <span className="text-gray-900">{children}</span>;
}
const tooltipRef = useRef<HTMLDivElement>(null)
// Close tooltip on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
// Force blur to close tooltip
const activeElement = document.activeElement as HTMLElement
activeElement?.blur()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [])
return (
<div className="relative group inline-block">
<span className="cursor-help underline decoration-dotted decoration-gray-400 underline-offset-4 text-primary-700 font-medium">
<div className="relative group inline-block" role="tooltip">
<span
className="cursor-help underline decoration-dotted decoration-gray-400 underline-offset-4 text-primary-700 font-medium focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 rounded"
tabIndex={0}
aria-describedby={`wiki-tooltip-${name.replace(/\s+/g, '-')}`}
>
{children}
</span>
<div className="absolute z-50 bottom-full left-1/2 -translate-x-1/2 mb-2 w-72 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-300 ease-out pointer-events-none group-hover:pointer-events-auto origin-bottom transform scale-95 group-hover:scale-100">
<div
id={`wiki-tooltip-${name.replace(/\s+/g, '-')}`}
ref={tooltipRef}
className="absolute z-50 bottom-full left-1/2 -translate-x-1/2 mb-2 w-72 opacity-0 invisible group-hover:opacity-100 group-hover:visible group-focus-within:opacity-100 group-focus-within:visible transition-all duration-300 ease-out pointer-events-none group-hover:pointer-events-auto group-focus-within:pointer-events-auto origin-bottom transform scale-95 group-hover:scale-100 group-focus-within:scale-100"
role="tooltip"
>
<div className="bg-white rounded-xl shadow-[0_10px_40px_-10px_rgba(0,0,0,0.15)] border border-gray-100 overflow-hidden text-left flex flex-col">
{imagePath && (
<div className="w-full h-44 bg-gray-100 overflow-hidden relative border-b border-gray-100">
<img
src={imagePath}
alt={name}
alt={`${name} - Wikipedia image`}
className="w-full h-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
@ -45,18 +69,18 @@ export function WikiTooltip({ children, summary, imagePath, wikiUrl, name }: Wik
<a
href={wikiUrl}
target="_blank"
rel="noreferrer"
className="mt-3 inline-flex items-center gap-1 text-xs font-semibold text-primary-600 hover:text-primary-800 transition-colors"
rel="noopener noreferrer"
className="mt-3 inline-flex items-center gap-1 text-xs font-semibold text-primary-600 hover:text-primary-800 transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 rounded"
onClick={(e) => e.stopPropagation()}
>
Ver en Wikipedia <ExternalLink className="h-3 w-3" />
Ver en Wikipedia <ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
)}
</div>
</div>
{/* Triangle arrow */}
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-[1px] border-8 border-transparent border-t-white z-10"></div>
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-[0px] border-8 border-transparent border-t-gray-100 -z-10 blur-[1px]"></div>
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-[1px] border-8 border-transparent border-t-white z-10" aria-hidden="true"></div>
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-[0px] border-8 border-transparent border-t-gray-100 -z-10 blur-[1px]" aria-hidden="true"></div>
</div>
</div>
);

View file

@ -0,0 +1,3 @@
export { SkeletonNewsCard, SkeletonNewsList, SkeletonFeedCard, SkeletonFeedList, SkeletonEntityCard, SkeletonEntityList, SkeletonStats, SkeletonChart } from './Skeleton'
export { EmptyState, NoResultsFound, NoNewsYet, NoFeedsConfigured, NoSearchResults, OfflineState, ErrorState } from './EmptyState'
export { WikiTooltip } from './WikiTooltip'

View file

@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { apiService, Alerta } from '../services/api'
import { Bell, CheckCheck, ChevronDown, ChevronUp } from 'lucide-react'
import { Link } from 'react-router-dom'
import { apiService, api, Alerta, News } from '../services/api'
import { Bell, CheckCheck, ChevronDown, ChevronUp, FileText } from 'lucide-react'
const TIPO_LABEL: Record<string, string> = {
persona: 'Persona',
@ -15,6 +16,11 @@ export function Alertas() {
const [nuevas, setNuevas] = useState(0)
const [limit, setLimit] = useState(50)
const [newsModal, setNewsModal] = useState<Alerta | null>(null)
const [entityNews, setEntityNews] = useState<News[]>([])
const [newsLoading, setNewsLoading] = useState(false)
const [newsTotal, setNewsTotal] = useState(0)
const load = async () => {
setLoading(true)
try {
@ -42,6 +48,26 @@ export function Alertas() {
load()
}
const openNews = async (alerta: Alerta) => {
setNewsModal(alerta)
setNewsLoading(true)
setEntityNews([])
setNewsTotal(0)
try {
const params = new URLSearchParams()
params.append('valor', alerta.valor)
params.append('tipo', alerta.tipo)
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)
}
}
return (
<div>
<div className="flex justify-between items-center mb-8">
@ -108,13 +134,17 @@ export function Alertas() {
</span>
</td>
<td className="px-4 py-3">
{a.status === 'nueva' ? (
<button onClick={() => markRead(a.id)} className="btn-secondary text-xs py-1 px-3">
Marcar leída
<div className="flex items-center gap-2">
<button onClick={() => openNews(a)} className="btn-secondary text-xs py-1 px-3 flex items-center gap-1">
<FileText className="h-3.5 w-3.5" />
Noticias
</button>
) : (
<span className="text-sm text-gray-300 dark:text-gray-600">—</span>
)}
{a.status === 'nueva' && (
<button onClick={() => markRead(a.id)} className="btn-secondary text-xs py-1 px-3">
Marcar leída
</button>
)}
</div>
</td>
</tr>
))}
@ -132,6 +162,75 @@ export function Alertas() {
Mostrar {limit > 50 ? 'menos' : 'más'} ({limit})
</button>
</div>
{/* Noticias relacionadas del concepto */}
{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">Noticias sobre &quot;{newsModal.valor}&quot;</h2>
<p className="text-sm text-gray-500 mt-0.5">
{newsTotal} noticias encontradas · {TIPO_LABEL[newsModal.tipo] || newsModal.tipo} · {newsModal.periodo}
</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">No hay noticias que mencionen a este concepto.</div>
) : (
<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

@ -5,6 +5,7 @@ import { formatDistanceToNow } from 'date-fns'
import { es } from 'date-fns/locale'
import { apiService, api } from '../services/api'
import { Search, Globe, Newspaper, Filter } from 'lucide-react'
import { SkeletonNewsList, NoResultsFound } from '../components/ui'
export function Home() {
const [searchParams, setSearchParams] = useSearchParams()
@ -156,13 +157,22 @@ export function Home() {
</div>
{isLoading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
</div>
<SkeletonNewsList count={6} />
) : error ? (
<div className="text-center py-12 text-red-600">
Error al cargar noticias
<button
onClick={() => window.location.reload()}
className="btn-primary mt-4"
>
Reintentar
</button>
</div>
) : data?.news?.length === 0 ? (
<NoResultsFound
query={q}
onClearFilters={() => setSearchParams({ page: '1', q })}
/>
) : (
<>
<div className="mb-4 text-sm text-gray-600">

View file

@ -33,13 +33,13 @@ 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 },
const TIMELINE_WINDOWS: { key: string; label: string; title: string; days: number }[] = [
{ key: '1', label: '24h', title: 'últimas 24 horas', days: 1 },
{ key: '2', label: '2 días', title: 'últimos 2 días', days: 2 },
{ key: '3', label: '3 días', title: 'últimos 3 días', days: 3 },
{ key: '4', label: '4 días', title: 'últimos 4 días', days: 4 },
{ key: '5', label: '5 días', title: 'últimos 5 días', days: 5 },
{ key: '7', label: '7 días', title: 'últimos 7 días', days: 7 },
]
function getTipoInfo(tipo: string) {
@ -61,6 +61,8 @@ export function Populares() {
const [configModal, setConfigModal] = useState<ConfigModal | null>(null)
const [saving, setSaving] = useState(false)
const [successMsg, setSuccessMsg] = useState('')
const [expandedEntities, setExpandedEntities] = useState<Set<string>>(new Set())
const [allExpanded, setAllExpanded] = useState(false)
// News-by-entity modal state
const [newsModal, setNewsModal] = useState<Entity | null>(null)
@ -158,7 +160,7 @@ export function Populares() {
const params = new URLSearchParams()
params.append('valor', entity.valor)
params.append('tipo', entity.tipo)
params.append('day_offset', String(win.offset))
params.append('days', String(win.days))
params.append('per_page', '50')
const res = await api.get(`/entities/news?${params}`)
setEntityNews(res.data.news || [])
@ -270,6 +272,29 @@ export function Populares() {
<h1 className="text-2xl font-bold">
🔥 Populares {selectedCountry ? `en ${selectedCountry.nombre}` : 'Global'}
</h1>
<div className="flex items-center gap-4">
<button
onClick={() => setAllExpanded(p => !p)}
className="px-3 py-1.5 text-sm border rounded hover:bg-gray-50 dark:hover:bg-gray-700 dark:border-gray-600 transition-colors"
title="Expandir/collapse todo"
>
{allExpanded ? '← Contraer todas' : '→ Expandir todas'}
</button>
<button
onClick={() => setExpandedEntities(p => p.clear())}
className="px-3 py-1.5 text-sm border rounded hover:bg-gray-50 dark:hover:bg-gray-700 dark:border-gray-600 transition-colors"
title="Contraer todas"
>
← Contraer
</button>
<button
onClick={() => setExpandedEntities(p => p.add('all'))}
className="px-3 py-1.5 text-sm border rounded hover:bg-gray-50 dark:hover:bg-gray-700 dark:border-gray-600 transition-colors"
title="Expandir todas"
>
Expandir →
</button>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={handleExportAliases}
@ -359,96 +384,102 @@ export function Populares() {
</div>
</div>
{/* Table */}
{/* Listado de entidades */}
{loading ? (
<div className="text-center py-12 text-gray-400">Cargando...</div>
) : entities.length === 0 ? (
<div className="text-center py-12 text-gray-400">No se encontraron entidades</div>
) : (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider w-10">#</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Entidad</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider hidden md:table-cell">Tipo</th>
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">Menciones</th>
<th className="px-4 py-3 text-center text-xs font-semibold text-gray-500 uppercase tracking-wider w-24">Config</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{entities.map((entity, idx) => {
const tipoInfo = getTipoInfo(entity.tipo)
return (
<tr key={entity.valor + idx} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
<td className="px-4 py-3 text-gray-400 text-sm">{(page - 1) * 50 + idx + 1}</td>
<td className="px-4 py-3 font-medium">
<div className="p-4 bg-gray-50 dark:bg-gray-700 border-b dark:border-gray-700">
<div className="flex flex-col sm:flex-row items-center justify-between gap-2">
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">📊 {entities.length} entidades populares</span>
<div className="flex gap-1">
<button
onClick={() => setExpandedEntities(p => p.clear())}
className="px-2 py-1 text-[10px] text-gray-500 hover:text-blue-600 dark:hover:text-blue-300 rounded"
title="Contraer todas"
>
←
</button>
<button
onClick={() => setExpandedEntities(p => p.add('all'))}
className="px-2 py-1 text-[10px] text-gray-500 hover:text-blue-600 dark:hover:text-blue-300 rounded"
title="Expandir todas"
>
Expandir →
</button>
</div>
</div>
</div>
<div className="p-4 space-y-2">
{entities.map((entity, idx) => {
const isExpanded = allExpanded || expandedEntities.has(entity.valor)
const tipoInfo = getTipoInfo(entity.tipo)
return (
<div
key={entity.valor}
className={`collapse ${isExpanded ? 'in' : ''} transition-colors duration-300 ease-in-out`}
>
<div className="flex items-center justify-between px-2 py-2 border-b dark:border-gray-700 last:border-0">
<div className="flex items-center gap-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" onClick={() => setExpandedEntities(p => p.toggle(entity.valor))}>
<WikiTooltip
name={entity.valor}
summary={entity.wiki_summary}
imagePath={entity.image_path}
wikiUrl={entity.wiki_url}
>
<div className="flex items-center gap-3">
{entity.image_path && (
<img
src={entity.image_path}
alt=""
className="w-24 h-24 rounded-full object-cover border-2 border-white shadow-md mx-auto"
onError={(e) => (e.currentTarget.style.display = 'none')}
/>
)}
<span>{entity.valor}</span>
</div>
<span className="font-medium text-blue-600 dark:text-blue-300 hover:underline transition-colors">
{entity.valor}
</span>
</WikiTooltip>
</td>
<td className="px-4 py-3 hidden md:table-cell">
<span className="text-xs text-gray-500 dark:text-gray-400">
#{entity.count} menciones
</span>
</div>
<div className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${tipoInfo.color}`}>
{tipoInfo.label}
</span>
</td>
<td className="px-4 py-3 text-right">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-sm font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{entity.count}
</span>
</td>
<td className="px-4 py-3 text-center">
<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>
<span>{entity.count}</span>
</div>
</div>
{isExpanded && (
<div className="p-3 pt-0">
<div className="flex items-center justify-between gap-2">
<button
onClick={() => openNews(entity)}
className="px-2 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"
>
🔍 Ver noticias
</button>
<button
onClick={() => openConfig(entity)}
className="px-2 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>
</td>
</tr>
)
})}
</tbody>
</table>
<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>
)}
</div>
)
})}
</div>
</div>
)}

View file

@ -3,6 +3,7 @@ import { useSearchParams, Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { apiService, api, News, Category, Country } from '../services/api'
import { Search as SearchIcon, Filter } from 'lucide-react'
import { SkeletonNewsList, NoResultsFound, NoSearchResults } from '../components/ui'
export function Search() {
const [searchParams, setSearchParams] = useSearchParams()
@ -132,13 +133,14 @@ export function Search() {
<p className="text-gray-600 mb-4">{data.total} resultados encontrados</p>
)}
{isLoading && (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
</div>
)}
{data?.news && data.news.length > 0 && (
{isLoading ? (
<SkeletonNewsList count={6} />
) : data?.news?.length === 0 ? (
<NoResultsFound
query={q}
onClearFilters={clearFilters}
/>
) : data?.news && data.news.length > 0 ? (
<div className="space-y-4">
{data.news.map((news: News) => (
<Link key={news.id} to={`/news/${news.id}`} className="card p-4 block hover:shadow-md transition-shadow">
@ -151,14 +153,8 @@ export function Search() {
</Link>
))}
</div>
)}
{(!q && !lang && !categoria && !pais) && (
<p className="text-center text-gray-500 mt-8">Introduce una búsqueda o selecciona filtros</p>
)}
{q && !isLoading && data?.news?.length === 0 && (
<p className="text-center text-gray-500 mt-8">No se encontraron resultados</p>
) : (
<NoSearchResults query={q} onSearch={clearFilters} />
)}
</div>
)