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:
2025-09-06 15:14:20 +02:00
parent 860070a302
commit 249a672cd7
36 changed files with 8212 additions and 1434 deletions

View File

@@ -1,4 +1,5 @@
import type { AppProps } from 'next/app'
import '../styles/globals.css'
export default function App({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />

View File

@@ -1,18 +1,59 @@
import { useState, useEffect } from "react"
import type { NextPage } from "next"
import Head from "next/head"
import AdminLayout from '../../components/AdminLayout'
import { KeyIcon, PlusIcon } from '@heroicons/react/24/outline'
const ApiKeys: NextPage = () => {
const [keys, setKeys] = useState([])
useEffect(() => {
fetch("/api/admin/api-keys").then(res => res.json()).then(setKeys)
}, [])
return (
<div>
<Head><title>API Keys - Infohliadka</title></Head>
<h1>API Keys Management</h1>
<div>{keys.length} active keys</div>
</div>
<>
<Head>
<title>API kľúče - Hliadka.sk Admin</title>
</Head>
<AdminLayout title="API kľúče">
<div className="px-4 py-6 sm:px-6 lg:px-8">
<div className="sm:flex sm:items-center">
<div className="sm:flex-auto">
<p className="text-sm text-gray-700">
Správa API kľúčov pre prístup k systému
</p>
</div>
<div className="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<button
type="button"
className="btn-primary"
>
<PlusIcon className="h-4 w-4 mr-2" />
Vytvoriť kľúč
</button>
</div>
</div>
<div className="mt-8">
<div className="card p-6">
<div className="flex items-center mb-4">
<KeyIcon className="h-6 w-6 text-gray-400 mr-3" />
<h2 className="text-lg font-medium text-gray-900">Aktívne API kľúče</h2>
</div>
<div className="text-sm text-gray-600">
Celkom aktívnych kľúčov: <span className="font-medium text-gray-900">{keys.length}</span>
</div>
{keys.length === 0 && (
<p className="text-gray-500 mt-4">
Zatiaľ neboli vytvorené žiadne API kľúče. Kliknite na "Vytvoriť kľúč" pre vytvorenie nového.
</p>
)}
</div>
</div>
</div>
</AdminLayout>
</>
)
}
export default ApiKeys

View File

@@ -1,93 +1,204 @@
import { useState } from "react"
import type { NextPage } from "next"
import Head from "next/head"
import Link from "next/link"
import { useState } from 'react'
import type { NextPage } from 'next'
import Head from 'next/head'
import AdminLayout from '../../components/AdminLayout'
import { CloudArrowUpIcon, DocumentArrowUpIcon, CheckCircleIcon } from '@heroicons/react/24/outline'
const BulkImport: NextPage = () => {
const [importData, setImportData] = useState("")
const [loading, setLoading] = useState(false)
const [result, setResult] = useState<any>(null)
const [file, setFile] = useState<File | null>(null)
const [importing, setImporting] = useState(false)
const [results, setResults] = useState<any>(null)
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
setFile(e.target.files[0])
setResults(null)
}
}
const handleImport = async () => {
if (!importData.trim()) return
setLoading(true)
try {
const lines = importData.trim().split('\n')
const sources = lines.map(line => {
const [domain, risk_level] = line.split(',')
return {
domain: domain?.trim(),
risk_level: parseInt(risk_level?.trim()) || 3
}
}).filter(s => s.domain)
if (!file) return
setImporting(true)
const formData = new FormData()
formData.append('file', file)
try {
const response = await fetch('/api/admin/bulk-import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sources })
body: formData
})
const data = await response.json()
setResult(data)
if (response.ok) {
const data = await response.json()
setResults(data)
setFile(null)
} else {
const error = await response.json()
alert('Chyba pri importe: ' + error.error)
}
} catch (error) {
setResult({ error: 'Import failed' })
alert('Chyba pri nahrávaní súboru')
} finally {
setImporting(false)
}
setLoading(false)
}
return (
<div>
<>
<Head>
<title>Bulk Import - Infohliadka</title>
<title>Hromadný import - Hliadka.sk Admin</title>
</Head>
<div style={{ padding: '20px' }}>
<div style={{ marginBottom: '20px' }}>
<Link href="/admin"> Back to Admin</Link>
</div>
<h1>Bulk Import Sources</h1>
<p>Import multiple sources at once. Format: domain,risk_level (one per line)</p>
<textarea
value={importData}
onChange={(e) => setImportData(e.target.value)}
placeholder="example.com,4&#10;badsite.sk,5&#10;spam.org,2"
rows={10}
style={{ width: '100%', marginBottom: '10px' }}
/>
<button
onClick={handleImport}
disabled={loading || !importData.trim()}
style={{
padding: '10px 20px',
backgroundColor: loading ? '#ccc' : '#007bff',
color: 'white',
border: 'none',
borderRadius: '4px'
}}
>
{loading ? 'Importing...' : 'Import Sources'}
</button>
{result && (
<div style={{ marginTop: '20px', padding: '15px', backgroundColor: '#f8f9fa', border: '1px solid #ddd' }}>
<h3>Import Result:</h3>
{result.error ? (
<p style={{ color: 'red' }}>Error: {result.error}</p>
) : (
<div>
<p>Total processed: {result.total}</p>
<p>Successfully imported: {result.imported}</p>
<p>Skipped (duplicates/invalid): {result.skipped}</p>
<AdminLayout title="Hromadný import">
<div className="px-4 py-6 sm:px-6 lg:px-8">
<div className="sm:flex sm:items-center">
<div className="sm:flex-auto">
<p className="text-sm text-gray-700">
Importujte viacero zdrojov naraz pomocou CSV súboru
</p>
</div>
</div>
<div className="mt-8 max-w-3xl">
<div className="card p-6">
<div className="text-center">
<CloudArrowUpIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-semibold text-gray-900">Nahranie súboru</h3>
<p className="mt-1 text-sm text-gray-500">
Vyberte CSV súbor s URL adresami zdrojov na import
</p>
</div>
<div className="mt-6">
<div className="flex justify-center px-6 py-10 border-2 border-gray-300 border-dashed rounded-md">
<div className="text-center">
<DocumentArrowUpIcon className="mx-auto h-12 w-12 text-gray-400" />
<div className="mt-4">
<label htmlFor="file-upload" className="cursor-pointer">
<span className="mt-2 block text-sm font-semibold text-gray-900">
Nahrať súbor
</span>
<input
id="file-upload"
name="file-upload"
type="file"
accept=".csv,.txt"
className="sr-only"
onChange={handleFileChange}
/>
</label>
<p className="text-sm text-gray-500">
CSV alebo TXT súbor do 10MB
</p>
</div>
</div>
</div>
{file && (
<div className="mt-4">
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
<div className="flex items-center">
<DocumentArrowUpIcon className="h-5 w-5 text-gray-400 mr-2" />
<span className="text-sm font-medium text-gray-900">{file.name}</span>
<span className="ml-2 text-sm text-gray-500">
({Math.round(file.size / 1024)} KB)
</span>
</div>
<button
onClick={() => setFile(null)}
className="text-sm text-red-600 hover:text-red-800"
>
Odstrániť
</button>
</div>
</div>
)}
{file && (
<div className="mt-6 flex justify-end">
<button
onClick={handleImport}
disabled={importing}
className="btn-primary"
>
{importing ? (
<>
<svg className="animate-spin -ml-1 mr-3 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Importuje sa...
</>
) : (
<>
<CloudArrowUpIcon className="h-4 w-4 mr-2" />
Spustiť import
</>
)}
</button>
</div>
)}
</div>
</div>
{results && (
<div className="mt-8">
<div className="card p-6">
<div className="flex items-center mb-4">
<CheckCircleIcon className="h-6 w-6 text-green-500 mr-3" />
<h3 className="text-lg font-medium text-gray-900">Import dokončený</h3>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="bg-green-50 p-4 rounded-lg">
<div className="text-2xl font-bold text-green-600">{results.imported || 0}</div>
<div className="text-sm text-green-700">Úspešne importované</div>
</div>
<div className="bg-yellow-50 p-4 rounded-lg">
<div className="text-2xl font-bold text-yellow-600">{results.skipped || 0}</div>
<div className="text-sm text-yellow-700">Preskočené (duplikáty)</div>
</div>
<div className="bg-red-50 p-4 rounded-lg">
<div className="text-2xl font-bold text-red-600">{results.failed || 0}</div>
<div className="text-sm text-red-700">Neúspešné</div>
</div>
</div>
{results.errors && results.errors.length > 0 && (
<div className="mt-6">
<h4 className="text-sm font-medium text-gray-900 mb-2">Chyby pri importe:</h4>
<div className="bg-red-50 rounded-md p-3">
<ul className="text-sm text-red-700 space-y-1">
{results.errors.map((error: string, idx: number) => (
<li key={idx}> {error}</li>
))}
</ul>
</div>
</div>
)}
</div>
</div>
)}
<div className="mt-8">
<div className="card p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Inštrukcie</h3>
<div className="prose prose-sm text-gray-500">
<ul>
<li>CSV súbor by mal obsahovať jeden URL na riadok</li>
<li>Prvý riadok môže obsahovať hlavičku (bude preskočený)</li>
<li>Podporované formáty: .csv, .txt</li>
<li>Maximálna veľkosť súboru: 10MB</li>
<li>Duplikátne URL adresy budú automaticky preskočené</li>
<li>Neplatné URL adresy budú označené ako chyby</li>
</ul>
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
</AdminLayout>
</>
)
}

View File

@@ -1,7 +1,8 @@
import { useState, useEffect } from 'react'
import type { NextPage } from 'next'
import Head from 'next/head'
import Link from 'next/link'
import AdminLayout from '../../components/AdminLayout'
import { TagIcon, PlusIcon } from '@heroicons/react/24/outline'
interface Category {
id: number
@@ -51,78 +52,119 @@ const CategoriesManagement: NextPage = () => {
}
return (
<div>
<>
<Head>
<title>Správa kategórií - Infohliadka</title>
<title>Správa kategórií - Hliadka.sk Admin</title>
</Head>
<AdminLayout title="Správa kategórií">
<div className="px-4 py-6 sm:px-6 lg:px-8">
<div className="sm:flex sm:items-center">
<div className="sm:flex-auto">
<p className="text-sm text-gray-700">
Spravujte kategórie a ich nastavenia pre klasifikáciu zdrojov
</p>
</div>
<div className="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<button
type="button"
className="btn-primary"
>
<PlusIcon className="h-4 w-4 mr-2" />
Pridať kategóriu
</button>
</div>
</div>
<div style={{ padding: '20px', fontFamily: 'Arial, sans-serif' }}>
<h1>Správa kategórií</h1>
{loading ? (
<div>Loading...</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: '20px' }}>
<thead>
<tr style={{ backgroundColor: '#f3f4f6' }}>
<th style={{ padding: '12px', textAlign: 'left', border: '1px solid #d1d5db' }}>Názov</th>
<th style={{ padding: '12px', textAlign: 'left', border: '1px solid #d1d5db' }}>Priorita</th>
<th style={{ padding: '12px', textAlign: 'left', border: '1px solid #d1d5db' }}>Farba</th>
<th style={{ padding: '12px', textAlign: 'left', border: '1px solid #d1d5db' }}>Aktívna</th>
<th style={{ padding: '12px', textAlign: 'left', border: '1px solid #d1d5db' }}>Akcie</th>
</tr>
</thead>
<tbody>
{categories.map((category) => (
<tr key={category.id}>
<td style={{ padding: '12px', border: '1px solid #d1d5db' }}>
<strong>{category.name}</strong>
<div style={{ fontSize: '12px', color: '#6b7280' }}>
{category.description}
<div className="mt-8 flow-root">
<div className="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<div className="card">
{loading ? (
<div className="p-6">
<div className="flex items-center justify-center">
<div className="text-gray-500">Načítavanie...</div>
</div>
</div>
</td>
<td style={{ padding: '12px', border: '1px solid #d1d5db' }}>
{category.priority}
</td>
<td style={{ padding: '12px', border: '1px solid #d1d5db' }}>
<div style={{
width: '20px',
height: '20px',
backgroundColor: category.color,
borderRadius: '4px',
display: 'inline-block'
}}></div>
<span style={{ marginLeft: '8px' }}>{category.color}</span>
</td>
<td style={{ padding: '12px', border: '1px solid #d1d5db' }}>
{category.is_active ? '✅' : '❌'}
</td>
<td style={{ padding: '12px', border: '1px solid #d1d5db' }}>
<button
onClick={() => toggleCategory(category.id, category.is_active)}
style={{
padding: '4px 8px',
backgroundColor: category.is_active ? '#ef4444' : '#10b981',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
{category.is_active ? 'Deaktivovať' : 'Aktivovať'}
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
<div style={{ marginTop: '30px' }}>
<Link href="/admin"> Späť na dashboard</Link>
) : (
<table className="min-w-full divide-y divide-gray-300">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">
Názov
</th>
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
Priorita
</th>
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
Farba
</th>
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
Status
</th>
<th scope="col" className="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span className="sr-only">Akcie</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
{categories.map((category) => (
<tr key={category.id} className="hover:bg-gray-50">
<td className="whitespace-nowrap py-4 pl-4 pr-3 sm:pl-6">
<div className="flex items-center">
<TagIcon className="h-5 w-5 text-gray-400 mr-3" />
<div>
<div className="text-sm font-medium text-gray-900">{category.name}</div>
<div className="text-sm text-gray-500">{category.description}</div>
</div>
</div>
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<span className="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800">
{category.priority}
</span>
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<div className="flex items-center">
<div
className="w-5 h-5 rounded border border-gray-300 mr-2"
style={{ backgroundColor: category.color }}
/>
<span className="text-xs font-mono">{category.color}</span>
</div>
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{category.is_active ? (
<span className="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
Aktívna
</span>
) : (
<span className="inline-flex items-center rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-medium text-red-800">
Neaktívna
</span>
)}
</td>
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<button
onClick={() => toggleCategory(category.id, category.is_active)}
className={`${
category.is_active ? 'btn-danger' : 'btn-secondary'
} text-xs px-3 py-1`}
>
{category.is_active ? 'Deaktivovať' : 'Aktivovať'}
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</AdminLayout>
</>
)
}

396
pages/admin/export.tsx Normal file
View File

@@ -0,0 +1,396 @@
import { useState, useEffect } from 'react'
import type { NextPage } from 'next'
import Head from 'next/head'
import AdminLayout from '../../components/AdminLayout'
import {
DocumentArrowDownIcon,
TableCellsIcon,
ChartBarIcon,
CalendarIcon,
FunnelIcon,
Cog6ToothIcon,
ClockIcon,
CheckCircleIcon,
ExclamationTriangleIcon
} from '@heroicons/react/24/outline'
interface ExportJob {
id: string
name: string
type: 'sources' | 'reports' | 'analytics' | 'users' | 'audit_logs'
format: 'csv' | 'xlsx' | 'json' | 'pdf'
status: 'pending' | 'processing' | 'completed' | 'failed'
created_at: string
completed_at?: string
download_url?: string
file_size?: string
records_count?: number
}
const ExportPage: NextPage = () => {
const [exportJobs, setExportJobs] = useState<ExportJob[]>([])
const [loading, setLoading] = useState(false)
const [selectedType, setSelectedType] = useState<string>('sources')
const [selectedFormat, setSelectedFormat] = useState<string>('csv')
const [dateRange, setDateRange] = useState({
from: '',
to: ''
})
const [filters, setFilters] = useState({
status: '',
riskLevel: '',
category: ''
})
useEffect(() => {
fetchExportJobs()
}, [])
const fetchExportJobs = async () => {
try {
const response = await fetch('/api/admin/export/jobs')
if (response.ok) {
const jobs = await response.json()
setExportJobs(jobs)
}
} catch (error) {
console.error('Failed to fetch export jobs:', error)
}
}
const createExport = async () => {
setLoading(true)
try {
const response = await fetch('/api/admin/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: selectedType,
format: selectedFormat,
dateRange,
filters
})
})
if (response.ok) {
const newJob = await response.json()
setExportJobs(prev => [newJob, ...prev])
}
} catch (error) {
console.error('Failed to create export:', error)
} finally {
setLoading(false)
}
}
const getStatusIcon = (status: string) => {
switch (status) {
case 'completed':
return <CheckCircleIcon className="h-5 w-5 text-green-500" />
case 'failed':
return <ExclamationTriangleIcon className="h-5 w-5 text-red-500" />
case 'processing':
return <div className="h-5 w-5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
default:
return <ClockIcon className="h-5 w-5 text-yellow-500" />
}
}
const getStatusColor = (status: string) => {
switch (status) {
case 'completed':
return 'bg-green-100 text-green-800'
case 'failed':
return 'bg-red-100 text-red-800'
case 'processing':
return 'bg-blue-100 text-blue-800'
default:
return 'bg-yellow-100 text-yellow-800'
}
}
return (
<AdminLayout title="Export dát">
<Head>
<title>Export dát - Hliadka.sk Admin</title>
</Head>
<div className="space-y-8">
{/* Export Configuration */}
<div className="card p-6">
<div className="flex items-center mb-6">
<DocumentArrowDownIcon className="h-6 w-6 text-primary-600 mr-3" />
<h2 className="text-xl font-semibold text-gray-900">Nový export</h2>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Export Type Selection */}
<div>
<h3 className="text-lg font-medium text-gray-900 mb-4">Typ dát</h3>
<div className="space-y-3">
{[
{ value: 'sources', label: 'Zdroje', description: 'Všetky monitorované zdroje s metadátami', icon: TableCellsIcon },
{ value: 'reports', label: 'Hlásenia', description: 'Používateľské hlásenia a ich stav', icon: DocumentArrowDownIcon },
{ value: 'analytics', label: 'Analytika', description: 'Štatistické údaje a metriky', icon: ChartBarIcon },
{ value: 'users', label: 'Používatelia', description: 'Administrátori a moderátori', icon: TableCellsIcon },
{ value: 'audit_logs', label: 'Audit log', description: 'Záznamy o aktivitách v systéme', icon: ClockIcon }
].map((type) => (
<label key={type.value} className="flex items-center p-4 border rounded-lg cursor-pointer hover:bg-gray-50">
<input
type="radio"
name="exportType"
value={type.value}
checked={selectedType === type.value}
onChange={(e) => setSelectedType(e.target.value)}
className="h-4 w-4 text-primary-600"
/>
<type.icon className="h-5 w-5 text-gray-400 ml-3 mr-3" />
<div>
<div className="font-medium text-gray-900">{type.label}</div>
<div className="text-sm text-gray-500">{type.description}</div>
</div>
</label>
))}
</div>
</div>
{/* Export Configuration */}
<div className="space-y-6">
{/* Format Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Formát súboru</label>
<select
value={selectedFormat}
onChange={(e) => setSelectedFormat(e.target.value)}
className="input"
>
<option value="csv">CSV (Comma Separated Values)</option>
<option value="xlsx">XLSX (Excel)</option>
<option value="json">JSON</option>
<option value="pdf">PDF Report</option>
</select>
</div>
{/* Date Range */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Časové obdobie</label>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs text-gray-500 mb-1">Od</label>
<input
type="date"
value={dateRange.from}
onChange={(e) => setDateRange(prev => ({ ...prev, from: e.target.value }))}
className="input"
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Do</label>
<input
type="date"
value={dateRange.to}
onChange={(e) => setDateRange(prev => ({ ...prev, to: e.target.value }))}
className="input"
/>
</div>
</div>
</div>
{/* Filters */}
{selectedType === 'sources' && (
<div className="space-y-4">
<h4 className="text-sm font-medium text-gray-700">Filtre</h4>
<div>
<label className="block text-xs text-gray-500 mb-1">Status</label>
<select
value={filters.status}
onChange={(e) => setFilters(prev => ({ ...prev, status: e.target.value }))}
className="input"
>
<option value="">Všetky</option>
<option value="pending">Čakajúce</option>
<option value="verified">Overené</option>
<option value="rejected">Zamietnuté</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Úroveň rizika</label>
<select
value={filters.riskLevel}
onChange={(e) => setFilters(prev => ({ ...prev, riskLevel: e.target.value }))}
className="input"
>
<option value="">Všetky</option>
<option value="1">1 - Nízke</option>
<option value="2">2 - Mierne</option>
<option value="3">3 - Stredné</option>
<option value="4">4 - Vysoké</option>
<option value="5">5 - Kritické</option>
</select>
</div>
</div>
)}
{/* Export Button */}
<button
onClick={createExport}
disabled={loading}
className="btn-primary w-full"
>
{loading ? (
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Vytvára sa export...
</div>
) : (
<div className="flex items-center justify-center">
<DocumentArrowDownIcon className="h-5 w-5 mr-2" />
Vytvoriť export
</div>
)}
</button>
</div>
</div>
</div>
{/* Export History */}
<div className="card p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center">
<ClockIcon className="h-6 w-6 text-primary-600 mr-3" />
<h2 className="text-xl font-semibold text-gray-900">História exportov</h2>
</div>
<button
onClick={fetchExportJobs}
className="btn-secondary"
>
Obnoviť
</button>
</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">
Názov
</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">
Formát
</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">
Vytvorený
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Veľkosť
</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">
{exportJobs.map((job) => (
<tr key={job.id} className="table-row">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{job.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<span className="capitalize">{job.type}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<span className="uppercase">{job.format}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
{getStatusIcon(job.status)}
<span className={`ml-2 inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(job.status)}`}>
{job.status}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(job.created_at).toLocaleString('sk-SK')}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{job.file_size || '-'}
{job.records_count && (
<div className="text-xs text-gray-400">{job.records_count} záznamov</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
{job.status === 'completed' && job.download_url && (
<a
href={job.download_url}
className="text-primary-600 hover:text-primary-900"
download
>
Stiahnuť
</a>
)}
{job.status === 'failed' && (
<button className="text-red-600 hover:text-red-900">
Zopakovať
</button>
)}
</td>
</tr>
))}
{exportJobs.length === 0 && (
<tr>
<td colSpan={7} className="px-6 py-12 text-center text-gray-500">
<DocumentArrowDownIcon className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p>Žiadne exporty</p>
<p className="text-sm">Vytvorte svoj prvý export dát</p>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* Quick Export Templates */}
<div className="card p-6">
<div className="flex items-center mb-6">
<Cog6ToothIcon className="h-6 w-6 text-primary-600 mr-3" />
<h2 className="text-xl font-semibold text-gray-900">Rýchle exporty</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<button className="p-4 border border-gray-200 rounded-lg hover:bg-gray-50 text-left">
<div className="text-sm font-medium text-gray-900">Všetky zdroje</div>
<div className="text-sm text-gray-500">CSV export všetkých zdrojov</div>
</button>
<button className="p-4 border border-gray-200 rounded-lg hover:bg-gray-50 text-left">
<div className="text-sm font-medium text-gray-900">Vysoké riziko</div>
<div className="text-sm text-gray-500">Zdroje s úrovňou rizika 4-5</div>
</button>
<button className="p-4 border border-gray-200 rounded-lg hover:bg-gray-50 text-left">
<div className="text-sm font-medium text-gray-900">Týždňový report</div>
<div className="text-sm text-gray-500">PDF report za posledný týždeň</div>
</button>
<button className="p-4 border border-gray-200 rounded-lg hover:bg-gray-50 text-left">
<div className="text-sm font-medium text-gray-900">Audit log</div>
<div className="text-sm text-gray-500">Záznamy za posledný mesiac</div>
</button>
</div>
</div>
</div>
</AdminLayout>
)
}
export default ExportPage

View File

@@ -1,15 +1,143 @@
import { useState, useEffect } from 'react'
import type { NextPage } from 'next'
import Head from 'next/head'
import Link from 'next/link'
import AdminLayout from '../../components/AdminLayout'
import {
ChartBarIcon,
ShieldExclamationIcon,
DocumentTextIcon,
ExclamationTriangleIcon,
ClockIcon,
EyeIcon,
ServerStackIcon,
CircleStackIcon,
UserGroupIcon,
GlobeAltIcon,
ArrowTrendingUpIcon,
ArrowTrendingDownIcon,
CheckCircleIcon
} from '@heroicons/react/24/outline'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Title,
Tooltip,
Legend,
ArcElement,
} from 'chart.js'
import { Line, Bar, Doughnut } from 'react-chartjs-2'
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Title,
Tooltip,
Legend,
ArcElement
)
interface DashboardStats {
// Basic stats
total_sources: number
pending_sources: number
pending_reports: number
high_risk_sources: number
sources_added_week: number
reports_today: number
// Advanced stats
verified_sources_today: number
api_calls_today: number
unique_visitors_today: number
system_uptime: string
database_size: string
active_moderators: number
// Performance metrics
avg_response_time: number
api_success_rate: number
memory_usage: number
cpu_usage: number
// Trend data
sources_trend: Array<{date: string, count: number}>
reports_trend: Array<{date: string, count: number}>
risk_distribution: Array<{level: string, count: number}>
category_distribution: Array<{category: string, count: number}>
// Recent activities
recent_sources: Array<{
id: number
url: string
status: string
created_at: string
}>
recent_reports: Array<{
id: number
source_url: string
status: string
created_at: string
}>
}
const StatCard = ({ title, value, change, icon: Icon, trend, color = 'blue' }: {
title: string
value: string | number
change?: number
icon: any
trend?: 'up' | 'down' | 'neutral'
color?: 'blue' | 'green' | 'red' | 'yellow' | 'purple'
}) => {
const colorClasses = {
blue: 'bg-blue-500',
green: 'bg-green-500',
red: 'bg-red-500',
yellow: 'bg-yellow-500',
purple: 'bg-purple-500'
}
return (
<div className="stat-card">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className={`p-3 rounded-lg ${colorClasses[color]}`}>
<Icon className="h-6 w-6 text-white" />
</div>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">{title}</dt>
<dd className="flex items-baseline">
<div className="text-2xl font-semibold text-gray-900">
{typeof value === 'number' ? value.toLocaleString() : value}
</div>
{change !== undefined && (
<div className={`ml-2 flex items-baseline text-sm font-semibold ${
change > 0 ? 'text-green-600' : change < 0 ? 'text-red-600' : 'text-gray-500'
}`}>
{change > 0 ? (
<ArrowTrendingUpIcon className="self-center flex-shrink-0 h-4 w-4 text-green-500" />
) : change < 0 ? (
<ArrowTrendingDownIcon className="self-center flex-shrink-0 h-4 w-4 text-red-500" />
) : null}
<span className="ml-1">
{Math.abs(change)}%
</span>
</div>
)}
</dd>
</dl>
</div>
</div>
</div>
)
}
const AdminDashboard: NextPage = () => {
@@ -18,6 +146,8 @@ const AdminDashboard: NextPage = () => {
useEffect(() => {
fetchStats()
const interval = setInterval(fetchStats, 30000) // Refresh every 30 seconds
return () => clearInterval(interval)
}, [])
const fetchStats = async () => {
@@ -34,85 +164,316 @@ const AdminDashboard: NextPage = () => {
}
}
// Chart configurations
const sourcesChartData = {
labels: stats?.sources_trend?.map(item => new Date(item.date).toLocaleDateString('sk-SK')) || [],
datasets: [
{
label: 'Nové zdroje',
data: stats?.sources_trend?.map(item => item.count) || [],
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.4,
},
],
}
const reportsChartData = {
labels: stats?.reports_trend?.map(item => new Date(item.date).toLocaleDateString('sk-SK')) || [],
datasets: [
{
label: 'Nové hlásenia',
data: stats?.reports_trend?.map(item => item.count) || [],
backgroundColor: 'rgba(34, 197, 94, 0.8)',
borderColor: 'rgb(34, 197, 94)',
borderWidth: 1,
},
],
}
const riskDistributionData = {
labels: stats?.risk_distribution?.map(item => `Úroveň ${item.level}`) || [],
datasets: [
{
data: stats?.risk_distribution?.map(item => item.count) || [],
backgroundColor: [
'#ef4444', // red
'#f97316', // orange
'#eab308', // yellow
'#22c55e', // green
'#3b82f6', // blue
],
borderWidth: 2,
borderColor: '#ffffff',
},
],
}
if (loading) {
return (
<div>
<AdminLayout title="Dashboard">
<Head>
<title>Admin Panel - Infohliadka</title>
<title>Dashboard - Hliadka.sk Admin</title>
</Head>
<div>Loading...</div>
</div>
<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>
</AdminLayout>
)
}
return (
<div>
<AdminLayout title="Dashboard">
<Head>
<title>Admin Panel - Infohliadka</title>
<title>Dashboard - Hliadka.sk Admin</title>
</Head>
<div style={{ padding: '20px', fontFamily: 'Arial, sans-serif' }}>
<h1>Admin Dashboard</h1>
{stats && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '20px', marginTop: '30px' }}>
<div style={{ padding: '20px', border: '1px solid #ddd', borderRadius: '8px' }}>
<h3>Celkové zdroje</h3>
<div style={{ fontSize: '24px', fontWeight: 'bold' }}>{stats.total_sources}</div>
</div>
<div style={{ padding: '20px', border: '1px solid #ddd', borderRadius: '8px' }}>
<h3>Čakajúce schválenie</h3>
<div style={{ fontSize: '24px', fontWeight: 'bold', color: '#f59e0b' }}>{stats.pending_sources}</div>
</div>
<div style={{ padding: '20px', border: '1px solid #ddd', borderRadius: '8px' }}>
<h3>Vysoké riziko</h3>
<div style={{ fontSize: '24px', fontWeight: 'bold', color: '#ef4444' }}>{stats.high_risk_sources}</div>
</div>
<div style={{ padding: '20px', border: '1px solid #ddd', borderRadius: '8px' }}>
<h3>Nové hlásenia</h3>
<div style={{ fontSize: '24px', fontWeight: 'bold', color: '#3b82f6' }}>{stats.pending_reports}</div>
</div>
<div style={{ padding: '20px', border: '1px solid #ddd', borderRadius: '8px' }}>
<h3>Pridané tento týždeň</h3>
<div style={{ fontSize: '24px', fontWeight: 'bold' }}>{stats.sources_added_week}</div>
</div>
<div style={{ padding: '20px', border: '1px solid #ddd', borderRadius: '8px' }}>
<h3>Hlásenia dnes</h3>
<div style={{ fontSize: '24px', fontWeight: 'bold' }}>{stats.reports_today}</div>
</div>
</div>
)}
{/* Key Metrics */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<StatCard
title="Celkové zdroje"
value={stats?.total_sources || 0}
change={12}
icon={CircleStackIcon}
color="blue"
/>
<StatCard
title="Čakajúce schválenie"
value={stats?.pending_sources || 0}
change={-5}
icon={ClockIcon}
color="yellow"
/>
<StatCard
title="Vysoké riziko"
value={stats?.high_risk_sources || 0}
change={8}
icon={ExclamationTriangleIcon}
color="red"
/>
<StatCard
title="Nové hlásenia"
value={stats?.pending_reports || 0}
change={15}
icon={DocumentTextIcon}
color="green"
/>
</div>
<div style={{ marginTop: '40px' }}>
<h2>Rýchle akcie</h2>
<div style={{ display: 'flex', gap: '15px', marginTop: '20px' }}>
<Link href="/admin/sources" style={{
padding: '12px 24px',
backgroundColor: '#3b82f6',
color: 'white',
textDecoration: 'none',
borderRadius: '6px'
}}>
Správa zdrojov
</Link>
<Link href="/admin/reports" style={{
padding: '12px 24px',
backgroundColor: '#10b981',
color: 'white',
textDecoration: 'none',
borderRadius: '6px'
}}>
Hlásenia
</Link>
{/* System Performance Metrics */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<StatCard
title="API volania dnes"
value={stats?.api_calls_today || 0}
icon={ServerStackIcon}
color="purple"
/>
<StatCard
title="Aktívni moderátori"
value={stats?.active_moderators || 0}
icon={UserGroupIcon}
color="green"
/>
<StatCard
title="Využitie CPU"
value={`${stats?.cpu_usage || 0}%`}
icon={ChartBarIcon}
color="blue"
/>
<StatCard
title="API Success Rate"
value={`${stats?.api_success_rate || 0}%`}
icon={CheckCircleIcon}
color="green"
/>
</div>
{/* Charts Section */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
{/* Sources Trend */}
<div className="card p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Trend pridávania zdrojov</h3>
<Line
data={sourcesChartData}
options={{
responsive: true,
plugins: {
legend: {
position: 'top' as const,
},
},
scales: {
y: {
beginAtZero: true,
},
},
}}
/>
</div>
{/* Reports Trend */}
<div className="card p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Trend hlásení</h3>
<Bar
data={reportsChartData}
options={{
responsive: true,
plugins: {
legend: {
position: 'top' as const,
},
},
scales: {
y: {
beginAtZero: true,
},
},
}}
/>
</div>
</div>
{/* Risk Distribution and Category Stats */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-8">
<div className="card p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Distribúcia rizika</h3>
<Doughnut
data={riskDistributionData}
options={{
responsive: true,
plugins: {
legend: {
position: 'bottom' as const,
},
},
}}
/>
</div>
<div className="card p-6 col-span-2">
<h3 className="text-lg font-medium text-gray-900 mb-4">Systémové informácie</h3>
<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
<div>
<dt className="text-sm font-medium text-gray-500">Uptime systému</dt>
<dd className="mt-1 text-sm text-gray-900">{stats?.system_uptime || 'N/A'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Veľkosť databázy</dt>
<dd className="mt-1 text-sm text-gray-900">{stats?.database_size || 'N/A'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Priemerný response time</dt>
<dd className="mt-1 text-sm text-gray-900">{stats?.avg_response_time || 0}ms</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Využitie pamäte</dt>
<dd className="mt-1 text-sm text-gray-900">{stats?.memory_usage || 0}%</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Jedinečných návštevníkov dnes</dt>
<dd className="mt-1 text-sm text-gray-900">{stats?.unique_visitors_today || 0}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Overených zdrojov dnes</dt>
<dd className="mt-1 text-sm text-gray-900">{stats?.verified_sources_today || 0}</dd>
</div>
</dl>
</div>
</div>
{/* Recent Activity */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="card p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Najnovšie zdroje</h3>
<div className="flow-root">
<ul className="-mb-8">
{stats?.recent_sources?.map((source, idx) => (
<li key={source.id}>
<div className="relative pb-8">
{idx !== (stats.recent_sources?.length || 0) - 1 && (
<span
className="absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200"
aria-hidden="true"
/>
)}
<div className="relative flex space-x-3">
<div>
<span className={`h-8 w-8 rounded-full flex items-center justify-center ring-8 ring-white ${
source.status === 'verified' ? 'bg-green-500' :
source.status === 'pending' ? 'bg-yellow-500' :
'bg-red-500'
}`}>
<ShieldExclamationIcon className="h-4 w-4 text-white" />
</span>
</div>
<div className="min-w-0 flex-1 pt-1.5 flex justify-between space-x-4">
<div>
<p className="text-sm text-gray-500 truncate">
{source.url}
</p>
</div>
<div className="text-right text-sm whitespace-nowrap text-gray-500">
{new Date(source.created_at).toLocaleString('sk-SK')}
</div>
</div>
</div>
</div>
</li>
)) || (
<li className="text-sm text-gray-500 text-center py-4">
Žiadne nedávne aktivity
</li>
)}
</ul>
</div>
</div>
<div className="card p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Najnovšie hlásenia</h3>
<div className="flow-root">
<ul className="-mb-8">
{stats?.recent_reports?.map((report, idx) => (
<li key={report.id}>
<div className="relative pb-8">
{idx !== (stats.recent_reports?.length || 0) - 1 && (
<span
className="absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200"
aria-hidden="true"
/>
)}
<div className="relative flex space-x-3">
<div>
<span className={`h-8 w-8 rounded-full flex items-center justify-center ring-8 ring-white ${
report.status === 'approved' ? 'bg-green-500' :
report.status === 'pending' ? 'bg-yellow-500' :
'bg-red-500'
}`}>
<DocumentTextIcon className="h-4 w-4 text-white" />
</span>
</div>
<div className="min-w-0 flex-1 pt-1.5 flex justify-between space-x-4">
<div>
<p className="text-sm text-gray-500 truncate">
{report.source_url}
</p>
</div>
<div className="text-right text-sm whitespace-nowrap text-gray-500">
{new Date(report.created_at).toLocaleString('sk-SK')}
</div>
</div>
</div>
</div>
</li>
)) || (
<li className="text-sm text-gray-500 text-center py-4">
Žiadne nedávne hlásenia
</li>
)}
</ul>
</div>
</div>
</div>
</div>
</AdminLayout>
)
}

View File

@@ -0,0 +1,56 @@
import type { NextPage } from 'next'
import Head from 'next/head'
import AdminLayout from '../../../components/AdminLayout'
import { ChartBarIcon, CpuChipIcon, ServerStackIcon } from '@heroicons/react/24/outline'
const Monitoring: NextPage = () => {
return (
<>
<Head>
<title>Monitoring - Hliadka.sk Admin</title>
</Head>
<AdminLayout title="Monitoring">
<div className="px-4 py-6 sm:px-6 lg:px-8">
<div className="sm:flex sm:items-center">
<div className="sm:flex-auto">
<p className="text-sm text-gray-700">
Monitorovanie výkonnosti systému a zdrojov v reálnom čase
</p>
</div>
</div>
<div className="mt-8 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<div className="card p-6">
<div className="flex items-center mb-4">
<CpuChipIcon className="h-8 w-8 text-blue-500 mr-3" />
<h2 className="text-lg font-medium text-gray-900">CPU Využitie</h2>
</div>
<div className="text-3xl font-bold text-blue-600">42%</div>
<p className="text-sm text-gray-600 mt-1">Aktuálne zaťaženie procesora</p>
</div>
<div className="card p-6">
<div className="flex items-center mb-4">
<ServerStackIcon className="h-8 w-8 text-green-500 mr-3" />
<h2 className="text-lg font-medium text-gray-900">Pamäť</h2>
</div>
<div className="text-3xl font-bold text-green-600">67%</div>
<p className="text-sm text-gray-600 mt-1">Využitá RAM pamäť</p>
</div>
<div className="card p-6">
<div className="flex items-center mb-4">
<ChartBarIcon className="h-8 w-8 text-purple-500 mr-3" />
<h2 className="text-lg font-medium text-gray-900">API Requests</h2>
</div>
<div className="text-3xl font-bold text-purple-600">1,247</div>
<p className="text-sm text-gray-600 mt-1">Posledných 24 hodín</p>
</div>
</div>
</div>
</AdminLayout>
</>
)
}
export default Monitoring

View File

@@ -0,0 +1,432 @@
import { useState, useEffect, useRef } from 'react'
import type { NextPage } from 'next'
import Head from 'next/head'
import AdminLayout from '../../../components/AdminLayout'
import {
ChartBarIcon,
ServerStackIcon,
CircleStackIcon as DatabaseIcon,
CpuChipIcon,
CircleStackIcon,
GlobeAltIcon,
ExclamationTriangleIcon,
CheckCircleIcon,
ClockIcon,
ArrowPathIcon
} from '@heroicons/react/24/outline'
import { Line, Bar } from 'react-chartjs-2'
interface RealtimeMetrics {
timestamp: string
cpu_usage: number
memory_usage: number
disk_usage: number
active_connections: number
api_requests_per_minute: number
database_queries_per_minute: number
response_time: number
error_rate: number
}
interface SystemAlert {
id: string
type: 'warning' | 'error' | 'info'
title: string
message: string
timestamp: string
resolved: boolean
}
interface ActiveUser {
id: string
name: string
email: string
last_activity: string
ip_address: string
location: string
}
const RealtimeMonitoring: NextPage = () => {
const [metrics, setMetrics] = useState<RealtimeMetrics[]>([])
const [alerts, setAlerts] = useState<SystemAlert[]>([])
const [activeUsers, setActiveUsers] = useState<ActiveUser[]>([])
const [isConnected, setIsConnected] = useState(false)
const [autoRefresh, setAutoRefresh] = useState(true)
const intervalRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
if (autoRefresh) {
fetchData()
intervalRef.current = setInterval(fetchData, 5000) // Update every 5 seconds
} else if (intervalRef.current) {
clearInterval(intervalRef.current)
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
}
}
}, [autoRefresh])
const fetchData = async () => {
try {
// Fetch real-time metrics
const metricsResponse = await fetch('/api/admin/monitoring/realtime')
if (metricsResponse.ok) {
const newMetric = await metricsResponse.json()
setMetrics(prev => {
const updated = [...prev, newMetric].slice(-20) // Keep last 20 data points
return updated
})
setIsConnected(true)
}
// Fetch alerts
const alertsResponse = await fetch('/api/admin/monitoring/alerts')
if (alertsResponse.ok) {
const alertsData = await alertsResponse.json()
setAlerts(alertsData)
}
// Fetch active users
const usersResponse = await fetch('/api/admin/monitoring/active-users')
if (usersResponse.ok) {
const usersData = await usersResponse.json()
setActiveUsers(usersData)
}
} catch (error) {
console.error('Failed to fetch monitoring data:', error)
setIsConnected(false)
}
}
const getLatestMetric = () => metrics[metrics.length - 1] || {}
const getStatusColor = (value: number, thresholds: { warning: number, critical: number }) => {
if (value >= thresholds.critical) return 'text-red-600 bg-red-100'
if (value >= thresholds.warning) return 'text-yellow-600 bg-yellow-100'
return 'text-green-600 bg-green-100'
}
// Chart configurations
const cpuChartData = {
labels: metrics.map((_, index) => `${index * 5}s ago`).reverse(),
datasets: [
{
label: 'CPU Usage %',
data: metrics.map(m => m.cpu_usage).reverse(),
borderColor: 'rgb(239, 68, 68)',
backgroundColor: 'rgba(239, 68, 68, 0.1)',
tension: 0.4,
fill: true,
},
],
}
const memoryChartData = {
labels: metrics.map((_, index) => `${index * 5}s ago`).reverse(),
datasets: [
{
label: 'Memory Usage %',
data: metrics.map(m => m.memory_usage).reverse(),
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.4,
fill: true,
},
],
}
const apiRequestsData = {
labels: metrics.map((_, index) => `${index * 5}s`).reverse(),
datasets: [
{
label: 'API Requests/min',
data: metrics.map(m => m.api_requests_per_minute).reverse(),
backgroundColor: 'rgba(34, 197, 94, 0.8)',
borderColor: 'rgb(34, 197, 94)',
},
],
}
const latest = getLatestMetric()
return (
<AdminLayout title="Real-time Monitoring">
<Head>
<title>Real-time Monitoring - Hliadka.sk Admin</title>
</Head>
{/* Connection Status */}
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className={`flex items-center space-x-2 px-3 py-1 rounded-full text-sm font-medium ${
isConnected ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
<span>{isConnected ? 'Pripojené' : 'Odpojené'}</span>
</div>
<button
onClick={() => setAutoRefresh(!autoRefresh)}
className={`flex items-center space-x-2 px-3 py-1 rounded-md text-sm font-medium transition-colors ${
autoRefresh
? 'bg-primary-100 text-primary-800 hover:bg-primary-200'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
}`}
>
<ArrowPathIcon className={`w-4 h-4 ${autoRefresh ? 'animate-spin' : ''}`} />
<span>{autoRefresh ? 'Auto-refresh ON' : 'Auto-refresh OFF'}</span>
</button>
</div>
<div className="text-sm text-gray-500">
Posledná aktualizácia: {new Date().toLocaleTimeString('sk-SK')}
</div>
</div>
{/* System Status Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div className="card p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">CPU Usage</p>
<p className={`text-2xl font-bold ${getStatusColor(latest.cpu_usage || 0, { warning: 70, critical: 90 })}`}>
{latest.cpu_usage || 0}%
</p>
</div>
<CpuChipIcon className="h-8 w-8 text-gray-400" />
</div>
<div className="mt-2">
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full ${
(latest.cpu_usage || 0) >= 90 ? 'bg-red-500' :
(latest.cpu_usage || 0) >= 70 ? 'bg-yellow-500' : 'bg-green-500'
}`}
style={{ width: `${latest.cpu_usage || 0}%` }}
/>
</div>
</div>
</div>
<div className="card p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Memory Usage</p>
<p className={`text-2xl font-bold ${getStatusColor(latest.memory_usage || 0, { warning: 80, critical: 95 })}`}>
{latest.memory_usage || 0}%
</p>
</div>
<CircleStackIcon className="h-8 w-8 text-gray-400" />
</div>
<div className="mt-2">
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full ${
(latest.memory_usage || 0) >= 95 ? 'bg-red-500' :
(latest.memory_usage || 0) >= 80 ? 'bg-yellow-500' : 'bg-green-500'
}`}
style={{ width: `${latest.memory_usage || 0}%` }}
/>
</div>
</div>
</div>
<div className="card p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Active Connections</p>
<p className="text-2xl font-bold text-primary-600">
{latest.active_connections || 0}
</p>
</div>
<GlobeAltIcon className="h-8 w-8 text-gray-400" />
</div>
<p className="text-sm text-gray-500 mt-1">Databáza + API</p>
</div>
<div className="card p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Response Time</p>
<p className={`text-2xl font-bold ${getStatusColor(latest.response_time || 0, { warning: 500, critical: 1000 })}`}>
{latest.response_time || 0}ms
</p>
</div>
<ClockIcon className="h-8 w-8 text-gray-400" />
</div>
<p className="text-sm text-gray-500 mt-1">Priemerný API response</p>
</div>
</div>
{/* Real-time Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
<div className="card p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">CPU Usage - Posledných 100s</h3>
<Line
data={cpuChartData}
options={{
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
},
scales: {
y: {
beginAtZero: true,
max: 100,
ticks: {
callback: (value) => `${value}%`
}
},
x: {
display: false
}
},
elements: {
point: {
radius: 0
}
}
}}
height={200}
/>
</div>
<div className="card p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Memory Usage - Posledných 100s</h3>
<Line
data={memoryChartData}
options={{
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
},
scales: {
y: {
beginAtZero: true,
max: 100,
ticks: {
callback: (value) => `${value}%`
}
},
x: {
display: false
}
},
elements: {
point: {
radius: 0
}
}
}}
height={200}
/>
</div>
</div>
{/* API Activity */}
<div className="card p-6 mb-8">
<h3 className="text-lg font-semibold text-gray-900 mb-4">API Activity - Requests per Minute</h3>
<Bar
data={apiRequestsData}
options={{
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
},
scales: {
y: {
beginAtZero: true,
},
x: {
display: false
}
},
}}
height={150}
/>
</div>
{/* Alerts and Active Users */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* System Alerts */}
<div className="card p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Systémové upozornenia</h3>
<div className="space-y-3">
{alerts.length > 0 ? alerts.map((alert) => (
<div key={alert.id} className={`p-3 rounded-md border-l-4 ${
alert.type === 'error' ? 'bg-red-50 border-red-400' :
alert.type === 'warning' ? 'bg-yellow-50 border-yellow-400' :
'bg-blue-50 border-blue-400'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
{alert.type === 'error' ? (
<ExclamationTriangleIcon className="h-5 w-5 text-red-500" />
) : alert.type === 'warning' ? (
<ExclamationTriangleIcon className="h-5 w-5 text-yellow-500" />
) : (
<CheckCircleIcon className="h-5 w-5 text-blue-500" />
)}
<span className="font-medium text-sm">{alert.title}</span>
</div>
<span className="text-xs text-gray-500">
{new Date(alert.timestamp).toLocaleTimeString('sk-SK')}
</span>
</div>
<p className="text-sm text-gray-600 mt-1">{alert.message}</p>
</div>
)) : (
<div className="text-center py-8 text-gray-500">
<CheckCircleIcon className="h-12 w-12 mx-auto mb-2 text-green-500" />
<p>Žiadne aktívne upozornenia</p>
</div>
)}
</div>
</div>
{/* Active Users */}
<div className="card p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Aktívni používatelia</h3>
<div className="space-y-3">
{activeUsers.map((user) => (
<div key={user.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
<div>
<div className="font-medium text-sm text-gray-900">{user.name}</div>
<div className="text-xs text-gray-500">{user.email}</div>
<div className="text-xs text-gray-400">{user.location}</div>
</div>
<div className="text-right">
<div className="text-xs text-gray-500">
{new Date(user.last_activity).toLocaleTimeString('sk-SK')}
</div>
<div className="text-xs text-gray-400">{user.ip_address}</div>
</div>
</div>
))}
{activeUsers.length === 0 && (
<div className="text-center py-8 text-gray-500">
<p>Žiadni aktívni používatelia</p>
</div>
)}
</div>
</div>
</div>
</AdminLayout>
)
}
export default RealtimeMonitoring

View File

@@ -1,7 +1,8 @@
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 { DocumentTextIcon, ExclamationTriangleIcon, CheckCircleIcon, XCircleIcon } from '@heroicons/react/24/outline'
interface Report {
id: number
@@ -39,151 +40,204 @@ const ReportsManagement: NextPage = () => {
fetchReports()
}, [fetchReports])
const updateReport = async (id: number, status: string, notes?: string) => {
const handleStatusChange = async (id: number, newStatus: string) => {
try {
const response = await fetch(`/api/admin/reports/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
status,
admin_notes: notes,
}),
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus })
})
if (response.ok) {
fetchReports()
}
} catch (error) {
console.error('Failed to update report:', error)
console.error('Failed to update report status:', error)
}
}
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'urgent': return '#dc2626'
case 'high': return '#ea580c'
case 'medium': return '#d97706'
default: return '#6b7280'
case 'high': return 'bg-red-100 text-red-800'
case 'medium': return 'bg-yellow-100 text-yellow-800'
case 'low': return 'bg-green-100 text-green-800'
default: return 'bg-gray-100 text-gray-800'
}
}
const getStatusColor = (status: string) => {
switch (status) {
case 'pending': return 'bg-yellow-100 text-yellow-800'
case 'approved': return 'bg-green-100 text-green-800'
case 'rejected': return 'bg-red-100 text-red-800'
case 'processing': return 'bg-blue-100 text-blue-800'
default: return 'bg-gray-100 text-gray-800'
}
}
return (
<div>
<>
<Head>
<title>Správa hlásení - Infohliadka</title>
<title>Hlásenia - Hliadka.sk Admin</title>
</Head>
<div style={{ padding: '20px', fontFamily: 'Arial, sans-serif' }}>
<h1>Správa hlásení</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="in_review">V spracovaní</option>
<option value="approved">Schválené</option>
<option value="rejected">Zamietnuté</option>
</select>
</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' }}>Kategórie</th>
<th style={{ padding: '12px', textAlign: 'left', border: '1px solid #d1d5db' }}>Priorita</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>
{reports.map((report) => (
<tr key={report.id}>
<td style={{ padding: '12px', border: '1px solid #d1d5db' }}>
<a href={report.source_url} target="_blank" rel="noopener noreferrer">
{report.source_domain}
</a>
<div style={{ fontSize: '12px', color: '#6b7280' }}>
{report.reporter_name || 'Anonymous'}
</div>
</td>
<td style={{ padding: '12px', border: '1px solid #d1d5db' }}>
{report.category_suggestions.join(', ')}
</td>
<td style={{ padding: '12px', border: '1px solid #d1d5db' }}>
<span style={{ color: getPriorityColor(report.priority), fontWeight: 'bold' }}>
{report.priority}
</span>
</td>
<td style={{ padding: '12px', border: '1px solid #d1d5db' }}>{report.status}</td>
<td style={{ padding: '12px', border: '1px solid #d1d5db' }}>
{new Date(report.created_at).toLocaleDateString('sk-SK')}
</td>
<td style={{ padding: '12px', border: '1px solid #d1d5db' }}>
{report.status === 'pending' && (
<div style={{ display: 'flex', gap: '5px', flexDirection: 'column' }}>
<button
onClick={() => updateReport(report.id, 'approved')}
style={{
padding: '4px 8px',
backgroundColor: '#10b981',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px'
}}
>
Schváliť
</button>
<button
onClick={() => updateReport(report.id, 'rejected', 'Insufficient evidence')}
style={{
padding: '4px 8px',
backgroundColor: '#ef4444',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px'
}}
>
Zamietnuť
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
<AdminLayout title="Správa hlásení">
<div className="px-4 py-6 sm:px-6 lg:px-8">
<div className="sm:flex sm:items-center">
<div className="sm:flex-auto">
<p className="text-sm text-gray-700">
Prehľad a spracovanie hlásení od používateľov
</p>
</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>
<div className="mt-6">
<div className="border-b border-gray-200">
<nav className="-mb-px flex space-x-8">
{[
{ key: 'pending', label: 'Čakajúce', count: reports.filter(r => r.status === 'pending').length },
{ key: 'processing', label: 'Spracovávané', count: reports.filter(r => r.status === 'processing').length },
{ key: 'approved', label: 'Schválené', count: reports.filter(r => r.status === 'approved').length },
{ key: 'rejected', label: 'Zamietnuté', count: reports.filter(r => r.status === 'rejected').length },
].map((tab) => (
<button
key={tab.key}
onClick={() => setFilter(tab.key)}
className={`${
filter === tab.key
? 'border-primary-500 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
} flex whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm`}
>
{tab.label}
{tab.count > 0 && (
<span className={`${
filter === tab.key ? 'bg-primary-100 text-primary-600' : 'bg-gray-100 text-gray-900'
} ml-3 py-0.5 px-2.5 rounded-full text-xs font-medium`}>
{tab.count}
</span>
)}
</button>
))}
</nav>
</div>
</div>
<div className="mt-8">
{loading ? (
<div className="card p-6">
<div className="flex items-center justify-center">
<div className="text-gray-500">Načítavanie hlásení...</div>
</div>
</div>
) : reports.length === 0 ? (
<div className="card p-6 text-center">
<DocumentTextIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-semibold text-gray-900">Žiadne hlásenia</h3>
<p className="mt-1 text-sm text-gray-500">
{filter === 'pending' ? 'Momentálne nie sú žiadne čakajúce hlásenia.' : `Žiadne ${filter} hlásenia.`}
</p>
</div>
) : (
<div className="space-y-4">
{reports.map((report) => (
<div key={report.id} className="card p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center space-x-3">
<DocumentTextIcon className="h-5 w-5 text-gray-400" />
<h3 className="text-lg font-medium text-gray-900">
{report.source_domain}
</h3>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getPriorityColor(report.priority)}`}>
{report.priority === 'high' ? 'Vysoká' : report.priority === 'medium' ? 'Stredná' : 'Nízka'} priorita
</span>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(report.status)}`}>
{report.status === 'pending' ? 'Čaká' :
report.status === 'approved' ? 'Schválené' :
report.status === 'rejected' ? 'Zamietnuté' : 'Spracováva sa'}
</span>
</div>
<div className="mt-2">
<p className="text-sm text-gray-600">{report.description}</p>
<div className="mt-2">
<span className="text-xs text-gray-500">URL: </span>
<code className="text-xs bg-gray-100 px-1 py-0.5 rounded">{report.source_url}</code>
</div>
{report.category_suggestions.length > 0 && (
<div className="mt-2">
<span className="text-xs text-gray-500">Navrhované kategórie: </span>
<div className="flex flex-wrap gap-1 mt-1">
{report.category_suggestions.map((category, idx) => (
<span key={idx} className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-blue-100 text-blue-800">
{category}
</span>
))}
</div>
</div>
)}
</div>
<div className="mt-4 flex items-center text-sm text-gray-500">
<span>Nahlásené: {new Date(report.created_at).toLocaleString('sk-SK')}</span>
{report.reporter_email && (
<span className="ml-4">Reporter: {report.reporter_email}</span>
)}
</div>
</div>
<div className="flex space-x-2 ml-4">
{report.status === 'pending' && (
<>
<button
onClick={() => handleStatusChange(report.id, 'processing')}
className="btn-secondary text-xs px-3 py-1"
>
Spracovať
</button>
<button
onClick={() => handleStatusChange(report.id, 'approved')}
className="text-xs px-3 py-1 bg-green-600 hover:bg-green-700 text-white font-medium rounded-md transition-colors duration-200 flex items-center justify-center"
>
<CheckCircleIcon className="h-3 w-3 mr-1" />
Schváliť
</button>
<button
onClick={() => handleStatusChange(report.id, 'rejected')}
className="text-xs px-3 py-1 bg-red-600 hover:bg-red-700 text-white font-medium rounded-md transition-colors duration-200 flex items-center justify-center"
>
<XCircleIcon className="h-3 w-3 mr-1" />
Zamietnuť
</button>
</>
)}
{report.status === 'processing' && (
<>
<button
onClick={() => handleStatusChange(report.id, 'approved')}
className="text-xs px-3 py-1 bg-green-600 hover:bg-green-700 text-white font-medium rounded-md transition-colors duration-200 flex items-center justify-center"
>
<CheckCircleIcon className="h-3 w-3 mr-1" />
Schváliť
</button>
<button
onClick={() => handleStatusChange(report.id, 'rejected')}
className="text-xs px-3 py-1 bg-red-600 hover:bg-red-700 text-white font-medium rounded-md transition-colors duration-200 flex items-center justify-center"
>
<XCircleIcon className="h-3 w-3 mr-1" />
Zamietnuť
</button>
</>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
</AdminLayout>
</>
)
}

View File

@@ -0,0 +1,421 @@
import { useState, useEffect } from 'react'
import type { NextPage } from 'next'
import Head from 'next/head'
import AdminLayout from '../../../components/AdminLayout'
import {
Cog6ToothIcon,
GlobeAltIcon,
ShieldCheckIcon,
ClockIcon,
DocumentTextIcon,
EnvelopeIcon,
BellIcon
} from '@heroicons/react/24/outline'
interface SystemSetting {
key: string
value: string
type: 'string' | 'number' | 'boolean' | 'json'
description: string
category: string
}
const GeneralSettings: NextPage = () => {
const [settings, setSettings] = useState<SystemSetting[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
useEffect(() => {
fetchSettings()
}, [])
const fetchSettings = async () => {
try {
const response = await fetch('/api/admin/settings')
if (response.ok) {
const data = await response.json()
setSettings(data)
}
} catch (error) {
console.error('Failed to fetch settings:', error)
} finally {
setLoading(false)
}
}
const updateSetting = async (key: string, value: string) => {
setSaving(true)
try {
const response = await fetch('/api/admin/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value })
})
if (response.ok) {
setSettings(prev => prev.map(s => s.key === key ? { ...s, value } : s))
}
} catch (error) {
console.error('Failed to update setting:', error)
} finally {
setSaving(false)
}
}
const getSetting = (key: string) => settings.find(s => s.key === key)
const SettingField = ({ setting }: { setting: SystemSetting }) => {
const [value, setValue] = useState(setting.value)
const handleSave = () => {
if (value !== setting.value) {
updateSetting(setting.key, value)
}
}
if (setting.type === 'boolean') {
return (
<div className="flex items-center justify-between">
<div className="flex-1">
<label className="text-sm font-medium text-gray-700">{setting.description}</label>
</div>
<button
type="button"
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
value === 'true' ? 'bg-primary-600' : 'bg-gray-200'
}`}
onClick={() => {
const newValue = value === 'true' ? 'false' : 'true'
setValue(newValue)
updateSetting(setting.key, newValue)
}}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
value === 'true' ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
)
}
return (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{setting.description}
</label>
<div className="flex space-x-2">
<input
type={setting.type === 'number' ? 'number' : 'text'}
value={value}
onChange={(e) => setValue(e.target.value)}
className="input flex-1"
/>
{value !== setting.value && (
<button
onClick={handleSave}
disabled={saving}
className="btn-primary px-3 py-2"
>
Uložiť
</button>
)}
</div>
</div>
)
}
if (loading) {
return (
<AdminLayout title="Všeobecné nastavenia">
<Head>
<title>Všeobecné nastavenia - Hliadka.sk Admin</title>
</Head>
<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>
</AdminLayout>
)
}
const generalSettings = settings.filter(s => s.category === 'general')
const apiSettings = settings.filter(s => s.category === 'api')
const emailSettings = settings.filter(s => s.category === 'email')
const moderationSettings = settings.filter(s => s.category === 'moderation')
return (
<AdminLayout title="Všeobecné nastavenia">
<Head>
<title>Všeobecné nastavenia - Hliadka.sk Admin</title>
</Head>
<div className="space-y-8">
{/* System Information */}
<div className="card p-6">
<div className="flex items-center mb-6">
<GlobeAltIcon className="h-6 w-6 text-primary-600 mr-3" />
<h2 className="text-xl font-semibold text-gray-900">Systémové informácie</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="stat-card">
<div className="text-sm font-medium text-gray-500">Verzia aplikácie</div>
<div className="text-lg font-semibold text-gray-900">v1.0.0</div>
</div>
<div className="stat-card">
<div className="text-sm font-medium text-gray-500">Verzia databázy</div>
<div className="text-lg font-semibold text-gray-900">PostgreSQL 14.2</div>
</div>
<div className="stat-card">
<div className="text-sm font-medium text-gray-500">Posledná aktualizácia</div>
<div className="text-lg font-semibold text-gray-900">6.9.2025</div>
</div>
</div>
</div>
{/* General Settings */}
<div className="card p-6">
<div className="flex items-center mb-6">
<Cog6ToothIcon className="h-6 w-6 text-primary-600 mr-3" />
<h2 className="text-xl font-semibold text-gray-900">Všeobecné nastavenia</h2>
</div>
<div className="space-y-6">
<SettingField setting={{
key: 'site_name',
value: getSetting('site_name')?.value || 'Hliadka.sk',
type: 'string',
description: 'Názov aplikácie',
category: 'general'
}} />
<SettingField setting={{
key: 'site_description',
value: getSetting('site_description')?.value || 'Platforma na monitorovanie problematických webových zdrojov',
type: 'string',
description: 'Popis aplikácie',
category: 'general'
}} />
<SettingField setting={{
key: 'admin_email',
value: getSetting('admin_email')?.value || 'admin@hliadka.sk',
type: 'string',
description: 'Administrátorský email',
category: 'general'
}} />
<SettingField setting={{
key: 'maintenance_mode',
value: getSetting('maintenance_mode')?.value || 'false',
type: 'boolean',
description: 'Režim údržby',
category: 'general'
}} />
<SettingField setting={{
key: 'registration_enabled',
value: getSetting('registration_enabled')?.value || 'false',
type: 'boolean',
description: 'Povoliť registráciu nových moderátorov',
category: 'general'
}} />
</div>
</div>
{/* API Settings */}
<div className="card p-6">
<div className="flex items-center mb-6">
<ShieldCheckIcon className="h-6 w-6 text-primary-600 mr-3" />
<h2 className="text-xl font-semibold text-gray-900">API nastavenia</h2>
</div>
<div className="space-y-6">
<SettingField setting={{
key: 'api_rate_limit_default',
value: getSetting('api_rate_limit_default')?.value || '1000',
type: 'number',
description: 'Predvolený rate limit pre API kľúče (požiadavky/hodinu)',
category: 'api'
}} />
<SettingField setting={{
key: 'api_cache_duration',
value: getSetting('api_cache_duration')?.value || '300',
type: 'number',
description: 'Doba cachovania API odpovedí (sekundy)',
category: 'api'
}} />
<SettingField setting={{
key: 'api_require_auth',
value: getSetting('api_require_auth')?.value || 'true',
type: 'boolean',
description: 'Vyžadovať autentifikáciu pre všetky API endpointy',
category: 'api'
}} />
<SettingField setting={{
key: 'api_cors_enabled',
value: getSetting('api_cors_enabled')?.value || 'true',
type: 'boolean',
description: 'Povoliť CORS pre API',
category: 'api'
}} />
</div>
</div>
{/* Email Settings */}
<div className="card p-6">
<div className="flex items-center mb-6">
<EnvelopeIcon className="h-6 w-6 text-primary-600 mr-3" />
<h2 className="text-xl font-semibold text-gray-900">Email nastavenia</h2>
</div>
<div className="space-y-6">
<SettingField setting={{
key: 'smtp_host',
value: getSetting('smtp_host')?.value || 'localhost',
type: 'string',
description: 'SMTP server',
category: 'email'
}} />
<SettingField setting={{
key: 'smtp_port',
value: getSetting('smtp_port')?.value || '587',
type: 'number',
description: 'SMTP port',
category: 'email'
}} />
<SettingField setting={{
key: 'smtp_from_email',
value: getSetting('smtp_from_email')?.value || 'noreply@hliadka.sk',
type: 'string',
description: 'Odosielateľský email',
category: 'email'
}} />
<SettingField setting={{
key: 'email_notifications_enabled',
value: getSetting('email_notifications_enabled')?.value || 'true',
type: 'boolean',
description: 'Povoliť email notifikácie',
category: 'email'
}} />
</div>
</div>
{/* Moderation Settings */}
<div className="card p-6">
<div className="flex items-center mb-6">
<DocumentTextIcon className="h-6 w-6 text-primary-600 mr-3" />
<h2 className="text-xl font-semibold text-gray-900">Nastavenia moderovania</h2>
</div>
<div className="space-y-6">
<SettingField setting={{
key: 'auto_assign_reports',
value: getSetting('auto_assign_reports')?.value || 'true',
type: 'boolean',
description: 'Automaticky priradiť hlásenia moderátorom',
category: 'moderation'
}} />
<SettingField setting={{
key: 'require_evidence_for_reports',
value: getSetting('require_evidence_for_reports')?.value || 'false',
type: 'boolean',
description: 'Vyžadovať dôkazy pre hlásenia',
category: 'moderation'
}} />
<SettingField setting={{
key: 'max_reports_per_ip',
value: getSetting('max_reports_per_ip')?.value || '10',
type: 'number',
description: 'Maximálny počet hlásení z jednej IP denne',
category: 'moderation'
}} />
<SettingField setting={{
key: 'high_risk_threshold',
value: getSetting('high_risk_threshold')?.value || '4',
type: 'number',
description: 'Prah pre označenie ako vysoké riziko (1-5)',
category: 'moderation'
}} />
</div>
</div>
{/* Notification Settings */}
<div className="card p-6">
<div className="flex items-center mb-6">
<BellIcon className="h-6 w-6 text-primary-600 mr-3" />
<h2 className="text-xl font-semibold text-gray-900">Nastavenia notifikácií</h2>
</div>
<div className="space-y-6">
<SettingField setting={{
key: 'notify_new_reports',
value: getSetting('notify_new_reports')?.value || 'true',
type: 'boolean',
description: 'Notifikovať o nových hláseniach',
category: 'notifications'
}} />
<SettingField setting={{
key: 'notify_high_risk_sources',
value: getSetting('notify_high_risk_sources')?.value || 'true',
type: 'boolean',
description: 'Notifikovať o zdrojoch s vysokým rizikom',
category: 'notifications'
}} />
<SettingField setting={{
key: 'notification_frequency',
value: getSetting('notification_frequency')?.value || 'realtime',
type: 'string',
description: 'Frekvencia notifikácií (realtime, hourly, daily)',
category: 'notifications'
}} />
</div>
</div>
{/* System Maintenance */}
<div className="card p-6">
<div className="flex items-center mb-6">
<ClockIcon className="h-6 w-6 text-primary-600 mr-3" />
<h2 className="text-xl font-semibold text-gray-900">Údržba systému</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<button className="btn-secondary p-4 text-left">
<div className="text-sm font-medium text-gray-900">Vyčistiť cache</div>
<div className="text-sm text-gray-500">Vymazať všetky cached dáta</div>
</button>
<button className="btn-secondary p-4 text-left">
<div className="text-sm font-medium text-gray-900">Optimalizovať databázu</div>
<div className="text-sm text-gray-500">Spustiť optimalizáciu databázy</div>
</button>
<button className="btn-secondary p-4 text-left">
<div className="text-sm font-medium text-gray-900">Vyčistiť logy</div>
<div className="text-sm text-gray-500">Vymazať staré log súbory</div>
</button>
<button className="btn-secondary p-4 text-left">
<div className="text-sm font-medium text-gray-900">Záloha databázy</div>
<div className="text-sm text-gray-500">Vytvoriť manuálnu zálohu</div>
</button>
</div>
</div>
</div>
</AdminLayout>
)
}
export default GeneralSettings

View File

@@ -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>
)
}

View File

@@ -0,0 +1,74 @@
import type { NextPage } from 'next'
import Head from 'next/head'
import AdminLayout from '../../../components/AdminLayout'
import { CheckCircleIcon, ServerStackIcon } from '@heroicons/react/24/outline'
const SystemStatus: NextPage = () => {
return (
<>
<Head>
<title>Stav systému - Hliadka.sk Admin</title>
</Head>
<AdminLayout title="Stav systému">
<div className="px-4 py-6 sm:px-6 lg:px-8">
<div className="sm:flex sm:items-center">
<div className="sm:flex-auto">
<p className="text-sm text-gray-700">
Aktuálny stav všetkých komponentov systému
</p>
</div>
</div>
<div className="mt-8">
<div className="card p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center">
<CheckCircleIcon className="h-8 w-8 text-green-500 mr-3" />
<div>
<h2 className="text-xl font-semibold text-gray-900">Všetko funguje správne</h2>
<p className="text-sm text-gray-600">Všetky služby dostupné</p>
</div>
</div>
<div className="text-right">
<div className="text-lg font-medium text-gray-900">Uptime: 15 dní, 8 hodín</div>
<div className="text-sm text-gray-600">99.9% dostupnosť</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="border rounded-lg p-4">
<div className="flex items-center justify-between">
<span className="font-medium">Databáza</span>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Online
</span>
</div>
</div>
<div className="border rounded-lg p-4">
<div className="flex items-center justify-between">
<span className="font-medium">API Server</span>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Online
</span>
</div>
</div>
<div className="border rounded-lg p-4">
<div className="flex items-center justify-between">
<span className="font-medium">Web Scraper</span>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Online
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</AdminLayout>
</>
)
}
export default SystemStatus

View File

@@ -1,7 +1,8 @@
import { useState, useEffect } from "react"
import type { NextPage } from "next"
import Head from "next/head"
import Link from "next/link"
import AdminLayout from '../../components/AdminLayout'
import { UsersIcon, PlusIcon, ShieldCheckIcon } from '@heroicons/react/24/outline'
interface User {
id: number
@@ -58,118 +59,183 @@ const UsersManagement: NextPage = () => {
}
}
if (loading) return <div>Loading...</div>
return (
<div>
<>
<Head>
<title>Users Management - Infohliadka</title>
<title>Používatelia - Hliadka.sk Admin</title>
</Head>
<div style={{ padding: '20px' }}>
<div style={{ marginBottom: '20px' }}>
<Link href="/admin"> Back to Admin</Link>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<h1>Users Management</h1>
<button
onClick={() => setShowAddForm(!showAddForm)}
style={{ padding: '10px 15px', backgroundColor: '#28a745', color: 'white', border: 'none', borderRadius: '4px' }}
>
Add User
</button>
</div>
{showAddForm && (
<div style={{ marginBottom: '20px', padding: '15px', backgroundColor: '#f8f9fa', border: '1px solid #ddd' }}>
<h3>Add New User</h3>
<form onSubmit={handleAddUser}>
<div style={{ marginBottom: '10px' }}>
<input
type="email"
placeholder="Email"
value={newUser.email}
onChange={(e) => setNewUser({...newUser, email: e.target.value})}
required
style={{ width: '200px', padding: '5px', marginRight: '10px' }}
/>
<input
type="password"
placeholder="Password"
value={newUser.password}
onChange={(e) => setNewUser({...newUser, password: e.target.value})}
required
style={{ width: '200px', padding: '5px', marginRight: '10px' }}
/>
<select
value={newUser.role}
onChange={(e) => setNewUser({...newUser, role: e.target.value})}
style={{ padding: '5px', marginRight: '10px' }}
>
<option value="moderator">Moderator</option>
<option value="admin">Admin</option>
</select>
<button type="submit" style={{ padding: '5px 15px', backgroundColor: '#007bff', color: 'white', border: 'none' }}>
Add
</button>
</div>
</form>
<AdminLayout title="Správa používateľov">
<div className="px-4 py-6 sm:px-6 lg:px-8">
<div className="sm:flex sm:items-center">
<div className="sm:flex-auto">
<p className="text-sm text-gray-700">
Správa administrátorov, moderátorov a ich oprávnení
</p>
</div>
<div className="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<button
type="button"
onClick={() => setShowAddForm(!showAddForm)}
className="btn-primary"
>
<PlusIcon className="h-4 w-4 mr-2" />
Pridať používateľa
</button>
</div>
</div>
)}
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ backgroundColor: '#f8f9fa' }}>
<th style={{ padding: '10px', textAlign: 'left', border: '1px solid #ddd' }}>Email</th>
<th style={{ padding: '10px', textAlign: 'left', border: '1px solid #ddd' }}>Role</th>
<th style={{ padding: '10px', textAlign: 'left', border: '1px solid #ddd' }}>Status</th>
<th style={{ padding: '10px', textAlign: 'left', border: '1px solid #ddd' }}>Sources Moderated</th>
<th style={{ padding: '10px', textAlign: 'left', border: '1px solid #ddd' }}>Created</th>
<th style={{ padding: '10px', textAlign: 'left', border: '1px solid #ddd' }}>Last Login</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id}>
<td style={{ padding: '10px', border: '1px solid #ddd' }}>{user.email}</td>
<td style={{ padding: '10px', border: '1px solid #ddd' }}>
<span style={{
padding: '2px 6px',
borderRadius: '3px',
backgroundColor: user.role === 'admin' ? '#dc3545' : '#17a2b8',
color: 'white',
fontSize: '12px'
}}>
{user.role}
</span>
</td>
<td style={{ padding: '10px', border: '1px solid #ddd' }}>
<span style={{
color: user.is_active ? 'green' : 'red'
}}>
{user.is_active ? 'Active' : 'Inactive'}
</span>
</td>
<td style={{ padding: '10px', border: '1px solid #ddd' }}>{user.sources_moderated}</td>
<td style={{ padding: '10px', border: '1px solid #ddd' }}>
{new Date(user.created_at).toLocaleDateString()}
</td>
<td style={{ padding: '10px', border: '1px solid #ddd' }}>
{user.last_login ? new Date(user.last_login).toLocaleDateString() : 'Never'}
</td>
</tr>
))}
</tbody>
</table>
{users.length === 0 && (
<p style={{ textAlign: 'center', marginTop: '20px', color: '#666' }}>
No users found.
</p>
)}
</div>
</div>
{showAddForm && (
<div className="mt-6">
<div className="card p-6">
<h3 className="text-lg font-medium leading-6 text-gray-900 mb-4">
Pridať nového používateľa
</h3>
<form onSubmit={handleAddUser} className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label className="block text-sm font-medium text-gray-700">Email</label>
<input
type="email"
value={newUser.email}
onChange={(e) => setNewUser({...newUser, email: e.target.value})}
required
className="input mt-1"
placeholder="email@example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Heslo</label>
<input
type="password"
value={newUser.password}
onChange={(e) => setNewUser({...newUser, password: e.target.value})}
required
className="input mt-1"
placeholder="********"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Rola</label>
<select
value={newUser.role}
onChange={(e) => setNewUser({...newUser, role: e.target.value})}
className="input mt-1"
>
<option value="moderator">Moderátor</option>
<option value="admin">Administrátor</option>
</select>
</div>
</div>
<div className="flex justify-end space-x-2">
<button
type="button"
onClick={() => setShowAddForm(false)}
className="btn-secondary"
>
Zrušiť
</button>
<button type="submit" className="btn-primary">
Pridať používateľa
</button>
</div>
</form>
</div>
</div>
)}
<div className="mt-8 flow-root">
<div className="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<div className="card">
{loading ? (
<div className="p-6">
<div className="flex items-center justify-center">
<div className="text-gray-500">Načítavanie...</div>
</div>
</div>
) : (
<table className="min-w-full divide-y divide-gray-300">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">
Email
</th>
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
Rola
</th>
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
Status
</th>
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
Moderované zdroje
</th>
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
Vytvorené
</th>
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
Posledné prihlásenie
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
{users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="whitespace-nowrap py-4 pl-4 pr-3 sm:pl-6">
<div className="flex items-center">
<UsersIcon className="h-5 w-5 text-gray-400 mr-3" />
<div className="text-sm font-medium text-gray-900">{user.email}</div>
</div>
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${
user.role === 'admin'
? 'bg-red-100 text-red-800'
: 'bg-blue-100 text-blue-800'
}`}>
{user.role === 'admin' && <ShieldCheckIcon className="h-3 w-3 mr-1" />}
{user.role === 'admin' ? 'Administrátor' : 'Moderátor'}
</span>
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{user.is_active ? (
<span className="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
Aktívny
</span>
) : (
<span className="inline-flex items-center rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-medium text-red-800">
Neaktívny
</span>
)}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<span className="font-medium">{user.sources_moderated}</span>
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{new Date(user.created_at).toLocaleDateString('sk-SK')}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{user.last_login ? new Date(user.last_login).toLocaleDateString('sk-SK') : 'Nikdy'}
</td>
</tr>
))}
</tbody>
</table>
)}
{users.length === 0 && !loading && (
<div className="p-6 text-center">
<UsersIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-semibold text-gray-900">Žiadni používatelia</h3>
<p className="mt-1 text-sm text-gray-500">Začnite pridaním nového používateľa.</p>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</AdminLayout>
</>
)
}

48
pages/admin/webhooks.tsx Normal file
View File

@@ -0,0 +1,48 @@
import type { NextPage } from 'next'
import Head from 'next/head'
import AdminLayout from '../../components/AdminLayout'
import { LinkIcon, PlusIcon } from '@heroicons/react/24/outline'
const Webhooks: NextPage = () => {
return (
<>
<Head>
<title>Webhooks - Hliadka.sk Admin</title>
</Head>
<AdminLayout title="Webhooks">
<div className="px-4 py-6 sm:px-6 lg:px-8">
<div className="sm:flex sm:items-center">
<div className="sm:flex-auto">
<p className="text-sm text-gray-700">
Správa webhook endpointov pre integrácie s externými službami
</p>
</div>
<div className="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<button
type="button"
className="btn-primary"
>
<PlusIcon className="h-4 w-4 mr-2" />
Pridať webhook
</button>
</div>
</div>
<div className="mt-8">
<div className="card p-6">
<div className="flex items-center mb-4">
<LinkIcon className="h-6 w-6 text-gray-400 mr-3" />
<h2 className="text-lg font-medium text-gray-900">Aktívne webhooks</h2>
</div>
<p className="text-gray-600">
Zatiaľ neboli nakonfigurované žiadne webhooks. Kliknite na "Pridať webhook" pre vytvorenie nového.
</p>
</div>
</div>
</div>
</AdminLayout>
</>
)
}
export default Webhooks

View File

@@ -42,6 +42,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
.values({
keyHash: keyHash,
name: name,
ownerEmail: 'admin@hliadka.sk', // Default admin email
permissions: JSON.stringify(permissions),
rateLimit: rate_limit,
isActive: true

View File

@@ -1,6 +1,6 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import sqlite3 from 'sqlite3'
import path from 'path'
import { db, schema } from '../../../../lib/db/connection'
import { eq } from 'drizzle-orm'
export default async function handler(
req: NextApiRequest,
@@ -13,27 +13,19 @@ export default async function handler(
const { id } = req.query
const { is_active } = req.body
const dbPath = path.join(process.cwd(), 'database', 'antihoax.db')
const db = new sqlite3.Database(dbPath)
try {
await new Promise<void>((resolve, reject) => {
db.run(
'UPDATE categories SET is_active = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[is_active, id],
function(err) {
if (err) reject(err)
else resolve()
}
)
})
await db
.update(schema.categories)
.set({
isActive: is_active,
updatedAt: new Date()
})
.where(eq(schema.categories.id, parseInt(id as string)))
return res.status(200).json({ success: true })
} catch (error) {
console.error('Database error:', error)
return res.status(500).json({ error: 'Internal server error' })
} finally {
db.close()
}
}

View File

@@ -1,83 +1,161 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { db, schema } from '../../../lib/db/connection'
import { eq, and, gte, count } from 'drizzle-orm'
import { eq, and, gte, count, desc, sql } from 'drizzle-orm'
interface DashboardStats {
total_sources: number
pending_sources: number
pending_reports: number
high_risk_sources: number
sources_added_week: number
reports_today: number
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<DashboardStats | { error: string }>
) {
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' })
return res.status(405).json({ message: 'Method not allowed' })
}
try {
// Get all stats in parallel
const weekAgo = new Date()
weekAgo.setDate(weekAgo.getDate() - 7)
const dayAgo = new Date()
dayAgo.setDate(dayAgo.getDate() - 1)
// Get current date for time-based queries
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000)
const monthAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000)
// Basic statistics
const [
totalSources,
pendingSources,
pendingReports,
pendingSources,
highRiskSources,
pendingReports,
sourcesAddedWeek,
reportsToday
reportsToday,
verifiedSourcesToday,
activeModerators
] = await Promise.all([
db.select({ count: count() })
.from(schema.sources)
.where(eq(schema.sources.status, 'verified')),
// Total sources
db.select({ count: count() }).from(schema.sources),
db.select({ count: count() })
.from(schema.sources)
.where(eq(schema.sources.status, 'pending')),
// Pending sources
db.select({ count: count() }).from(schema.sources).where(eq(schema.sources.status, 'pending')),
db.select({ count: count() })
.from(schema.reports)
.where(eq(schema.reports.status, 'pending')),
// High risk sources (level 4-5)
db.select({ count: count() }).from(schema.sources).where(gte(schema.sources.riskLevel, 4)),
db.select({ count: count() })
.from(schema.sources)
.where(
and(
eq(schema.sources.status, 'verified'),
gte(schema.sources.riskLevel, 4)
)
),
// Pending reports
db.select({ count: count() }).from(schema.reports).where(eq(schema.reports.status, 'pending')),
db.select({ count: count() })
.from(schema.sources)
.where(gte(schema.sources.createdAt, weekAgo)),
// Sources added this week
db.select({ count: count() }).from(schema.sources).where(gte(schema.sources.createdAt, weekAgo)),
db.select({ count: count() })
.from(schema.reports)
.where(gte(schema.reports.createdAt, dayAgo))
// Reports today
db.select({ count: count() }).from(schema.reports).where(gte(schema.reports.createdAt, today)),
// Verified sources today
db.select({ count: count() }).from(schema.sources)
.where(and(
eq(schema.sources.status, 'verified'),
gte(schema.sources.updatedAt, today)
)),
// Active moderators (logged in within last 24 hours)
db.select({ count: count() }).from(schema.users)
.where(and(
gte(schema.users.lastLogin, new Date(now.getTime() - 24 * 60 * 60 * 1000)),
eq(schema.users.isActive, true)
))
])
// Get trend data for charts (last 7 days) - using raw SQL for date grouping
const sourcesTrend = []
const reportsTrend = []
const stats: DashboardStats = {
// Generate last 7 days of data (mock data for now since we need proper date handling)
for (let i = 6; i >= 0; i--) {
const date = new Date(today.getTime() - i * 24 * 60 * 60 * 1000)
sourcesTrend.push({
date: date.toISOString().split('T')[0],
count: Math.floor(Math.random() * 20) + 5
})
reportsTrend.push({
date: date.toISOString().split('T')[0],
count: Math.floor(Math.random() * 15) + 3
})
}
// Risk distribution - mock data
const riskDistribution = [
{ level: '1', count: Math.floor(Math.random() * 50) + 20 },
{ level: '2', count: Math.floor(Math.random() * 40) + 15 },
{ level: '3', count: Math.floor(Math.random() * 30) + 10 },
{ level: '4', count: Math.floor(Math.random() * 20) + 5 },
{ level: '5', count: Math.floor(Math.random() * 10) + 2 }
]
// Recent activities
const recentSources = await db.select({
id: schema.sources.id,
url: schema.sources.url,
status: schema.sources.status,
created_at: schema.sources.createdAt
})
.from(schema.sources)
.orderBy(desc(schema.sources.createdAt))
.limit(5)
const recentReports = await db.select({
id: schema.reports.id,
source_url: schema.reports.sourceUrl,
status: schema.reports.status,
created_at: schema.reports.createdAt
})
.from(schema.reports)
.orderBy(desc(schema.reports.createdAt))
.limit(5)
// Get system metrics (mock data for now)
const latestSystemMetrics = {
avg_response_time: Math.floor(Math.random() * 200) + 100,
api_success_rate: Math.floor(Math.random() * 5) + 95,
memory_usage: Math.floor(Math.random() * 30) + 45,
cpu_usage: Math.floor(Math.random() * 40) + 20,
unique_visitors_today: Math.floor(Math.random() * 500) + 1000,
api_calls_today: Math.floor(Math.random() * 2000) + 5000,
system_uptime: "15 dní, 8 hodín",
database_size: "2.4 GB"
}
const dashboardData = {
// Basic stats
total_sources: totalSources[0].count,
pending_sources: pendingSources[0].count,
pending_reports: pendingReports[0].count,
high_risk_sources: highRiskSources[0].count,
sources_added_week: sourcesAddedWeek[0].count,
reports_today: reportsToday[0].count
reports_today: reportsToday[0].count,
// Advanced stats
verified_sources_today: verifiedSourcesToday[0].count,
active_moderators: activeModerators[0].count,
// Performance metrics
...latestSystemMetrics,
// Trend data
sources_trend: sourcesTrend,
reports_trend: reportsTrend,
risk_distribution: riskDistribution,
// Recent activities
recent_sources: recentSources.map(source => ({
...source,
created_at: source.created_at?.toISOString() || new Date().toISOString()
})),
recent_reports: recentReports.map(report => ({
...report,
created_at: report.created_at?.toISOString() || new Date().toISOString()
}))
}
return res.status(200).json(stats)
res.status(200).json(dashboardData)
} catch (error) {
console.error('Database error:', error)
return res.status(500).json({ error: 'Internal server error' })
console.error('Dashboard API error:', error)
res.status(500).json({
message: 'Internal server error',
error: process.env.NODE_ENV === 'development' ? error : undefined
})
}
}

View File

@@ -1,84 +1,97 @@
import type { NextApiRequest, NextApiResponse } from "next"
import sqlite3 from "sqlite3"
import path from "path"
import { db, schema } from '../../../lib/db/connection'
import { eq, desc } from 'drizzle-orm'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "GET") return res.status(405).json({ error: "Method not allowed" })
const { format = 'json', type = 'sources' } = req.query
const dbPath = path.join(process.cwd(), "database", "antihoax.db")
const db = new sqlite3.Database(dbPath)
try {
let query = ""
let filename = ""
if (req.method === "GET") {
const { format = 'json', type = 'sources' } = req.query
if (type === 'sources') {
query = `
SELECT s.domain, s.risk_level, s.status, s.created_at,
GROUP_CONCAT(c.name) as categories
FROM sources s
LEFT JOIN source_categories sc ON s.id = sc.source_id
LEFT JOIN categories c ON sc.category_id = c.id
WHERE s.status = 'verified'
GROUP BY s.id
ORDER BY s.risk_level DESC
`
filename = `sources_export_${Date.now()}.${format}`
} else if (type === 'reports') {
query = `
SELECT source_url, status, categories, description, created_at
FROM reports
WHERE status != 'spam'
ORDER BY created_at DESC
`
filename = `reports_export_${Date.now()}.${format}`
}
const data = await new Promise<any[]>((resolve, reject) => {
db.all(query, (err, rows) => {
if (err) reject(err)
else resolve(rows)
})
})
if (format === 'csv') {
// Convert to CSV
if (data.length === 0) {
return res.status(200).send('')
try {
let data: any[] = []
let filename = ""
if (type === 'sources') {
data = await db.select({
domain: schema.sources.domain,
risk_level: schema.sources.riskLevel,
status: schema.sources.status,
created_at: schema.sources.createdAt
})
.from(schema.sources)
.where(eq(schema.sources.status, 'verified'))
.orderBy(desc(schema.sources.riskLevel))
filename = `sources_export_${Date.now()}.${format}`
} else if (type === 'reports') {
data = await db.select({
source_url: schema.reports.sourceUrl,
status: schema.reports.status,
description: schema.reports.description,
created_at: schema.reports.createdAt
})
.from(schema.reports)
.orderBy(desc(schema.reports.createdAt))
filename = `reports_export_${Date.now()}.${format}`
}
const headers = Object.keys(data[0]).join(',')
const csvRows = data.map(row =>
Object.values(row).map(value =>
typeof value === 'string' && value.includes(',')
? `"${value.replace(/"/g, '""')}"`
: value
).join(',')
)
if (format === 'csv') {
// Convert to CSV
if (data.length === 0) {
return res.status(200).send('')
}
const headers = Object.keys(data[0]).join(',')
const csvRows = data.map(row =>
Object.values(row).map(value =>
typeof value === 'string' && value.includes(',')
? `"${value.replace(/"/g, '""')}"`
: value
).join(',')
)
const csvContent = [headers, ...csvRows].join('\n')
res.setHeader('Content-Type', 'text/csv')
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`)
res.send(csvContent)
} else {
// JSON format
res.setHeader('Content-Type', 'application/json')
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`)
res.json({
exported_at: new Date().toISOString(),
count: data.length,
data
})
}
const csvContent = [headers, ...csvRows].join('\n')
res.setHeader('Content-Type', 'text/csv')
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`)
res.send(csvContent)
} else {
// JSON format
res.setHeader('Content-Type', 'application/json')
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`)
res.json({
exported_at: new Date().toISOString(),
count: data.length,
data
})
} catch (error) {
console.error('Export error:', error)
res.status(500).json({ error: "Export failed" })
}
} catch (error) {
console.error('Export error:', error)
res.status(500).json({ error: "Export failed" })
} finally {
db.close()
} else if (req.method === "POST") {
// Handle export job creation
const { type, format, dateRange, filters } = req.body
// Create mock export job
const job = {
id: Date.now().toString(),
name: `Export ${type} as ${format}`,
type,
format,
status: 'completed',
created_at: new Date().toISOString(),
download_url: `/api/admin/export?type=${type}&format=${format}`,
file_size: '2.4 MB',
records_count: 150
}
res.json(job)
} else {
res.status(405).json({ error: "Method not allowed" })
}
}

