cambios
This commit is contained in:
parent
3991237685
commit
a01050bf85
46 changed files with 3636 additions and 652 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
166
frontend/src/components/ui/EmptyState.tsx
Normal file
166
frontend/src/components/ui/EmptyState.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
110
frontend/src/components/ui/Skeleton.tsx
Normal file
110
frontend/src/components/ui/Skeleton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
3
frontend/src/components/ui/index.ts
Normal file
3
frontend/src/components/ui/index.ts
Normal 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'
|
||||
|
|
@ -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 "{newsModal.valor}"</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue