go integration and wikipedia

This commit is contained in:
jlimolina 2026-03-28 18:30:07 +01:00
parent 47a252e339
commit ee90335b92
7828 changed files with 1307913 additions and 20807 deletions

76
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,76 @@
import { useState, useEffect } from 'react'
import { Routes, Route } from 'react-router-dom'
import { Layout } from './components/layout/Layout'
import { Home } from './pages/Home'
import { News } from './pages/News'
import { Feeds } from './pages/Feeds'
import { Search } from './pages/Search'
import { Login } from './pages/Login'
import { Stats } from './pages/Stats'
import { Favorites } from './pages/Favorites'
import { Account } from './pages/Account'
import { Populares } from './pages/Populares'
import { AdminAliases } from './pages/AdminAliases'
import { AdminUsers } from './pages/AdminUsers'
import { AdminSettings } from './pages/AdminSettings'
import { AdminWorkers } from './pages/AdminWorkers'
import { WelcomeWizard } from './pages/WelcomeWizard'
import { api } from './services/api'
function App() {
const [showWelcome, setShowWelcome] = useState<boolean | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
checkFirstUser()
}, [])
const checkFirstUser = async () => {
try {
const res = await api.get('/auth/check-first-user')
setShowWelcome(res.data.is_first_user)
} catch {
setShowWelcome(false)
} finally {
setLoading(false)
}
}
const handleWelcomeComplete = () => {
setShowWelcome(false)
}
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
</div>
)
}
if (showWelcome) {
return <WelcomeWizard onComplete={handleWelcomeComplete} />
}
return (
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="news/:id" element={<News />} />
<Route path="feeds" element={<Feeds />} />
<Route path="search" element={<Search />} />
<Route path="populares" element={<Populares />} />
<Route path="stats" element={<Stats />} />
<Route path="favorites" element={<Favorites />} />
<Route path="account" element={<Account />} />
<Route path="login" element={<Login />} />
<Route path="admin/aliases" element={<AdminAliases />} />
<Route path="admin/users" element={<AdminUsers />} />
<Route path="admin/settings" element={<AdminSettings />} />
<Route path="admin/workers" element={<AdminWorkers />} />
</Route>
</Routes>
)
}
export default App

View file

@ -0,0 +1,157 @@
import { Outlet, Link, useNavigate } from 'react-router-dom'
import { Search, Rss, BarChart3, Home as HomeIcon, Heart, User, Flame, Settings, Users, Tags, Database, Server } from 'lucide-react'
import { useEffect, useState } from 'react'
export function Layout() {
const navigate = useNavigate()
const [isLoggedIn, setIsLoggedIn] = useState(false)
const [username, setUsername] = useState('')
const [isAdmin, setIsAdmin] = useState(false)
const [showAdminMenu, setShowAdminMenu] = useState(false)
const checkAuth = () => {
const token = localStorage.getItem('token')
const userData = localStorage.getItem('user')
if (token && userData) {
try {
const user = JSON.parse(userData)
setIsLoggedIn(true)
setUsername(user.username || '')
setIsAdmin(user.is_admin === true)
} catch {
setIsLoggedIn(false)
setUsername('')
setIsAdmin(false)
}
} else {
setIsLoggedIn(false)
setUsername('')
setIsAdmin(false)
}
}
useEffect(() => {
checkAuth()
// Listen for storage changes (when user logs in/out in another tab)
window.addEventListener('storage', checkAuth)
return () => window.removeEventListener('storage', checkAuth)
}, [])
const handleLogout = () => {
localStorage.removeItem('token')
localStorage.removeItem('user')
setIsLoggedIn(false)
setUsername('')
navigate('/login')
}
return (
<div className="min-h-screen bg-gray-50">
<nav className="bg-white border-b border-gray-200 sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex items-center">
<Link to="/" className="flex items-center gap-2">
<Rss className="h-8 w-8 text-primary-600" />
<span className="text-xl font-bold text-gray-900">RSS2</span>
</Link>
<div className="hidden sm:flex ml-10 space-x-8">
<Link to="/" className="flex items-center gap-2 text-gray-600 hover:text-primary-600 transition-colors">
<HomeIcon className="h-4 w-4" />
Home
</Link>
<Link to="/feeds" className="flex items-center gap-2 text-gray-600 hover:text-primary-600 transition-colors">
<Rss className="h-4 w-4" />
Feeds
</Link>
<Link to="/search" className="flex items-center gap-2 text-gray-600 hover:text-primary-600 transition-colors">
<Search className="h-4 w-4" />
Search
</Link>
<Link to="/populares" className="flex items-center gap-2 text-gray-600 hover:text-primary-600 transition-colors">
<Flame className="h-4 w-4" />
Popular
</Link>
<Link to="/stats" className="flex items-center gap-2 text-gray-600 hover:text-primary-600 transition-colors">
<BarChart3 className="h-4 w-4" />
Stats
</Link>
{isAdmin && (
<div className="relative">
<button
onClick={() => setShowAdminMenu(!showAdminMenu)}
className="flex items-center gap-2 text-gray-600 hover:text-primary-600 transition-colors"
>
<Settings className="h-4 w-4" />
Admin
</button>
{showAdminMenu && (
<div className="absolute left-0 mt-2 w-48 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg py-1 z-50">
<Link
to="/admin/workers"
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
onClick={() => setShowAdminMenu(false)}
>
<Server className="h-4 w-4" />
Workers
</Link>
<Link
to="/populares"
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
onClick={() => setShowAdminMenu(false)}
>
<Tags className="h-4 w-4" />
Alias / Tipos (Popular)
</Link>
<Link
to="/admin/users"
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
onClick={() => setShowAdminMenu(false)}
>
<Users className="h-4 w-4" />
Usuarios
</Link>
<Link
to="/admin/settings"
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
onClick={() => setShowAdminMenu(false)}
>
<Database className="h-4 w-4" />
Configuración
</Link>
</div>
)}
</div>
)}
<Link to="/favorites" className="flex items-center gap-2 text-gray-600 hover:text-primary-600 transition-colors">
<Heart className="h-4 w-4" />
Favorites
</Link>
</div>
</div>
<div className="flex items-center gap-4">
{isLoggedIn ? (
<>
<Link to="/account" className="flex items-center gap-2 text-gray-600 hover:text-primary-600">
<User className="h-4 w-4" />
{username}
</Link>
<button onClick={handleLogout} className="btn-secondary">
Logout
</button>
</>
) : (
<Link to="/login" className="btn-secondary">
Login
</Link>
)}
</div>
</div>
</div>
</nav>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<Outlet />
</main>
</div>
)
}

View file

@ -0,0 +1,63 @@
import React, { ReactNode } from 'react';
import { ExternalLink } from 'lucide-react';
interface WikiTooltipProps {
children: ReactNode;
summary?: string;
imagePath?: string;
wikiUrl?: string;
name: string;
}
export function WikiTooltip({ children, summary, imagePath, wikiUrl, name }: WikiTooltipProps) {
if (!summary && !imagePath) {
return <span className="text-gray-900">{children}</span>;
}
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">
{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 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}
className="w-full h-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
)}
<div className="p-4 w-full">
<h4 className="font-bold text-gray-900 text-base mb-1">{name}</h4>
{summary && (
<p className="text-sm text-gray-600 line-clamp-4 leading-relaxed">
{summary}
</p>
)}
{wikiUrl && (
<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"
onClick={(e) => e.stopPropagation()}
>
Ver en Wikipedia <ExternalLink className="h-3 w-3" />
</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>
</div>
);
}

38
frontend/src/index.css Normal file
View file

@ -0,0 +1,38 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
@layer components {
.btn-primary {
@apply bg-primary-600 hover:bg-primary-700 text-white font-medium py-2 px-4 rounded-lg transition-colors;
}
.btn-secondary {
@apply bg-gray-200 hover:bg-gray-300 text-gray-800 font-medium py-2 px-4 rounded-lg transition-colors;
}
.card {
@apply bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700;
}
.input {
@apply w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 focus:ring-2 focus:ring-primary-500 focus:border-transparent;
}
}

25
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,25 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
retry: 1,
},
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>,
)

View file