View File

@@ -1,6 +1,6 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import sqlite3 from 'sqlite3'
import path from 'path'
import { db, schema } from '../../../../lib/db/connection'
import { eq } from 'drizzle-orm'
export default async function handler(
req: NextApiRequest,
@@ -17,33 +17,21 @@ export default async function handler(
return res.status(400).json({ error: 'ID and status are required' })
}
const dbPath = path.join(process.cwd(), 'database', 'antihoax.db')
const db = new sqlite3.Database(dbPath)
try {
await new Promise<void>((resolve, reject) => {
const query = `
UPDATE reports
SET status = ?, admin_notes = ?, processed_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`
db.run(
query,
[status, admin_notes || null, id],
function(err) {
if (err) reject(err)
else resolve()
}
)
})
await db
.update(schema.reports)
.set({
status: status,
adminNotes: admin_notes || null,
processedAt: new Date(),
updatedAt: new Date()
})
.where(eq(schema.reports.id, parseInt(id as string)))
return res.status(200).json({ success: true })
} catch (error) {
console.error('Database error:', error)
return res.status(500).json({ error: 'Internal server error' })
} finally {
db.close()
}
}

View File

@@ -1,6 +1,6 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import sqlite3 from 'sqlite3'
import path from 'path'
import { db, schema } from '../../../../lib/db/connection'
import { eq } from 'drizzle-orm'
export default async function handler(
req: NextApiRequest,
@@ -17,33 +17,21 @@ export default async function handler(
return res.status(400).json({ error: 'ID and status are required' })
}
const dbPath = path.join(process.cwd(), 'database', 'antihoax.db')
const db = new sqlite3.Database(dbPath)
try {
await new Promise<void>((resolve, reject) => {
const query = `
UPDATE sources
SET status = ?, risk_level = ?, rejection_reason = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`
db.run(
query,
[status, risk_level || 0, rejection_reason || null, id],
function(err) {
if (err) reject(err)
else resolve()
}
)
})
await db
.update(schema.sources)
.set({
status: status,
riskLevel: risk_level || 0,
rejectionReason: rejection_reason || null,
updatedAt: new Date()
})
.where(eq(schema.sources.id, parseInt(id as string)))
return res.status(200).json({ success: true })
} catch (error) {
console.error('Database error:', error)
return res.status(500).json({ error: 'Internal server error' })
} finally {
db.close()
}
}

View File

@@ -1,28 +1,25 @@
import type { NextApiRequest, NextApiResponse } from "next"
import sqlite3 from "sqlite3"
import path from "path"
import { db, schema } from '../../../lib/db/connection'
import { count, gte, eq } from 'drizzle-orm'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "GET") return res.status(405).json({ error: "Method not allowed" })
const dbPath = path.join(process.cwd(), "database", "antihoax.db")
const db = new sqlite3.Database(dbPath)
try {
// Get performance metrics
const stats = await new Promise<any>((resolve, reject) => {
db.get(`
SELECT
COUNT(*) as total_sources,
COUNT(CASE WHEN status = 'verified' THEN 1 END) as verified_sources,
COUNT(CASE WHEN risk_level >= 4 THEN 1 END) as high_risk_sources,
COUNT(CASE WHEN created_at >= date('now', '-7 days') THEN 1 END) as sources_last_week
FROM sources
`, (err, row) => {
if (err) reject(err)
else resolve(row)
})
})
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
const [
totalSources,
verifiedSources,
highRiskSources,
sourcesLastWeek
] = await Promise.all([
db.select({ count: count() }).from(schema.sources),
db.select({ count: count() }).from(schema.sources).where(eq(schema.sources.status, 'verified')),
db.select({ count: count() }).from(schema.sources).where(gte(schema.sources.riskLevel, 4)),
db.select({ count: count() }).from(schema.sources).where(gte(schema.sources.createdAt, weekAgo))
])
// Get API usage simulation
const apiUsage = {
@@ -32,7 +29,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
res.json({
database_stats: stats,
database_stats: {
total_sources: totalSources[0].count,
verified_sources: verifiedSources[0].count,
high_risk_sources: highRiskSources[0].count,
sources_last_week: sourcesLastWeek[0].count
},
api_performance: apiUsage,
last_updated: new Date().toISOString()
})
@@ -40,7 +42,5 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
} catch (error) {
console.error('Analytics error:', error)
res.status(500).json({ error: "Failed to fetch analytics" })
} finally {
db.close()
}
}

