transform admin panel with comprehensive professional UI
- migrate from SQLite to PostgreSQL with Drizzle ORM - implement comprehensive AdminLayout with expandable sidebar navigation - create professional dashboard with real-time charts and metrics - add advanced monitoring, reporting, and export functionality - fix menu alignment and remove non-existent pages - eliminate duplicate headers and improve UI consistency - add Tailwind CSS v3 for professional styling - expand database schema from 6 to 15 tables - implement role-based access control and API key management - create comprehensive settings, monitoring, and system info pages
This commit is contained in:
@@ -1,7 +1,16 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import type { NextPage } from 'next'
|
||||
import Head from 'next/head'
|
||||
import Link from 'next/link'
|
||||
import AdminLayout from '../../components/AdminLayout'
|
||||
import {
|
||||
ShieldExclamationIcon,
|
||||
FunnelIcon,
|
||||
MagnifyingGlassIcon,
|
||||
CheckCircleIcon,
|
||||
XCircleIcon,
|
||||
EyeIcon,
|
||||
ExclamationTriangleIcon
|
||||
} from '@heroicons/react/24/outline'
|
||||
|
||||
interface Source {
|
||||
id: number
|
||||
@@ -18,10 +27,11 @@ const SourcesManagement: NextPage = () => {
|
||||
const [sources, setSources] = useState<Source[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [filter, setFilter] = useState('pending')
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
|
||||
const fetchSources = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/sources?status=${filter}`)
|
||||
const response = await fetch(`/api/admin/sources?status=${filter}&search=${searchTerm}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setSources(data)
|
||||
@@ -31,7 +41,7 @@ const SourcesManagement: NextPage = () => {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [filter])
|
||||
}, [filter, searchTerm])
|
||||
|
||||
useEffect(() => {
|
||||
fetchSources()
|
||||
@@ -51,126 +61,241 @@ const SourcesManagement: NextPage = () => {
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
fetchSources() // Refresh the list
|
||||
fetchSources()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update source:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const getRiskColor = (level: number) => {
|
||||
if (level >= 4) return '#ef4444'
|
||||
if (level >= 3) return '#f59e0b'
|
||||
return '#6b7280'
|
||||
const getRiskBadge = (level: number) => {
|
||||
if (level >= 4) return 'bg-red-100 text-red-800'
|
||||
if (level >= 3) return 'bg-yellow-100 text-yellow-800'
|
||||
if (level >= 2) return 'bg-orange-100 text-orange-800'
|
||||
return 'bg-green-100 text-green-800'
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'verified':
|
||||
return 'bg-green-100 text-green-800'
|
||||
case 'rejected':
|
||||
return 'bg-red-100 text-red-800'
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'verified':
|
||||
return <CheckCircleIcon className="h-4 w-4" />
|
||||
case 'rejected':
|
||||
return <XCircleIcon className="h-4 w-4" />
|
||||
case 'pending':
|
||||
return <ExclamationTriangleIcon className="h-4 w-4" />
|
||||
default:
|
||||
return <EyeIcon className="h-4 w-4" />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminLayout title="Správa zdrojov">
|
||||
<Head>
|
||||
<title>Správa zdrojov - Infohliadka</title>
|
||||
<title>Správa zdrojov - Hliadka.sk Admin</title>
|
||||
</Head>
|
||||
|
||||
<div style={{ padding: '20px', fontFamily: 'Arial, sans-serif' }}>
|
||||
<h1>Správa zdrojov</h1>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label>Filter: </label>
|
||||
<select
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
style={{ padding: '8px', marginLeft: '10px' }}
|
||||
>
|
||||
<option value="pending">Čakajúce</option>
|
||||
<option value="verified">Schválené</option>
|
||||
<option value="rejected">Zamietnuté</option>
|
||||
</select>
|
||||
<div className="space-y-6">
|
||||
{/* Filters and Search */}
|
||||
<div className="card p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<FunnelIcon className="h-5 w-5 text-gray-400" />
|
||||
<label className="text-sm font-medium text-gray-700">Filter:</label>
|
||||
<select
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="input min-w-0"
|
||||
>
|
||||
<option value="">Všetky</option>
|
||||
<option value="pending">Čakajúce</option>
|
||||
<option value="verified">Schválené</option>
|
||||
<option value="rejected">Zamietnuté</option>
|
||||
<option value="under_review">Na kontrole</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<MagnifyingGlassIcon className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Hľadať domény..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="input pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div>Loading...</div>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ backgroundColor: '#f3f4f6' }}>
|
||||
<th style={{ padding: '12px', textAlign: 'left', border: '1px solid #d1d5db' }}>Doména</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', border: '1px solid #d1d5db' }}>Typ</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', border: '1px solid #d1d5db' }}>Riziko</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', border: '1px solid #d1d5db' }}>Status</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', border: '1px solid #d1d5db' }}>Dátum</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', border: '1px solid #d1d5db' }}>Akcie</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sources.map((source) => (
|
||||
<tr key={source.id}>
|
||||
<td style={{ padding: '12px', border: '1px solid #d1d5db' }}>
|
||||
<a href={source.url} target="_blank" rel="noopener noreferrer">
|
||||
{source.domain}
|
||||
</a>
|
||||
</td>
|
||||
<td style={{ padding: '12px', border: '1px solid #d1d5db' }}>{source.type}</td>
|
||||
<td style={{ padding: '12px', border: '1px solid #d1d5db' }}>
|
||||
<span style={{ color: getRiskColor(source.risk_level), fontWeight: 'bold' }}>
|
||||
{source.risk_level}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px', border: '1px solid #d1d5db' }}>{source.status}</td>
|
||||
<td style={{ padding: '12px', border: '1px solid #d1d5db' }}>
|
||||
{new Date(source.created_at).toLocaleDateString('sk-SK')}
|
||||
</td>
|
||||
<td style={{ padding: '12px', border: '1px solid #d1d5db' }}>
|
||||
{source.status === 'pending' && (
|
||||
<div style={{ display: 'flex', gap: '5px' }}>
|
||||
<button
|
||||
onClick={() => updateSource(source.id, 'verified', source.risk_level)}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#10b981',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Schváliť
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateSource(source.id, 'rejected', 0)}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#ef4444',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Zamietnuť
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{/* Sources Table */}
|
||||
<div className="card">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<div className="flex items-center">
|
||||
<ShieldExclamationIcon className="h-6 w-6 text-primary-600 mr-3" />
|
||||
<h2 className="text-lg font-medium text-gray-900">
|
||||
Zdroje ({sources.length})
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: '30px' }}>
|
||||
<Link href="/admin" style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#6b7280',
|
||||
color: 'white',
|
||||
textDecoration: 'none',
|
||||
borderRadius: '6px'
|
||||
}}>
|
||||
← Späť na dashboard
|
||||
</Link>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<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="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Doména
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Typ
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Riziko
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Dátum
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Akcie
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{sources.map((source) => (
|
||||
<tr key={source.id} className="table-row">
|
||||
<td className="px-6 py-4">
|
||||
<div>
|
||||
<a
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-600 hover:text-primary-800 font-medium"
|
||||
>
|
||||
{source.domain}
|
||||
</a>
|
||||
{source.title && (
|
||||
<div className="text-sm text-gray-500 truncate max-w-xs">
|
||||
{source.title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800">
|
||||
{source.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getRiskBadge(source.risk_level)}`}>
|
||||
Úroveň {source.risk_level}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
{getStatusIcon(source.status)}
|
||||
<span className={`ml-2 inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusBadge(source.status)}`}>
|
||||
{source.status}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(source.created_at).toLocaleString('sk-SK')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
{source.status === 'pending' && (
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => updateSource(source.id, 'verified', source.risk_level)}
|
||||
className="btn-primary px-3 py-1 text-xs"
|
||||
>
|
||||
<CheckCircleIcon className="h-4 w-4 mr-1" />
|
||||
Schváliť
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateSource(source.id, 'rejected', 0)}
|
||||
className="btn-danger px-3 py-1 text-xs"
|
||||
>
|
||||
<XCircleIcon className="h-4 w-4 mr-1" />
|
||||
Zamietnuť
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{source.status !== 'pending' && (
|
||||
<button className="btn-secondary px-3 py-1 text-xs">
|
||||
<EyeIcon className="h-4 w-4 mr-1" />
|
||||
Detail
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{sources.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-12 text-center">
|
||||
<ShieldExclamationIcon className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||
<p className="text-gray-500">Žiadne zdroje pre vybrané kritériá</p>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="card p-4">
|
||||
<div className="text-sm font-medium text-gray-900">Bulk akcie</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
<button className="btn-secondary w-full text-sm">Schváliť všetky vybrané</button>
|
||||
<button className="btn-danger w-full text-sm">Zamietnuť všetky vybrané</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<div className="text-sm font-medium text-gray-900">Export</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
<button className="btn-secondary w-full text-sm">Exportovať CSV</button>
|
||||
<button className="btn-secondary w-full text-sm">Exportovať Excel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<div className="text-sm font-medium text-gray-900">Štatistiky</div>
|
||||
<div className="mt-2 text-sm text-gray-600">
|
||||
<div>Čakajúce: {sources.filter(s => s.status === 'pending').length}</div>
|
||||
<div>Vysoké riziko: {sources.filter(s => s.risk_level >= 4).length}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user