@ -0,0 +1,156 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { User, LogOut, Save } from 'lucide-react'
interface UserData {
id: number
email: string
username: string
is_admin: boolean
created_at: string
avatar_url?: string
}
export function Account() {
const navigate = useNavigate()
const [user, setUser] = useState<UserData | null>(null)
const [username, setUsername] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [message, setMessage] = useState('')
useEffect(() => {
const token = localStorage.getItem('token')
const userData = localStorage.getItem('user')
if (!token) {
navigate('/login')
return
}
if (userData) {
const parsed = JSON.parse(userData)
setUser(parsed)
setUsername(parsed.username || '')
setEmail(parsed.email || '')
}
}, [navigate])
const handleLogout = () => {
localStorage.removeItem('token')
localStorage.removeItem('user')
navigate('/login')
}
const handleUpdate = (e: React.FormEvent) => {
e.preventDefault()
setMessage('Profile updated successfully!')
setTimeout(() => setMessage(''), 3000)
}
if (!user) {
return null
}
return (
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-8">Account</h1>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="md:col-span-2">
<form onSubmit={handleUpdate} className="card p-6">
<div className="flex items-center gap-3 mb-6">
<div className="h-16 w-16 rounded-full bg-primary-100 flex items-center justify-center">
<User className="h-8 w-8 text-primary-600" />
</div>
<div>
<h2 className="text-xl font-semibold">{user.username}</h2>
<p className="text-gray-500 text-sm">{user.email}</p>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Username
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="input"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="input"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
New Password (leave blank to keep current)
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input"
placeholder="••••••••"
/>
</div>
{message && (
<div className="p-3 bg-green-50 text-green-700 rounded-lg">
{message}
</div>
)}
<button type="submit" className="btn-primary flex items-center gap-2">
<Save className="h-4 w-4" />
Save Changes
</button>
</div>
</form>
</div>
<div>
<div className="card p-6">
<h3 className="font-semibold mb-4">Account Info</h3>
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-gray-500">User ID:</span>
<span className="font-medium">{user.id}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Role:</span>
<span className="font-medium">{user.is_admin ? 'Admin' : 'User'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Member since:</span>
<span className="font-medium">
{new Date(user.created_at).toLocaleDateString()}
</span>
</div>
</div>
<button
onClick={handleLogout}
className="btn-danger w-full mt-6 flex items-center justify-center gap-2"
>
<LogOut className="h-4 w-4" />
Logout
</button>
</div>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,255 @@
import { useState, useEffect } from 'react'
import { api } from '../services/api'
interface Alias {
id: number
alias: string
canonical_name: string
tipo: string
created_at: string
}
export function AdminAliases() {
const [aliases, setAliases] = useState<Alias[]>([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingAlias, setEditingAlias] = useState<Alias | null>(null)
const [tipoFilter, setTipoFilter] = useState('')
const [form, setForm] = useState({
alias: '',
canonical_name: '',
tipo: 'persona'
})
useEffect(() => {
fetchAliases()
}, [tipoFilter])
const fetchAliases = async () => {
setLoading(true)
try {
const params = tipoFilter ? `?tipo=${tipoFilter}` : ''
const res = await api.get(`/admin/aliases${params}`)
setAliases(res.data.aliases)
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
if (editingAlias) {
await api.put(`/admin/aliases/${editingAlias.id}`, form)
} else {
await api.post('/admin/aliases', form)
}
setShowModal(false)
setEditingAlias(null)
setForm({ alias: '', canonical_name: '', tipo: 'persona' })
fetchAliases()
} catch (err) {
console.error(err)
alert('Error al guardar alias')
}
}
const handleEdit = (alias: Alias) => {
setEditingAlias(alias)
setForm({
alias: alias.alias,
canonical_name: alias.canonical_name,
tipo: alias.tipo
})
setShowModal(true)
}
const handleDelete = async (id: number) => {
if (!confirm('¿Eliminar este alias?')) return
try {
await api.delete(`/admin/aliases/${id}`)
fetchAliases()
} catch (err) {
console.error(err)
}
}
const handleExport = async () => {
try {
const res = await api.get('/admin/aliases/export', { responseType: 'blob' })
const url = window.URL.createObjectURL(new Blob([res.data]))
const link = document.createElement('a')
link.href = url
link.setAttribute('download', 'aliases.csv')
document.body.appendChild(link)
link.click()
link.remove()
} catch (err) {
console.error(err)
}
}
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const formData = new FormData()
formData.append('file', file)
try {
const res = await api.post('/admin/aliases/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
alert(`Importados: ${res.data.inserted}, Omitidos: ${res.data.skipped}`)
fetchAliases()
} catch (err) {
console.error(err)
alert('Error al importar')
}
}
const openNewModal = () => {
setEditingAlias(null)
setForm({ alias: '', canonical_name: '', tipo: 'persona' })
setShowModal(true)
}
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Gestión de Alias</h1>
<div className="flex gap-2">
<button onClick={handleExport} className="btn-secondary">
Exportar CSV
</button>
<label className="btn-secondary cursor-pointer">
Importar CSV
<input type="file" accept=".csv" onChange={handleImport} className="hidden" />
</label>
<button onClick={openNewModal} className="btn-primary">
+ Nuevo Alias
</button>
</div>
</div>
<div className="mb-4">
<select
value={tipoFilter}
onChange={(e) => setTipoFilter(e.target.value)}
className="p-2 border rounded dark:bg-gray-700"
>
<option value="">Todos los tipos</option>
<option value="persona">Personas</option>
<option value="organizacion">Organizaciones</option>
<option value="lugar">Lugares</option>
<option value="tema">Temas</option>
</select>
</div>
{loading ? (
<div className="text-center py-8">Cargando...</div>
) : aliases.length === 0 ? (
<div className="text-center py-8 text-gray-500">No hay aliases</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">Alias</th>
<th className="px-4 py-3 text-left">Nombre Canónico</th>
<th className="px-4 py-3 text-left">Tipo</th>
<th className="px-4 py-3 text-right">Acciones</th>
</tr>
</thead>
<tbody>
{aliases.map((alias) => (
<tr key={alias.id} className="border-t dark:border-gray-700">
<td className="px-4 py-3 font-mono">{alias.alias}</td>
<td className="px-4 py-3">{alias.canonical_name}</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded text-xs ${
alias.tipo === 'persona' ? 'bg-blue-100 text-blue-800' :
alias.tipo === 'organizacion' ? 'bg-purple-100 text-purple-800' :
alias.tipo === 'lugar' ? 'bg-green-100 text-green-800' :
'bg-gray-100 text-gray-800'
}`}>
{alias.tipo}
</span>
</td>
<td className="px-4 py-3 text-right">
<button onClick={() => handleEdit(alias)} className="text-blue-600 hover:underline mr-3">
Editar
</button>
<button onClick={() => handleDelete(alias.id)} className="text-red-600 hover:underline">
Eliminar
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{showModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4">
{editingAlias ? 'Editar Alias' : 'Nuevo Alias'}
</h2>
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Alias (ej: starmer)</label>
<input
type="text"
value={form.alias}
onChange={(e) => setForm({ ...form, alias: e.target.value })}
className="w-full p-2 border rounded dark:bg-gray-700"
required
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Nombre Canónico (ej: Keir Starmer)</label>
<input
type="text"
value={form.canonical_name}
onChange={(e) => setForm({ ...form, canonical_name: e.target.value })}
className="w-full p-2 border rounded dark:bg-gray-700"
required
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Tipo</label>
<select
value={form.tipo}
onChange={(e) => setForm({ ...form, tipo: e.target.value })}
className="w-full p-2 border rounded dark:bg-gray-700"
>
<option value="persona">Persona</option>
<option value="organizacion">Organización</option>
<option value="lugar">Lugar</option>
<option value="tema">Tema</option>
</select>
</div>
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={() => setShowModal(false)}
className="px-4 py-2 border rounded hover:bg-gray-100 dark:hover:bg-gray-700"
>
Cancelar
</button>
<button type="submit" className="btn-primary">
Guardar
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,129 @@
import { useState } from 'react'
import { apiService } from '../services/api'
import { AlertTriangle, Database, RefreshCw, Download, FileArchive } from 'lucide-react'
export function AdminSettings() {
const [resetting, setResetting] = useState(false)
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const handleReset = async () => {
const confirmMsg = '¿Estás seguro de que quieres BORRAR TODA LA BASE DE DATOS?\n\nEsta acción eliminará:\n- Todas las noticias\n- Todos los feeds\n- Todas las traducciones\n- Todos los favoritos\n- Todos los alias\n\nEsta acción NO se puede deshacer.'
if (!confirm(confirmMsg)) return
if (!confirm('¿REALMENTE ESTÁS SEGURO? Escribe "SI" para confirmar.')) return
const input = prompt('Escribe "SI" para confirmar el borrado total:')
if (input !== 'SI') {
setMessage({ type: 'error', text: 'Cancelado: no escribiste "SI"' })
return
}
setResetting(true)
setMessage(null)
try {
const result = await apiService.resetDatabase()
setMessage({ type: 'success', text: result.message })
} catch (err: any) {
setMessage({
type: 'error',
text: err?.response?.data?.error || 'Error al resetear la base de datos'
})
} finally {
setResetting(false)
}
}
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-6 flex items-center gap-2">
<Database className="h-6 w-6" />
Configuración del Sistema
</h1>
<div className="max-w-2xl space-y-6">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-red-600" />
Zona de Peligro
</h2>
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<h3 className="font-medium text-red-800 dark:text-red-200 mb-2">
Resetear Base de Datos
</h3>
<p className="text-sm text-red-600 dark:text-red-300 mb-4">
Eliminará todas las noticias, feeds, traducciones, favoritos y alias.
Los usuarios NO serán eliminados. Esta acción no se puede deshacer.
</p>
<button
onClick={handleReset}
disabled={resetting}
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{resetting ? (
<>
<RefreshCw className="h-4 w-4 animate-spin" />
Reseteando...
</>
) : (
<>
<AlertTriangle className="h-4 w-4" />
Resetear Base de Datos
</>
)}
</button>
{message && (
<div className={`mt-4 p-3 rounded-md text-sm ${
message.type === 'success'
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200'
: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-200'
}`}>
{message.text}
</div>
)}
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Download className="h-5 w-5 text-blue-600" />
Copia de Seguridad
</h2>
<div className="space-y-4">
<div className="p-4 border border-gray-100 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-900/50">
<h3 className="font-medium mb-1 flex items-center gap-2">
<FileArchive className="h-4 w-4" />
Noticias y Traducciones (ZIP)
</h3>
<p className="text-sm text-gray-500 mb-4">
Descarga un archivo comprimido con todas las noticias, traducciones y sus etiquetas (tags).
Ideal para respaldar el contenido generado sin incluir configuraciones de sistema.
</p>
<button
onClick={() => apiService.backupNewsZipped()}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
<Download className="h-4 w-4" />
Descargar Backup .zip
</button>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-4">Información del Sistema</h2>
<div className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
<p> Los alias permiten unificar entidades (ej: "Starmer" "Keir Starmer")</p>
<p> Solo los administradores pueden gestionar usuarios y alias</p>
<p> El primer usuario registrado se convierte en administrador automáticamente</p>
</div>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,150 @@
import { useState, useEffect } from 'react'
import { api } from '../services/api'
import { Users, Shield, ShieldOff, Crown } from 'lucide-react'
interface User {
id: number
email: string
username: string
is_admin: boolean
created_at: string
}
export function AdminUsers() {
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
const [currentUserId, setCurrentUserId] = useState<number | null>(null)
useEffect(() => {
const userData = localStorage.getItem('user')
if (userData) {
try {
const user = JSON.parse(userData)
setCurrentUserId(user.id)
} catch {}
}
fetchUsers()
}, [])
const fetchUsers = async () => {
setLoading(true)
try {
const res = await api.get('/admin/users')
setUsers(res.data.users)
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}
const handlePromote = async (userId: number) => {
if (!confirm('¿Promover este usuario a administrador?')) return
try {
await api.post(`/admin/users/${userId}/promote`)
fetchUsers()
} catch (err) {
console.error(err)
alert('Error al promover usuario')
}
}
const handleDemote = async (userId: number) => {
if (!confirm('¿Quitar permisos de administrador a este usuario?')) return
try {
await api.post(`/admin/users/${userId}/demote`)
fetchUsers()
} catch (err) {
console.error(err)
alert('Error al quitar permisos')
}
}
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('es', {
year: 'numeric',
month: 'short',
day: 'numeric'
})
}
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold flex items-center gap-2">
<Users className="h-6 w-6" />
Gestión de Usuarios
</h1>
</div>
{loading ? (
<div className="text-center py-8">Cargando...</div>
) : users.length === 0 ? (
<div className="text-center py-8 text-gray-500">No hay usuarios</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">Usuario</th>
<th className="px-4 py-3 text-left">Email</th>
<th className="px-4 py-3 text-left">Rol</th>
<th className="px-4 py-3 text-left">Fecha Registro</th>
<th className="px-4 py-3 text-right">Acciones</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-t dark:border-gray-700">
<td className="px-4 py-3 font-medium">
{user.username}
{user.id === currentUserId && (
<span className="ml-2 text-xs bg-blue-100 text-blue-800 px-2 py-0.5 rounded"></span>
)}
</td>
<td className="px-4 py-3 text-gray-500">{user.email}</td>
<td className="px-4 py-3">
{user.is_admin ? (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs bg-purple-100 text-purple-800">
<Crown className="h-3 w-3" />
Admin
</span>
) : (
<span className="px-2 py-1 rounded text-xs bg-gray-100 text-gray-800">
Usuario
</span>
)}
</td>
<td className="px-4 py-3 text-gray-500 text-sm">
{formatDate(user.created_at)}
</td>
<td className="px-4 py-3 text-right">
{user.id !== currentUserId && (
user.is_admin ? (
<button
onClick={() => handleDemote(user.id)}
className="text-red-600 hover:text-red-800 text-sm flex items-center gap-1 ml-auto"
>
<ShieldOff className="h-4 w-4" />
Quitar Admin
</button>
) : (
<button
onClick={() => handlePromote(user.id)}
className="text-green-600 hover:text-green-800 text-sm flex items-center gap-1 ml-auto"
>
<Shield className="h-4 w-4" />
Hacer Admin
</button>
)
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,261 @@
import { useState, useEffect } from 'react'
import { api } from '../services/api'
import { Cpu, Play, Square, Settings, RefreshCw, Loader2, Server } from 'lucide-react'
interface WorkerStatus {
type: string
workers: number
status: string
running: number
}
export function AdminWorkers() {
const [status, setStatus] = useState<WorkerStatus | null>(null)
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [configType, setConfigType] = useState<'cpu' | 'gpu'>('cpu')
const [configWorkers, setConfigWorkers] = useState(2)
useEffect(() => {
fetchStatus()
}, [])
const fetchStatus = async () => {
setLoading(true)
try {
const res = await api.get('/admin/workers/status')
setStatus(res.data)
setConfigType(res.data.type)
setConfigWorkers(res.data.workers)
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}
const handleStart = async () => {
setActionLoading('start')
try {
await api.post('/admin/workers/start', {
type: configType,
workers: configWorkers
})
await fetchStatus()
} catch (err: any) {
alert(err.response?.data?.error || 'Error al iniciar workers')
} finally {
setActionLoading(null)
}
}
const handleStop = async () => {
setActionLoading('stop')
try {
await api.post('/admin/workers/stop')
await fetchStatus()
} catch (err: any) {
alert(err.response?.data?.error || 'Error al detener workers')
} finally {
setActionLoading(null)
}
}
const handleConfig = async () => {
setActionLoading('config')
try {
await api.post('/admin/workers/config', {
type: configType,
workers: configWorkers
})
await fetchStatus()
} catch (err: any) {
alert(err.response?.data?.error || 'Error al guardar configuración')
} finally {
setActionLoading(null)
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-primary-600" />
</div>
)
}
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold flex items-center gap-2">
<Server className="h-6 w-6" />
Workers de Traducción
</h1>
<button
onClick={fetchStatus}
className="btn-secondary flex items-center gap-2"
>
<RefreshCw className="h-4 w-4" />
Actualizar
</button>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Settings className="h-5 w-5" />
Configuración
</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2">Tipo de procesamiento</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setConfigType('cpu')}
className={`p-3 rounded-lg border-2 transition-all flex flex-col items-center ${
configType === 'cpu'
? 'border-green-500 bg-green-50 dark:bg-green-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-green-300'
}`}
>
<Cpu className="h-6 w-6 text-green-600 mb-1" />
<span className="font-medium">CPU</span>
</button>
<button
type="button"
onClick={() => setConfigType('gpu')}
className={`p-3 rounded-lg border-2 transition-all flex flex-col items-center ${
configType === 'gpu'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-blue-300'
}`}
>
<svg className="h-6 w-6 text-blue-600 mb-1" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
</svg>
<span className="font-medium">GPU</span>
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-2">Número de workers</label>
<div className="flex flex-wrap gap-2">
{[1, 2, 3, 4, 5, 6, 7, 8].map((num) => (
<button
key={num}
type="button"
onClick={() => setConfigWorkers(num)}
className={`w-10 h-10 rounded-lg font-medium transition-all ${
configWorkers === num
? 'bg-primary-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
{num}
</button>
))}
</div>
</div>
<button
onClick={handleConfig}
disabled={actionLoading === 'config'}
className="btn-primary w-full flex items-center justify-center gap-2"
>
{actionLoading === 'config' ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Settings className="h-4 w-4" />
)}
Guardar Configuración
</button>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Server className="h-5 w-5" />
Estado Actual
</h2>
<div className="space-y-4">
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<span className="font-medium">Estado</span>
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
status?.status === 'running'
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}>
{status?.status === 'running' ? 'Activo' : 'Detenido'}
</span>
</div>
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<span className="font-medium">Tipo</span>
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
status?.type === 'gpu'
? 'bg-blue-100 text-blue-800'
: 'bg-green-100 text-green-800'
}`}>
{status?.type === 'gpu' ? 'GPU' : 'CPU'}
</span>
</div>
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<span className="font-medium">Workers configurados</span>
<span className="text-lg font-bold">{status?.workers}</span>
</div>
<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>
</div>
<div className="flex gap-3 pt-2">
{status?.status === 'running' ? (
<button
onClick={handleStop}
disabled={actionLoading === 'stop'}
className="btn-danger flex-1 flex items-center justify-center gap-2"
>
{actionLoading === 'stop' ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Square className="h-4 w-4" />
)}
Detener
</button>
) : (
<button
onClick={handleStart}
disabled={actionLoading === 'start'}
className="btn-primary flex-1 flex items-center justify-center gap-2"
>
{actionLoading === 'start' ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
Iniciar
</button>
)}
</div>
</div>
</div>
</div>
<div className="mt-6 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h3 className="font-medium text-blue-800 dark:text-blue-200 mb-2">Información</h3>
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1">
<li> CPU: Más lento pero funciona en cualquier equipo</li>
<li> GPU: Mucho más rápido, necesita tarjeta NVIDIA</li>
<li> Los cambios en la configuración se aplicarán al iniciar los workers</li>
<li> Recomendado: 2-4 workers para CPU, 1-2 para GPU</li>
</ul>
</div>
</div>
)
}

View file

@ -0,0 +1,108 @@
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiService, News } from '../services/api'
import { Heart, Trash2, ExternalLink, Calendar, Newspaper } from 'lucide-react'
export function Favorites() {
const queryClient = useQueryClient()
const [favorites, setFavorites] = useState<News[]>([])
useEffect(() => {
const stored = localStorage.getItem('favorites')
if (stored) {
setFavorites(JSON.parse(stored))
}
}, [])
const removeFavorite = (id: string) => {
const updated = favorites.filter(n => n.id !== id)
setFavorites(updated)
localStorage.setItem('favorites', JSON.stringify(updated))
}
const formatDate = (dateStr?: string) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleDateString('es', { day: '2-digit', month: 'short', year: 'numeric' })
}
return (
<div>
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-gray-900">Favorites</h1>
<span className="text-gray-500">{favorites.length} news saved</span>
</div>
{favorites.length === 0 ? (
<div className="card p-12 text-center">
<Heart className="h-16 w-16 text-gray-300 mx-auto mb-4" />
<h2 className="text-xl font-semibold text-gray-700 mb-2">No favorites yet</h2>
<p className="text-gray-500">
Save news to your favorites by clicking the heart icon on any news item.
</p>
</div>
) : (
<div className="space-y-4">
{favorites.map((news) => (
<div key={news.id} className="card p-6 hover:shadow-md transition-shadow">
<div className="flex gap-4">
{news.imagen_url && (
<img
src={news.imagen_url}
alt={news.titulo}
className="w-32 h-24 object-cover rounded-lg flex-shrink-0"
/>
)}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-lg font-semibold text-gray-900 line-clamp-2">
{news.title_translated || news.titulo}
</h3>
{news.summary_translated && (
<p className="text-sm text-gray-600 mt-1 line-clamp-2">
{news.summary_translated}
</p>
)}
{news.summary_translated === null && news.resumen && (
<p className="text-sm text-gray-600 mt-1 line-clamp-2">
{news.resumen}
</p>
)}
</div>
<button
onClick={() => removeFavorite(news.id)}
className="text-red-500 hover:text-red-600 p-1"
title="Remove from favorites"
>
<Heart className="h-5 w-5 fill-current" />
</button>
</div>
<div className="flex items-center gap-4 mt-3 text-sm text-gray-500">
<span className="flex items-center gap-1">
<Newspaper className="h-4 w-4" />
{news.fuente_nombre}
</span>
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{formatDate(news.fecha)}
</span>
</div>
<a
href={news.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 hover:underline text-sm mt-2 inline-flex items-center gap-1"
>
<ExternalLink className="h-3 w-3" />
View Original
</a>
</div>
</div>
</div>
))}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,370 @@
import { useState, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiService, Feed, Category, Country } from '../services/api'
import { Plus, Trash2, ExternalLink, Upload, Download, RefreshCw, Power, PowerOff, AlertCircle } from 'lucide-react'
export function Feeds() {
const queryClient = useQueryClient()
const [showAddForm, setShowAddForm] = useState(false)
const [newFeed, setNewFeed] = useState({ nombre: '', url: '', descripcion: '', categoria_id: '', pais_id: '', idioma: '' })
const [filtroActivo, setFiltroActivo] = useState('')
const [filtroCategoria, setFiltroCategoria] = useState('')
const [filtroPais, setFiltroPais] = useState('')
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],
queryFn: () => apiService.getFeeds({
activo: filtroActivo || undefined,
categoria_id: filtroCategoria || undefined,
pais_id: filtroPais || undefined,
}),
})
const { data: categorias } = useQuery({
queryKey: ['categories'],
queryFn: apiService.getCategories,
})
const { data: paises } = useQuery({
queryKey: ['countries'],
queryFn: apiService.getCountries,
})
const createFeed = useMutation({
mutationFn: apiService.createFeed,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['feeds'] })
setShowAddForm(false)
setNewFeed({ nombre: '', url: '', descripcion: '', categoria_id: '', pais_id: '', idioma: '' })
},
})
const deleteFeed = useMutation({
mutationFn: apiService.deleteFeed,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['feeds'] })
},
})
const toggleFeed = useMutation({
mutationFn: apiService.toggleFeed,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['feeds'] })
},
})
const reactivateFeed = useMutation({
mutationFn: apiService.reactivateFeed,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['feeds'] })
},
})
const importFeeds = useMutation({
mutationFn: apiService.importFeeds,
onSuccess: (data) => {
setImportResult(data)
queryClient.invalidateQueries({ queryKey: ['feeds'] })
},
onError: (error: any) => {
const message = error?.response?.status === 401
? 'Debes iniciar sesión para importar archivos'
: error?.response?.data?.error || 'Error al importar el archivo'
setImportResult({ imported: 0, skipped: 0, failed: 0, message })
},
})
const handleExport = async () => {
try {
const blob = await apiService.exportFeeds({
activo: filtroActivo || undefined,
categoria_id: filtroCategoria || undefined,
pais_id: filtroPais || undefined,
})
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'feeds_export.csv'
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
} catch (error) {
console.error('Export failed:', error)
}
}
const handleImport = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
importFeeds.mutate(file)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
createFeed.mutate({
nombre: newFeed.nombre,
url: newFeed.url,
descripcion: newFeed.descripcion || undefined,
categoria_id: newFeed.categoria_id ? parseInt(newFeed.categoria_id) : undefined,
pais_id: newFeed.pais_id ? parseInt(newFeed.pais_id) : undefined,
idioma: newFeed.idioma || undefined,
})
}
return (
<div>
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-gray-900">Feeds RSS</h1>
<div className="flex gap-2">
<button onClick={() => fileInputRef.current?.click()} className="btn-secondary flex items-center gap-2">
<Upload className="h-4 w-4" />
Importar CSV
</button>
<input
ref={fileInputRef}
type="file"
accept=".csv"
onChange={handleImport}
className="hidden"
/>
<button onClick={handleExport} className="btn-secondary flex items-center gap-2">
<Download className="h-4 w-4" />
Exportar
</button>
<button onClick={() => setShowAddForm(!showAddForm)} className="btn-primary flex items-center gap-2">
<Plus className="h-4 w-4" />
Añadir Feed
</button>
</div>
</div>
{importResult && (
<div className={`card p-4 mb-4 ${importResult.failed > 0 || importResult.message.includes('Error') || importResult.message.includes('Debes') ? 'bg-red-50 border-red-200' : 'bg-green-50 border-green-200'}`}>
<p className={importResult.failed > 0 || importResult.message.includes('Error') || importResult.message.includes('Debes') ? 'text-red-800' : 'text-green-800'}>
{importResult.message}
</p>
{importResult.imported > 0 && (
<p className="text-sm text-green-600 mt-1">
Importados: {importResult.imported}, Omitidos: {importResult.skipped}, Errores: {importResult.failed}
</p>
)}
<button onClick={() => setImportResult(null)} className="text-sm text-gray-500 underline mt-2">
Cerrar
</button>
</div>
)}
<div className="card p-4 mb-6">
<div className="flex flex-wrap gap-4">
<select
value={filtroActivo}
onChange={(e) => setFiltroActivo(e.target.value)}
className="input w-auto"
>
<option value="">Todos</option>
<option value="true">Activos</option>
<option value="false">Inactivos</option>
</select>
<select
value={filtroCategoria}
onChange={(e) => setFiltroCategoria(e.target.value)}
className="input w-auto"
>
<option value="">Todas las categorías</option>
{categorias?.map((cat) => (
<option key={cat.id} value={cat.id}>{cat.nombre}</option>
))}
</select>
<select
value={filtroPais}
onChange={(e) => setFiltroPais(e.target.value)}
className="input w-auto"
>
<option value="">Todos los países</option>
{paises?.map((pais) => (
<option key={pais.id} value={pais.id}>{pais.nombre}</option>
))}
</select>
</div>
</div>
{showAddForm && (
<form onSubmit={handleSubmit} className="card p-6 mb-8">
<h2 className="text-lg font-semibold mb-4">Nuevo Feed</h2>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<input
type="text"
placeholder="Nombre *"
value={newFeed.nombre}
onChange={(e) => setNewFeed({ ...newFeed, nombre: e.target.value })}
className="input"
required
/>
<input
type="url"
placeholder="URL del feed RSS *"
value={newFeed.url}
onChange={(e) => setNewFeed({ ...newFeed, url: e.target.value })}
className="input"
required
/>
</div>
<input
type="text"
placeholder="Descripción"
value={newFeed.descripcion}
onChange={(e) => setNewFeed({ ...newFeed, descripcion: e.target.value })}
className="input"
/>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<select
value={newFeed.categoria_id}
onChange={(e) => setNewFeed({ ...newFeed, categoria_id: e.target.value })}
className="input"
>
<option value="">Categoría</option>
{categorias?.map((cat) => (
<option key={cat.id} value={cat.id}>{cat.nombre}</option>
))}
</select>
<select
value={newFeed.pais_id}
onChange={(e) => setNewFeed({ ...newFeed, pais_id: e.target.value })}
className="input"
>
<option value="">País</option>
{paises?.map((pais) => (
<option key={pais.id} value={pais.id}>{pais.nombre}</option>
))}
</select>
<input
type="text"
placeholder="Idioma (ej: es, en, fr)"
value={newFeed.idioma}
onChange={(e) => setNewFeed({ ...newFeed, idioma: e.target.value })}
className="input"
/>
</div>
<div className="flex gap-2">
<button type="submit" className="btn-primary" disabled={createFeed.isPending}>
{createFeed.isPending ? 'Añadiendo...' : 'Añadir'}
</button>
<button type="button" onClick={() => setShowAddForm(false)} className="btn-secondary">
Cancelar
</button>
</div>
</div>
</form>
)}
<div className="text-sm text-gray-500 mb-4">
Total: {feedsData?.total || 0} feeds
</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>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-900">Nombre</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-900">URL</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-900">Idioma</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-900">Categoría</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-900">País</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-900">Estado</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-900">Noticias</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-900">Errores</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-900">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{feedsData?.feeds?.map((feed) => (
<tr key={feed.id} className="hover:bg-gray-50">
<td className="px-4 py-3">
<div className="font-medium text-gray-900">{feed.nombre}</div>
{feed.descripcion && (
<div className="text-xs text-gray-500 truncate max-w-xs">{feed.descripcion}</div>
)}
</td>
<td className="px-4 py-3">
<a
href={feed.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 hover:underline flex items-center gap-1 text-sm"
>
<ExternalLink className="h-3 w-3" />
Ver
</a>
</td>
<td className="px-4 py-3 text-sm text-gray-500">{feed.idioma || '-'}</td>
<td className="px-4 py-3 text-sm text-gray-500">{feed.categoria || '-'}</td>
<td className="px-4 py-3 text-sm text-gray-500">{feed.pais || '-'}</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-1 rounded ${feed.activo ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}>
{feed.activo ? 'Activo' : 'Inactivo'}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-500">{feed.noticias_count || 0}</td>
<td className="px-4 py-3">
{feed.fallos && feed.fallos > 0 ? (
<span className="text-red-600 text-sm" title={feed.last_error || ''}>
{feed.fallos}
</span>
) : (
<span className="text-gray-400">-</span>
)}
</td>
<td className="px-4 py-3">
<div className="flex gap-2">
{feed.fallos && feed.fallos > 0 && (
<button
onClick={() => reactivateFeed.mutate(feed.id)}
className="p-1 rounded text-orange-600 hover:text-orange-700"
title={`Reactivar (${feed.fallos} errores)`}
disabled={reactivateFeed.isPending}
>
<AlertCircle className="h-4 w-4" />
</button>
)}
<button
onClick={() => toggleFeed.mutate(feed.id)}
className={`p-1 rounded ${feed.activo ? 'text-gray-400 hover:text-red-600' : 'text-green-600 hover:text-green-700'}`}
title={feed.activo ? 'Desactivar' : 'Activar'}
>
{feed.activo ? <PowerOff className="h-4 w-4" /> : <Power className="h-4 w-4" />}
</button>
<button
onClick={() => {
if (confirm('¿Eliminar este feed?')) {
deleteFeed.mutate(feed.id)
}
}}
className="p-1 text-red-600 hover:text-red-700"
title="Eliminar"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}

222
frontend/src/pages/Home.tsx Normal file
View file

@ -0,0 +1,222 @@
import { useState } 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 { Search, Globe, Newspaper, Filter } from 'lucide-react'
export function Home() {
const [searchParams, setSearchParams] = useSearchParams()
const page = parseInt(searchParams.get('page') || '1')
const q = searchParams.get('q') || ''
const categoryId = searchParams.get('category_id') || ''
const countryId = searchParams.get('country_id') || ''
const translatedOnly = searchParams.get('translated_only') === 'true'
const { data: categories } = useQuery({
queryKey: ['categories'],
queryFn: () => apiService.getCategories(),
})
const { data: countries } = useQuery({
queryKey: ['countries'],
queryFn: () => apiService.getCountries(),
})
const { data, isLoading, error } = useQuery({
queryKey: ['news', page, q, categoryId, countryId, translatedOnly],
queryFn: () => apiService.getNews({
page,
q,
category_id: categoryId || undefined,
country_id: countryId || undefined,
translated_only: translatedOnly ? 'true' : undefined,
}),
})
const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const query = formData.get('q')
setSearchParams({
q: query as string,
page: '1',
category_id: categoryId,
country_id: countryId,
translated_only: translatedOnly ? 'true' : '',
})
}
const handleFilterChange = (key: string, value: string) => {
const newParams = Object.fromEntries(searchParams)
if (value) {
newParams[key] = value
} else {
delete newParams[key]
}
newParams.page = '1'
setSearchParams(newParams)
}
const toggleTranslated = () => {
const newParams = Object.fromEntries(searchParams)
if (translatedOnly) {
delete newParams.translated_only
} else {
newParams.translated_only = 'true'
}
newParams.page = '1'
setSearchParams(newParams)
}
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-4">Noticias del Mundo</h1>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
<div className="relative md:col-span-2">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<form onSubmit={handleSearch} className="flex gap-2">
<input
name="q"
defaultValue={q}
placeholder="Buscar noticias..."
className="input pl-10 w-full"
/>
<button type="submit" className="btn-primary">
Buscar
</button>
</form>
</div>
<select
value={categoryId}
onChange={(e) => handleFilterChange('category_id', e.target.value)}
className="input"
>
<option value="">Todas las categorías</option>
{categories?.map((cat) => (
<option key={cat.id} value={cat.id}>{cat.nombre}</option>
))}
</select>
<select
value={countryId}
onChange={(e) => handleFilterChange('country_id', e.target.value)}
className="input"
>
<option value="">Todos los países</option>
{countries?.map((country) => (
<option key={country.id} value={country.id}>{country.nombre}</option>
))}
</select>
</div>
<div className="flex items-center gap-4">
<button
onClick={toggleTranslated}
className={`flex items-center gap-2 px-4 py-2 rounded-lg border transition-colors ${
translatedOnly
? 'bg-primary-600 text-white border-primary-600'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
}`}
>
<Globe className="h-4 w-4" />
Solo traducidas
</button>
{(categoryId || countryId || translatedOnly) && (
<button
onClick={() => setSearchParams({ page: '1', q })}
className="text-sm text-gray-500 hover:text-gray-700"
>
Limpiar filtros
</button>
)}
</div>
</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>
) : error ? (
<div className="text-center py-12 text-red-600">
Error al cargar noticias
</div>
) : (
<>
<div className="mb-4 text-sm text-gray-600">
{data?.total} noticias encontradas
{translatedOnly && <span className="ml-2 text-primary-600">(traducidas)</span>}
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{data?.news?.map((news) => (
<Link key={news.id} to={`/news/${news.id}`} className="card hover:shadow-md transition-shadow">
{news.imagen_url && news.imagen_url.trim() && (
<img
src={news.imagen_url}
alt={news.title_translated || news.titulo}
className="w-full h-48 object-cover rounded-t-xl"
/>
)}
<div className="p-4">
<div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
{news.fuente_nombre && (
<span className="flex items-center gap-1">
<Newspaper className="h-4 w-4" />
{news.fuente_nombre}
</span>
)}
{news.fuente_nombre && news.fecha && <span></span>}
{news.fecha && (
<span className="text-xs">
{formatDistanceToNow(new Date(news.fecha), { addSuffix: true, locale: es })}
</span>
)}
{news.title_translated && (
<span className="ml-auto text-xs bg-primary-100 text-primary-700 px-2 py-0.5 rounded">
ES
</span>
)}
</div>
<h2 className="text-lg font-semibold text-gray-900 mb-2 line-clamp-2">
{news.title_translated || news.titulo}
</h2>
<p className="text-gray-600 text-sm line-clamp-3">
{news.summary_translated || news.resumen}
</p>
</div>
</Link>
))}
</div>
{data && data.total_pages > 1 && (
<div className="flex justify-center gap-2 mt-8">
<button
onClick={() => setSearchParams({ ...Object.fromEntries(searchParams), page: String(page - 1) })}
disabled={page <= 1}
className="btn-secondary disabled:opacity-50"
>
Anterior
</button>
<span className="flex items-center px-4 text-gray-600">
{page} / {data.total_pages}
</span>
<button
onClick={() => setSearchParams({ ...Object.fromEntries(searchParams), page: String(page + 1) })}
disabled={page >= data.total_pages}
className="btn-secondary disabled:opacity-50"
>
Siguiente
</button>
</div>
)}
</>
)}
</div>
)
}

View file

@ -0,0 +1,115 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { apiService, api } from '../services/api'
export function Login() {
const navigate = useNavigate()
const [isLogin, setIsLogin] = useState(true)
const [form, setForm] = useState({ email: '', password: '', username: '' })
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState('')
const [successMessage, setSuccessMessage] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setSuccessMessage('')
if (!isLogin && form.password !== confirmPassword) {
setError('Las contraseñas no coinciden')
return
}
try {
if (isLogin) {
const { token, user } = await apiService.login(form.email, form.password)
localStorage.setItem('token', token)
localStorage.setItem('user', JSON.stringify(user))
} else {
const result = await apiService.register(form.email, form.username, form.password)
localStorage.setItem('token', result.token)
localStorage.setItem('user', JSON.stringify(result.user))
if (result.is_first_user) {
setSuccessMessage('🎉 ¡Felicidades! Has sido registrado como ADMINISTRADOR del sistema.')
setTimeout(() => navigate('/'), 3000)
return
}
}
navigate('/')
} catch (err: any) {
setError(err.response?.data?.error || 'Error al iniciar sesión')
}
}
return (
<div className="max-w-md mx-auto">
<div className="card p-8">
<h1 className="text-2xl font-bold text-center mb-2">
{isLogin ? 'Iniciar Sesión' : 'Registrarse'}
</h1>
{successMessage && (
<div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4 text-center">
{successMessage}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
{!isLogin && (
<>
<input
type="text"
placeholder="Nombre de usuario"
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
className="input"
required
/>
</>
)}
<input
type="email"
placeholder="Email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
className="input"
required
/>
<input
type="password"
placeholder="Contraseña"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
className="input"
required
/>
{!isLogin && (
<input
type="password"
placeholder="Confirmar contraseña"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="input"
required
/>
)}
{error && <p className="text-red-600 text-sm">{error}</p>}
<button type="submit" className="btn-primary w-full">
{isLogin ? 'Iniciar Sesión' : 'Registrarse'}
</button>
</form>
<p className="text-center mt-4 text-gray-600">
{isLogin ? '¿No tienes cuenta? ' : '¿Ya tienes cuenta? '}
<button onClick={() => { setIsLogin(!isLogin); setError(''); setConfirmPassword(''); }} className="text-primary-600 hover:underline">
{isLogin ? 'Regístrate' : 'Inicia sesión'}
</button>
</p>
</div>
</div>
)
}

147
frontend/src/pages/News.tsx Normal file
View file

@ -0,0 +1,147 @@
import { useParams, Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { formatDistanceToNow } from 'date-fns'
import { es } from 'date-fns/locale'
import { apiService } from '../services/api'
import { Globe, ArrowLeft, ExternalLink, Newspaper, Heart } from 'lucide-react'
import { useState, useEffect } from 'react'
import { WikiTooltip } from '../components/ui/WikiTooltip'
export function News() {
const { id } = useParams<{ id: string }>()
const [isFavorite, setIsFavorite] = useState(false)
const { data: news, isLoading, error } = useQuery({
queryKey: ['news', id],
queryFn: () => apiService.getNewsById(id!),
enabled: !!id,
})
useEffect(() => {
const stored = localStorage.getItem('favorites')
if (stored && id) {
const favorites = JSON.parse(stored)
setIsFavorite(favorites.some((n: any) => n.id === id))
}
}, [id])
const toggleFavorite = () => {
const stored = localStorage.getItem('favorites')
let favorites = stored ? JSON.parse(stored) : []
if (isFavorite) {
favorites = favorites.filter((n: any) => n.id !== id)
} else if (news) {
favorites.push(news)
}
localStorage.setItem('favorites', JSON.stringify(favorites))
setIsFavorite(!isFavorite)
}
if (isLoading) {
return (
<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>
)
}
if (error || !news) {
return (
<div className="text-center py-12">
<p className="text-red-600">Error al cargar la noticia</p>
<Link to="/" className="text-primary-600 hover:underline mt-4 inline-block">
Volver al inicio
</Link>
</div>
)
}
return (
<div>
<Link to="/" className="inline-flex items-center gap-2 text-gray-600 hover:text-primary-600 mb-6">
<ArrowLeft className="h-4 w-4" />
Volver
</Link>
<article className="card p-8">
{news.imagen_url && news.imagen_url.trim() && (
<img src={news.imagen_url} alt={news.titulo} className="w-full h-96 object-cover rounded-xl mb-8" />
)}
<div className="flex items-center gap-4 text-sm text-gray-500 mb-4">
{news.fuente_nombre && (
<span className="flex items-center gap-1">
<Newspaper className="h-4 w-4" />
{news.fuente_nombre}
</span>
)}
<span></span>
<span>
{news.fecha && formatDistanceToNow(new Date(news.fecha), { addSuffix: true, locale: es })}
</span>
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-4">
{news.title_translated || news.titulo}
</h1>
<p className="text-lg text-gray-600 mb-6">
{news.summary_translated || news.resumen}
</p>
<div className="flex gap-4">
<a
href={news.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-primary-600 hover:underline"
>
<ExternalLink className="h-4 w-4" />
View Original
</a>
<button
onClick={toggleFavorite}
className={`inline-flex items-center gap-2 ${isFavorite ? 'text-red-500' : 'text-gray-500 hover:text-red-500'}`}
>
<Heart className={`h-4 w-4 ${isFavorite ? 'fill-current' : ''}`} />
{isFavorite ? 'Saved' : 'Save'}
</button>
</div>
{news.entities && news.entities.length > 0 && (
<div className="mt-8 pt-8 border-t border-gray-100">
<h3 className="text-xl font-bold tracking-tight text-gray-900 mb-4">Entidades Mencionadas</h3>
<div className="flex flex-wrap gap-2">
{news.entities.map((ent: any) => (
<WikiTooltip
key={ent.valor + ent.tipo}
name={ent.valor}
summary={ent.wiki_summary}
imagePath={ent.image_path}
wikiUrl={ent.wiki_url}
>
<span className="inline-flex items-center gap-4 px-3 py-2 rounded-xl text-base bg-gray-50 border border-gray-200 hover:border-primary-300 hover:bg-primary-50 transition-colors cursor-pointer group-hover:border-primary-300 group-hover:bg-primary-50">
{ent.image_path && (
<img
src={ent.image_path}
alt=""
className="w-20 h-20 rounded-full object-cover border-2 border-white shadow-md"
onError={(e) => (e.currentTarget.style.display = 'none')}
/>
)}
<span className="font-semibold text-gray-800 ml-1">{ent.valor}</span>
<span className="text-[10px] text-gray-500 font-medium uppercase tracking-wider bg-white px-1.5 py-0.5 rounded-md border border-gray-200">
{ent.tipo}
</span>
</span>
</WikiTooltip>
))}
</div>
</div>
)}
</article>
</div>
)
}

View file

@ -0,0 +1,524 @@
import { useState, useEffect, useRef } from 'react'
import { api } from '../services/api'
import { WikiTooltip } from '../components/ui/WikiTooltip'
interface Entity {
valor: string
tipo: string
count: number
wiki_summary?: string
wiki_url?: string
image_path?: string
}
interface Category {
id: number
nombre: string
}
interface Country {
id: number
nombre: string
}
interface ConfigModal {
entity: Entity
}
const TIPOS = [
{ value: 'persona', label: '👤 Persona', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' },
{ value: 'organizacion', label: '🏢 Organización', color: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200' },
{ value: 'lugar', label: '📍 Lugar', color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' },
{ value: 'tema', label: '📰 Tema', color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' },
]
function getTipoInfo(tipo: string) {
return TIPOS.find(t => t.value === tipo) || TIPOS[3]
}
export function Populares() {
const [entities, setEntities] = useState<Entity[]>([])
const [categories, setCategories] = useState<Category[]>([])
const [countries, setCountries] = useState<Country[]>([])
const [tipo, setTipo] = useState('persona')
const [countryId, setCountryId] = useState('')
const [categoryId, setCategoryId] = useState('')
const [searchQuery, setSearchQuery] = useState('')
const [activeSearch, setActiveSearch] = useState('')
const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [loading, setLoading] = useState(false)
const [configModal, setConfigModal] = useState<ConfigModal | null>(null)
const [saving, setSaving] = useState(false)
const [successMsg, setSuccessMsg] = useState('')
// Form state for configure modal
const [newTipo, setNewTipo] = useState('')
const [aliasInput, setAliasInput] = useState('')
const [canonicalInput, setCanonicalInput] = useState('')
const [activeTab, setActiveTab] = useState<'tipo' | 'alias'>('tipo')
const fileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
api.get('/categories').then(res => setCategories(res.data)).catch(console.error)
api.get('/countries').then(res => setCountries(res.data)).catch(console.error)
}, [])
useEffect(() => {
setPage(1)
}, [tipo, countryId, categoryId, activeSearch])
useEffect(() => {
fetchEntities()
}, [tipo, countryId, categoryId, activeSearch, page])
const fetchEntities = async () => {
setLoading(true)
try {
const params = new URLSearchParams()
params.append('tipo', tipo)
if (countryId) params.append('country_id', countryId)
if (categoryId) params.append('category_id', categoryId)
if (activeSearch) params.append('q', activeSearch)
params.append('page', page.toString())
params.append('per_page', '50')
const res = await api.get(`/entities?${params}`)
setEntities(res.data.entities || [])
setTotalPages(res.data.total_pages || 1)
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}
const openConfig = (entity: Entity) => {
setConfigModal({ entity })
setNewTipo(entity.tipo)
setAliasInput(entity.valor)
setCanonicalInput(entity.valor)
setActiveTab('tipo')
setSuccessMsg('')
}
const closeConfig = () => {
setConfigModal(null)
setSaving(false)
setSuccessMsg('')
}
const handleSaveTipo = async () => {
if (!configModal || newTipo === configModal.entity.tipo) return
setSaving(true)
try {
await api.post('/admin/entities/retype', {
valor: configModal.entity.valor,
new_tipo: newTipo,
})
setSuccessMsg(`✅ Tipo cambiado a "${getTipoInfo(newTipo).label}"`)
fetchEntities()
} catch (err: any) {
setSuccessMsg(`❌ Error: ${err?.response?.data?.error || 'Error desconocido'}`)
} finally {
setSaving(false)
}
}
const handleSaveAlias = async () => {
if (!canonicalInput.trim() || !aliasInput.trim()) return
setSaving(true)
// Split input by newlines or commas, trim spaces, and remove empty entries
const aliasesList = aliasInput
.split(/[\n,]+/)
.map(a => a.trim())
.filter(a => a.length > 0)
if (aliasesList.length === 0) {
setSaving(false)
return
}
try {
await api.post('/admin/aliases', {
aliases: aliasesList,
canonical_name: canonicalInput.trim(),
tipo: configModal?.entity.tipo || newTipo,
})
setSuccessMsg(`${aliasesList.length} alias creados y fusionados en "${canonicalInput}"`)
setAliasInput('')
fetchEntities() // Refresh popular entity metrics
} catch (err: any) {
setSuccessMsg(`❌ Error: ${err?.response?.data?.error || 'Ya existe este alias'}`)
} finally {
setSaving(false)
}
}
const handleExportAliases = async () => {
try {
const res = await api.get('/admin/aliases/export', { responseType: 'blob' })
const url = window.URL.createObjectURL(new Blob([res.data]))
const link = document.createElement('a')
link.href = url
link.setAttribute('download', 'aliases.csv')
document.body.appendChild(link)
link.click()
link.remove()
} catch (err) { console.error(err) }
}
const handleImportAliases = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const formData = new FormData()
formData.append('file', file)
try {
const res = await api.post('/admin/aliases/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
alert(`✅ Importados: ${res.data.inserted}, Omitidos: ${res.data.skipped}`)
e.target.value = ''
} catch { alert('❌ Error al importar CSV') }
}
const handleBackupDB = async () => {
try {
const res = await api.get('/admin/backup', { responseType: 'blob' })
const url = window.URL.createObjectURL(new Blob([res.data]))
const link = document.createElement('a')
link.href = url
const now = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-')
link.setAttribute('download', `backup_${now}.sql`)
document.body.appendChild(link)
link.click()
link.remove()
} catch (err: any) {
alert('❌ Error al generar backup. Verifica que pg_dump esté disponible.')
}
}
const selectedCountry = countries.find(c => c.id.toString() === countryId)
return (
<div className="p-6">
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6 gap-3">
<h1 className="text-2xl font-bold">
🔥 Populares {selectedCountry ? `en ${selectedCountry.nombre}` : 'Global'}
</h1>
<div className="flex flex-wrap gap-2">
<button
onClick={handleExportAliases}
className="px-3 py-1.5 text-sm border rounded hover:bg-gray-50 dark:hover:bg-gray-700 dark:border-gray-600"
>
📤 Exportar aliases
</button>
<label className="px-3 py-1.5 text-sm border rounded hover:bg-gray-50 dark:hover:bg-gray-700 dark:border-gray-600 cursor-pointer">
📥 Importar aliases
<input
ref={fileInputRef}
type="file"
accept=".csv"
onChange={handleImportAliases}
className="hidden"
/>
</label>
<button
onClick={handleBackupDB}
className="px-3 py-1.5 text-sm bg-amber-500 hover:bg-amber-600 text-white rounded font-medium"
>
💾 Backup BBDD
</button>
</div>
</div>
{/* Filters */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Tipo</label>
<select
value={tipo}
onChange={(e) => setTipo(e.target.value)}
className="w-full p-2 border rounded dark:bg-gray-700 dark:border-gray-600"
>
{TIPOS.map(t => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">País</label>
<select
value={countryId}
onChange={(e) => setCountryId(e.target.value)}
className="w-full p-2 border rounded dark:bg-gray-700 dark:border-gray-600"
>
<option value="">🌍 Global</option>
{countries.map(c => (
<option key={c.id} value={c.id}>{c.nombre}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Categoría</label>
<select
value={categoryId}
onChange={(e) => setCategoryId(e.target.value)}
className="w-full p-2 border rounded dark:bg-gray-700 dark:border-gray-600"
>
<option value="">Todas las categorías</option>
{categories.map(c => (
<option key={c.id} value={c.id}>{c.nombre}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Buscar</label>
<div className="flex gap-2">
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onKeyDown={e => e.key === 'Enter' && setActiveSearch(searchQuery)}
placeholder="Término o alias..."
className="w-full p-2 border rounded dark:bg-gray-700 dark:border-gray-600"
/>
<button
onClick={() => setActiveSearch(searchQuery)}
className="px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
🔍
</button>
</div>
</div>
</div>
</div>
{/* Table */}
{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">
<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>
</WikiTooltip>
</td>
<td className="px-4 py-3 hidden md:table-cell">
<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">
<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>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
{/* Pagination Controls */}
{!loading && entities.length > 0 && totalPages > 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">{totalPages}</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(totalPages, p + 1))}
disabled={page >= totalPages}
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>
)}
{/* Configure Modal */}
{configModal && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4" onClick={closeConfig}>
<div
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-md"
onClick={e => e.stopPropagation()}
>
{/* Modal Header */}
<div className="flex items-center justify-between px-6 py-4 border-b dark:border-gray-700">
<div>
<h2 className="text-lg font-bold">Configurar entidad</h2>
<p className="text-sm text-gray-500 mt-0.5">"{configModal.entity.valor}"</p>
</div>
<button
onClick={closeConfig}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-xl leading-none"
>
</button>
</div>
{/* Tabs */}
<div className="flex border-b dark:border-gray-700">
<button
onClick={() => { setActiveTab('tipo'); setSuccessMsg('') }}
className={`flex-1 py-3 text-sm font-medium transition-colors ${
activeTab === 'tipo'
? 'border-b-2 border-blue-500 text-blue-600'
: 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
}`}
>
🔄 Cambiar tipo
</button>
<button
onClick={() => { setActiveTab('alias'); setSuccessMsg('') }}
className={`flex-1 py-3 text-sm font-medium transition-colors ${
activeTab === 'alias'
? 'border-b-2 border-blue-500 text-blue-600'
: 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
}`}
>
🔁 Crear alias
</button>
</div>
{/* Tab Content */}
<div className="p-6">
{activeTab === 'tipo' && (
<div>
<p className="text-sm text-gray-500 mb-4">
Mover "<strong>{configModal.entity.valor}</strong>" a otra categoría. Afecta a todos los tags con este valor en la base de datos.
</p>
<div className="grid grid-cols-2 gap-2 mb-4">
{TIPOS.map(t => (
<button
key={t.value}
onClick={() => setNewTipo(t.value)}
className={`px-3 py-2.5 rounded-lg border-2 text-sm font-medium transition-all ${
newTipo === t.value
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300'
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
}`}
>
{t.label}
{t.value === configModal.entity.tipo && (
<span className="ml-1 text-xs text-gray-400">(actual)</span>
)}
</button>
))}
</div>
{successMsg && (
<p className="text-sm mb-3 py-2 px-3 rounded bg-gray-50 dark:bg-gray-700">{successMsg}</p>
)}
<button
onClick={handleSaveTipo}
disabled={saving || newTipo === configModal.entity.tipo}
className="w-full py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg font-medium text-sm transition-colors"
>
{saving ? 'Guardando...' : 'Cambiar tipo'}
</button>
</div>
)}
{activeTab === 'alias' && (
<div>
<p className="text-sm text-gray-500 mb-4">
Crea un alias para normalizar variantes del mismo nombre. El alias se mapeará al nombre canónico que definas.
</p>
<div className="mb-3">
<label className="block text-sm font-medium mb-1">
Aliases <span className="text-gray-400 font-normal">(separados por comas o saltos de línea)</span>
</label>
<textarea
value={aliasInput}
onChange={e => setAliasInput(e.target.value)}
className="w-full p-2 border rounded dark:bg-gray-700 dark:border-gray-600 text-sm min-h-[80px] resize-y"
placeholder="ej: donald trump, el presidente, dictador&#10;trump"
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">
Nombre canónico <span className="text-gray-400 font-normal">(nombre correcto y normalizado)</span>
</label>
<input
type="text"
value={canonicalInput}
onChange={e => setCanonicalInput(e.target.value)}
className="w-full p-2 border rounded dark:bg-gray-700 dark:border-gray-600 text-sm"
placeholder="ej: Donald Trump"
/>
</div>
{successMsg && (
<p className="text-sm mb-3 py-2 px-3 rounded bg-gray-50 dark:bg-gray-700">{successMsg}</p>
)}
<button
onClick={handleSaveAlias}
disabled={saving || !aliasInput.trim() || !canonicalInput.trim()}
className="w-full py-2 bg-green-600 hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg font-medium text-sm transition-colors"
>
{saving ? 'Creando...' : 'Crear alias'}
</button>
</div>
)}
</div>
</div>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,148 @@
import { useState } from 'react'
import { useSearchParams, Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { apiService, News, Category, Country } from '../services/api'
import { Search as SearchIcon, Filter } from 'lucide-react'
export function Search() {
const [searchParams, setSearchParams] = useSearchParams()
const q = searchParams.get('q') || ''
const lang = searchParams.get('lang') || ''
const categoria = searchParams.get('categoria') || ''
const pais = searchParams.get('pais') || ''
const { data: categorias } = useQuery({
queryKey: ['categories'],
queryFn: apiService.getCategories,
})
const { data: paises } = useQuery({
queryKey: ['countries'],
queryFn: apiService.getCountries,
})
const { data, isLoading } = useQuery({
queryKey: ['search', q, lang, categoria, pais],
queryFn: () => apiService.search({
q,
lang: lang || undefined,
}),
enabled: !!q || !!lang,
})
const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {
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('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
setSearchParams(newParams)
}
const clearFilters = () => {
const q = searchParams.get('q') || ''
setSearchParams(q ? { q } : {})
}
return (
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-8">Buscar Noticias</h1>
<form onSubmit={handleSearch} className="card p-4 mb-6">
<div className="flex flex-wrap gap-4 items-end">
<div className="flex-1 min-w-[200px]">
<label className="block text-sm font-medium text-gray-700 mb-1">Buscar</label>
<input
name="q"
defaultValue={q}
placeholder="Palabras clave..."
className="input w-full"
/>
</div>
<div className="w-32">
<label className="block text-sm font-medium text-gray-700 mb-1">Idioma</label>
<select name="lang" defaultValue={lang} className="input w-full">
<option value="">Todos</option>
<option value="es">Español</option>
<option value="en">Inglés</option>
<option value="fr">Francés</option>
<option value="pt">Portugués</option>
<option value="de">Alemán</option>
<option value="it">Italiano</option>
<option value="ru">Ruso</option>
<option value="zh">Chino</option>
<option value="ja">Japonés</option>
<option value="ar">Árabe</option>
</select>
</div>
<div className="w-40">
<label className="block text-sm font-medium text-gray-700 mb-1">Categoría</label>
<select name="categoria" defaultValue={categoria} className="input w-full">
<option value="">Todas</option>
{categorias?.map((cat) => (
<option key={cat.id} value={cat.id}>{cat.nombre}</option>
))}
</select>
</div>
<div className="w-40">
<label className="block text-sm font-medium text-gray-700 mb-1">País</label>
<select name="pais" defaultValue={pais} className="input w-full">
<option value="">Todos</option>
{paises?.map((p) => (
<option key={p.id} value={p.id}>{p.nombre}</option>
))}
</select>
</div>
<button type="submit" className="btn-primary">
<SearchIcon className="h-5 w-5" />
</button>
{(categoria || pais || lang) && (
<button type="button" onClick={clearFilters} className="btn-secondary">
Limpiar
</button>
)}
</div>
</form>
{data && data.total > 0 && (
<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 && (
<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">
<h3 className="font-semibold text-gray-900">{news.title_translated || news.titulo}</h3>
<p className="text-gray-600 text-sm mt-1">{news.summary_translated || news.resumen}</p>
<div className="flex gap-4 mt-2 text-xs text-gray-500">
<span>{news.lang_translated || news.fuente_nombre}</span>
<span>{new Date(news.fecha).toLocaleDateString()}</span>
</div>
</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>
)}
</div>
)
}

View file

@ -0,0 +1,49 @@
import { useQuery } from '@tanstack/react-query'
import { apiService } from '../services/api'
import { Newspaper, Rss, TrendingUp, Globe } from 'lucide-react'
export function Stats() {
const { data, isLoading } = useQuery({
queryKey: ['stats'],
queryFn: apiService.getStats,
})
if (isLoading) {
return (
<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>
)
}
const stats = [
{ label: 'Total Noticias', value: data?.total_news || 0, icon: Newspaper, color: 'text-blue-600' },
{ label: 'Total Feeds', value: data?.total_feeds || 0, icon: Rss, color: 'text-green-600' },
{ label: 'Traducidas', value: data?.total_translated || 0, icon: Globe, color: 'text-indigo-600' },
{ label: 'Noticias Hoy', value: data?.news_today || 0, icon: TrendingUp, color: 'text-orange-600' },
{ label: 'Esta Semana', value: data?.news_this_week || 0, icon: TrendingUp, color: 'text-purple-600' },
{ label: 'Este Mes', value: data?.news_this_month || 0, icon: TrendingUp, color: 'text-red-600' },
]
return (
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-8">Estadísticas</h1>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{stats.map((stat) => (
<div key={stat.label} className="card p-6">
<div className="flex items-center gap-4">
<div className={`p-3 rounded-lg bg-gray-100 ${stat.color}`}>
<stat.icon className="h-6 w-6" />
</div>
<div>
<p className="text-sm text-gray-500">{stat.label}</p>
<p className="text-2xl font-bold text-gray-900">{stat.value.toLocaleString()}</p>
</div>
</div>
</div>
))}
</div>
</div>
)
}

View file

@ -0,0 +1,352 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { apiService, api } from '../services/api'
import { User, Mail, Lock, Cpu, ChevronRight, ChevronLeft, Check, Sparkles, AlertCircle } from 'lucide-react'
interface WelcomeWizardProps {
onComplete: () => void
}
export function WelcomeWizard({ onComplete }: WelcomeWizardProps) {
const navigate = useNavigate()
const [step, setStep] = useState(1)
const [form, setForm] = useState({
username: '',
email: '',
password: '',
confirmPassword: '',
preferCpu: true,
workerCount: 2
})
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [startingWorkers, setStartingWorkers] = useState(false)
const totalSteps = 3
const handleNext = () => {
setError('')
if (step === 1) {
if (!form.username.trim() || !form.email.trim()) {
setError('Por favor completa todos los campos')
return
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
setError('Por favor ingresa un correo válido')
return
}
}
if (step === 2) {
if (!form.password || form.password.length < 6) {
setError('La contraseña debe tener al menos 6 caracteres')
return
}
if (form.password !== form.confirmPassword) {
setError('Las contraseñas no coinciden')
return
}
}
if (step < totalSteps) {
setStep(step + 1)
}
}
const handleBack = () => {
setError('')
if (step > 1) {
setStep(step - 1)
}
}
const handleSubmit = async () => {
setError('')
setLoading(true)
setStartingWorkers(true)
try {
// 1. Registrar usuario
const result = await apiService.register(form.email, form.username, form.password)
localStorage.setItem('token', result.token)
localStorage.setItem('user', JSON.stringify(result.user))
// 2. Configurar workers
await api.post('/admin/workers/config', {
type: form.preferCpu ? 'cpu' : 'gpu',
workers: form.workerCount
})
// 3. Iniciar workers automáticamente
try {
await api.post('/admin/workers/start', {
type: form.preferCpu ? 'cpu' : 'gpu',
workers: form.workerCount
})
} catch (workerErr) {
console.warn('No se pudieron iniciar los workers:', workerErr)
}
onComplete()
} catch (err: any) {
setError(err.response?.data?.error || 'Error al crear la cuenta')
setLoading(false)
setStartingWorkers(false)
}
}
const renderStep = () => {
switch (step) {
case 1:
return (
<div className="space-y-4">
<div className="text-center mb-6">
<div className="w-16 h-16 bg-gradient-to-br from-primary-500 to-primary-700 rounded-full flex items-center justify-center mx-auto mb-4">
<User className="h-8 w-8 text-white" />
</div>
<h2 className="text-xl font-bold">Bienvenido a RSS2</h2>
<p className="text-gray-500 text-sm mt-1">Cuéntanos sobre ti</p>
</div>
<div>
<label className="block text-sm font-medium mb-1">Nombre de usuario</label>
<input
type="text"
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
className="input w-full"
placeholder="Tu nombre"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Correo electrónico</label>
<input
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
className="input w-full"
placeholder="tu@email.com"
/>
</div>
</div>
)
case 2:
return (
<div className="space-y-4">
<div className="text-center mb-6">
<div className="w-16 h-16 bg-gradient-to-br from-purple-500 to-purple-700 rounded-full flex items-center justify-center mx-auto mb-4">
<Lock className="h-8 w-8 text-white" />
</div>
<h2 className="text-xl font-bold">Protege tu cuenta</h2>
<p className="text-gray-500 text-sm mt-1">Crea una contraseña segura</p>
</div>
<div>
<label className="block text-sm font-medium mb-1">Contraseña</label>
<input
type="password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
className="input w-full"
placeholder="Mínimo 6 caracteres"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Confirmar contraseña</label>
<input
type="password"
value={form.confirmPassword}
onChange={(e) => setForm({ ...form, confirmPassword: e.target.value })}
className="input w-full"
placeholder="Repite tu contraseña"
/>
</div>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3 flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-yellow-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-yellow-800 dark:text-yellow-200">
Como primer usuario, tu cuenta tendrá <strong>derechos de administrador</strong>
</p>
</div>
</div>
)
case 3:
return (
<div className="space-y-4">
<div className="text-center mb-6">
<div className="w-16 h-16 bg-gradient-to-br from-green-500 to-green-700 rounded-full flex items-center justify-center mx-auto mb-4">
<Cpu className="h-8 w-8 text-white" />
</div>
<h2 className="text-xl font-bold">Configuración de traducción</h2>
<p className="text-gray-500 text-sm mt-1">¿Qué prefieres para procesar las traducciones?</p>
</div>
<div className="grid grid-cols-2 gap-4">
<button
type="button"
onClick={() => setForm({ ...form, preferCpu: true })}
className={`p-4 rounded-lg border-2 transition-all text-left ${
form.preferCpu
? 'border-green-500 bg-green-50 dark:bg-green-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-green-300'
}`}
>
<Cpu className="h-8 w-8 text-green-600 mb-2" />
<h3 className="font-semibold">CPU</h3>
<p className="text-xs text-gray-500 mt-1">
Más lento pero funciona en cualquier equipo
</p>
</button>
<button
type="button"
onClick={() => setForm({ ...form, preferCpu: false })}
className={`p-4 rounded-lg border-2 transition-all text-left ${
!form.preferCpu
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-blue-300'
}`}
>
<svg className="h-8 w-8 text-blue-600 mb-2" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
</svg>
<h3 className="font-semibold">GPU</h3>
<p className="text-xs text-gray-500 mt-1">
Mucho más rápido, necesita tarjeta NVIDIA
</p>
</button>
</div>
<div className="mt-6">
<label className="block text-sm font-medium mb-2 text-center">
Número de workers de traducción
</label>
<div className="flex flex-wrap justify-center gap-2">
{[1, 2, 3, 4, 5, 6, 7, 8].map((num) => (
<button
key={num}
type="button"
onClick={() => setForm({ ...form, workerCount: num })}
className={`w-10 h-10 rounded-lg font-medium transition-all ${
form.workerCount === num
? 'bg-primary-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
{num}
</button>
))}
</div>
<p className="text-xs text-gray-500 text-center mt-2">
Recomendado: 2-4 workers para CPU, 1-2 para GPU
</p>
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3 mt-4">
<p className="text-sm text-blue-800 dark:text-blue-200">
<strong>Resumen:</strong> {form.preferCpu ? 'CPU' : 'GPU'} con {form.workerCount} worker{form.workerCount > 1 ? 's' : ''}
</p>
</div>
</div>
)
default:
return null
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 via-white to-purple-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900 flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-primary-500 to-purple-600 rounded-2xl shadow-lg mb-4">
<Sparkles className="h-8 w-8 text-white" />
</div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">RSS2</h1>
<p className="text-gray-500 dark:text-gray-400">Configuración inicial</p>
</div>
<div className="card p-6">
<div className="flex items-center justify-between mb-6">
{[1, 2, 3].map((s) => (
<div key={s} className="flex items-center">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-colors ${
step >= s
? 'bg-primary-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-500'
}`}>
{step > s ? <Check className="h-5 w-5" /> : s}
</div>
{s < 3 && (
<div className={`w-12 h-1 mx-1 rounded ${
step > s ? 'bg-primary-600' : 'bg-gray-200 dark:bg-gray-700'
}`} />
)}
</div>
))}
</div>
{renderStep()}
{error && (
<div className="mt-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
<div className="flex gap-3 mt-6">
{step > 1 && (
<button
type="button"
onClick={handleBack}
className="btn-secondary flex items-center justify-center gap-2"
>
<ChevronLeft className="h-4 w-4" />
Atrás
</button>
)}
{step < totalSteps ? (
<button
type="button"
onClick={handleNext}
className="btn-primary flex-1 flex items-center justify-center gap-2"
>
Siguiente
<ChevronRight className="h-4 w-4" />
</button>
) : (
<button
type="button"
onClick={handleSubmit}
disabled={loading}
className="btn-primary flex-1 flex items-center justify-center gap-2"
>
{loading || startingWorkers ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent" />
Configurando...
</>
) : (
<>
<Check className="h-4 w-4" />
Completar y iniciar
</>
)}
</button>
)}
</div>
</div>
<p className="text-center text-xs text-gray-400 mt-4">
Paso {step} de {totalSteps}
</p>
</div>
</div>
)
}

View file

@ -0,0 +1,195 @@
import axios from 'axios'
const API_BASE = import.meta.env.VITE_API_URL || '/api'
export const api = axios.create({
baseURL: API_BASE,
headers: {
'Content-Type': 'application/json',
},
})
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
export interface News {
id: string
titulo: string
resumen: string
url: string
fecha: string
imagen_url?: string
categoria_id?: number
pais_id?: number
fuente_nombre: string
title_translated?: string
summary_translated?: string
lang_translated?: string
entities?: any[]
}
export interface NewsListResponse {
news: News[]
total: number
page: number
per_page: number
total_pages: number
}
export interface Feed {
id: number
nombre: string
descripcion?: string
url: string
categoria_id?: number
pais_id?: number
idioma?: string
activo: boolean
fallos?: number
last_error?: string
categoria?: string
pais?: string
noticias_count?: number
}
export interface FeedListResponse {
feeds: Feed[]
total: number
page: number
per_page: number
total_pages: number
}
export interface Category {
id: number
nombre: string
}
export interface Country {
id: number
nombre: string
continente: string
}
export interface Stats {
total_news: number
total_feeds: number
total_users: number
total_translated: number
news_today: number
news_this_week: number
news_this_month: number
}
export const apiService = {
getNews: async (params: { page?: number; per_page?: number; q?: string; category_id?: string; country_id?: string; translated_only?: string }): Promise<NewsListResponse> => {
const { data } = await api.get('/news', { params })
return data
},
getNewsById: async (id: string): Promise<News> => {
const { data } = await api.get(`/news/${id}`)
return data
},
getFeeds: async (params?: { page?: number; per_page?: number; activo?: string; categoria_id?: string; pais_id?: string }): Promise<FeedListResponse> => {
const { data } = await api.get('/feeds', { params })
return data
},
createFeed: async (feed: { nombre: string; url: string; descripcion?: string; categoria_id?: number; pais_id?: number; idioma?: string }): Promise<{ id: number }> => {
const { data } = await api.post('/feeds', feed)
return data
},
updateFeed: async (id: number, feed: { nombre: string; url: string; descripcion?: string; categoria_id?: number; pais_id?: number; idioma?: string; activo?: boolean }): Promise<void> => {
const { data } = await api.put(`/feeds/${id}`, feed)
return data
},
deleteFeed: async (id: number): Promise<void> => {
await api.delete(`/feeds/${id}`)
},
toggleFeed: async (id: number): Promise<void> => {
await api.post(`/feeds/${id}/toggle`)
},
reactivateFeed: async (id: number): Promise<void> => {
await api.post(`/feeds/${id}/reactivate`)
},
exportFeeds: async (params?: { activo?: string; categoria_id?: string; pais_id?: string }): Promise<Blob> => {
const { data } = await api.get('/feeds/export', { params, responseType: 'blob' })
return data
},
importFeeds: async (file: File): Promise<{ imported: number; skipped: number; failed: number; message: string }> => {
const formData = new FormData()
formData.append('file', file)
const { data } = await api.post('/feeds/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return data
},
search: async (params: { q: string; page?: number; lang?: string }): Promise<NewsListResponse> => {
const { data } = await api.get('/search', { params })
return data
},
getStats: async (): Promise<Stats> => {
const { data } = await api.get('/stats')
return data
},
getCategories: async (): Promise<Category[]> => {
const { data } = await api.get('/categories')
return data
},
getCountries: async (): Promise<Country[]> => {
const { data } = await api.get('/countries')
return data
},
login: async (email: string, password: string): Promise<{ token: string; user: any }> => {
const { data } = await api.post('/auth/login', { email, password })
return data
},
register: async (email: string, username: string, password: string): Promise<{ token: string; user: any; is_first_user?: boolean }> => {
const { data } = await api.post('/auth/register', { email, username, password })
return data
},
resetDatabase: async (): Promise<{ message: string; tables_cleared: string[] }> => {
const { data } = await api.post('/admin/reset-db')
return data
},
backupNewsZipped: async () => {
try {
const response = await api.get('/admin/backup/news', {
responseType: 'blob',
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `backup_noticias_${new Date().toISOString().split('T')[0]}.zip`);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('Failed to download backup:', error);
alert('Error al descargar la copia de seguridad. Verifica tu conexión o permisos.');
}
},
}

9
frontend/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}