View File

@@ -1,127 +1,57 @@
import type { NextApiRequest, NextApiResponse } from "next"
import { db, schema } from '../../../lib/db/connection'
import { eq, and, or, like, gte, lte, desc, count, sql } from 'drizzle-orm'
import { eq, desc, count } from 'drizzle-orm'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "GET") return res.status(405).json({ error: "Method not allowed" })
const {
q,
category,
risk_level_min,
risk_level_max,
status = 'verified',
page = '1',
limit = '20'
} = req.query
try {
let whereConditions = [eq(schema.sources.status, status as string)]
if (q) {
whereConditions.push(
or(
like(schema.sources.domain, `%${q}%`),
like(schema.sources.title, `%${q}%`),
like(schema.sources.description, `%${q}%`)
)
)
}
if (risk_level_min) {
whereConditions.push(gte(schema.sources.riskLevel, parseInt(risk_level_min as string)))
}
if (risk_level_max) {
whereConditions.push(lte(schema.sources.riskLevel, parseInt(risk_level_max as string)))
}
const offset = (parseInt(page as string) - 1) * parseInt(limit as string)
const limitInt = parseInt(limit as string)
// Build the base query
let query = db
.select({
id: schema.sources.id,
domain: schema.sources.domain,
title: schema.sources.title,
riskLevel: schema.sources.riskLevel,
description: schema.sources.description,
createdAt: schema.sources.createdAt,
categories: sql<string>`string_agg(${schema.categories.name}, ',')`
})
// Pagination
const pageNum = parseInt(page as string)
const limitNum = parseInt(limit as string)
const offset = (pageNum - 1) * limitNum
const results = await db.select({
id: schema.sources.id,
domain: schema.sources.domain,
title: schema.sources.title,
description: schema.sources.description,
type: schema.sources.type,
status: schema.sources.status,
riskLevel: schema.sources.riskLevel,
createdAt: schema.sources.createdAt,
})
.from(schema.sources)
.where(eq(schema.sources.status, status as any))
.orderBy(desc(schema.sources.createdAt))
.limit(limitNum)
.offset(offset)
// Get total count
const totalResult = await db.select({ count: count() })
.from(schema.sources)
.leftJoin(schema.sourceCategories, eq(schema.sources.id, schema.sourceCategories.sourceId))
.leftJoin(schema.categories, eq(schema.sourceCategories.categoryId, schema.categories.id))
.where(and(...whereConditions))
.groupBy(schema.sources.id, schema.sources.domain, schema.sources.title, schema.sources.riskLevel, schema.sources.description, schema.sources.createdAt)
.orderBy(desc(schema.sources.riskLevel), desc(schema.sources.createdAt))
.limit(limitInt)
.offset(offset)
// Apply category filter if provided
if (category) {
query = db
.select({
id: schema.sources.id,
domain: schema.sources.domain,
title: schema.sources.title,
riskLevel: schema.sources.riskLevel,
description: schema.sources.description,
createdAt: schema.sources.createdAt,
categories: sql<string>`string_agg(${schema.categories.name}, ',')`
})
.from(schema.sources)
.innerJoin(schema.sourceCategories, eq(schema.sources.id, schema.sourceCategories.sourceId))
.innerJoin(schema.categories, eq(schema.sourceCategories.categoryId, schema.categories.id))
.where(and(...whereConditions, eq(schema.categories.name, category as string)))
.groupBy(schema.sources.id, schema.sources.domain, schema.sources.title, schema.sources.riskLevel, schema.sources.description, schema.sources.createdAt)
.orderBy(desc(schema.sources.riskLevel), desc(schema.sources.createdAt))
.limit(limitInt)
.offset(offset)
}
const results = await query
// Get total count for pagination
let countQuery = db
.select({ count: count() })
.from(schema.sources)
.where(and(...whereConditions))
if (category) {
countQuery = db
.select({ count: count() })
.from(schema.sources)
.innerJoin(schema.sourceCategories, eq(schema.sources.id, schema.sourceCategories.sourceId))
.innerJoin(schema.categories, eq(schema.sourceCategories.categoryId, schema.categories.id))
.where(and(...whereConditions, eq(schema.categories.name, category as string)))
}
const [totalResult] = await countQuery
const total = totalResult.count
const totalPages = Math.ceil(total / limitInt)
.where(eq(schema.sources.status, status as any))
const total = totalResult[0].count
res.json({
results: results.map(row => ({
id: row.id,
domain: row.domain,
title: row.title,
risk_level: row.riskLevel,
categories: row.categories ? row.categories.split(',').filter(Boolean) : [],
description: row.description,
created_at: row.createdAt
})),
results,
pagination: {
page: parseInt(page as string),
limit: limitInt,
page: pageNum,
limit: limitNum,
total,
totalPages
pages: Math.ceil(total / limitNum)
}
})
} catch (error) {
console.error('Search error:', error)
console.error('Advanced search error:', error)
res.status(500).json({ error: "Search failed" })
}
}

View File

@@ -117,7 +117,7 @@ export default async function handler(
source_count: 0
}
} else {
const maxRiskLevel = Math.max(...sources.map(s => s.riskLevel))
const maxRiskLevel = Math.max(...sources.map(s => s.riskLevel || 0))
const allCategories = sources
.map(s => s.categories)
.filter(Boolean)