go integration and wikipedia
This commit is contained in:
parent
47a252e339
commit
ee90335b92
7828 changed files with 1307913 additions and 20807 deletions
76
frontend/src/App.tsx
Normal file
76
frontend/src/App.tsx
Normal 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
|
||||
157
frontend/src/components/layout/Layout.tsx
Normal file
157
frontend/src/components/layout/Layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
63
frontend/src/components/ui/WikiTooltip.tsx
Normal file
63
frontend/src/components/ui/WikiTooltip.tsx
Normal 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
38
frontend/src/index.css
Normal 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
25
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
156
frontend/src/pages/Account.tsx
Normal file
156
frontend/src/pages/Account.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
255
frontend/src/pages/AdminAliases.tsx
Normal file
255
frontend/src/pages/AdminAliases.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
129
frontend/src/pages/AdminSettings.tsx
Normal file
129
frontend/src/pages/AdminSettings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
150
frontend/src/pages/AdminUsers.tsx
Normal file
150
frontend/src/pages/AdminUsers.tsx
Normal 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">Tú</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>
|
||||
)
|
||||
}
|
||||
261
frontend/src/pages/AdminWorkers.tsx
Normal file
261
frontend/src/pages/AdminWorkers.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
108
frontend/src/pages/Favorites.tsx
Normal file
108
frontend/src/pages/Favorites.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
370
frontend/src/pages/Feeds.tsx
Normal file
370
frontend/src/pages/Feeds.tsx
Normal 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
222
frontend/src/pages/Home.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
115
frontend/src/pages/Login.tsx
Normal file
115
frontend/src/pages/Login.tsx
Normal 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
147
frontend/src/pages/News.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
524
frontend/src/pages/Populares.tsx
Normal file
524
frontend/src/pages/Populares.tsx
Normal 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 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>
|
||||
)
|
||||
}
|
||||
148
frontend/src/pages/Search.tsx
Normal file
148
frontend/src/pages/Search.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
49
frontend/src/pages/Stats.tsx
Normal file
49
frontend/src/pages/Stats.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
352
frontend/src/pages/WelcomeWizard.tsx
Normal file
352
frontend/src/pages/WelcomeWizard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
195
frontend/src/services/api.ts
Normal file
195
frontend/src/services/api.ts
Normal 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
9
frontend/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue