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:
8
.mcp.json
Normal file
8
.mcp.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"browsermcp": {
|
||||
"command": "npx",
|
||||
"args": ["@browsermcp/mcp@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
305
components/AdminLayout.tsx
Normal file
305
components/AdminLayout.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
HomeIcon,
|
||||
ShieldExclamationIcon,
|
||||
DocumentTextIcon,
|
||||
UsersIcon,
|
||||
Cog6ToothIcon,
|
||||
ChartBarIcon,
|
||||
KeyIcon,
|
||||
BellIcon,
|
||||
EyeIcon,
|
||||
ExclamationTriangleIcon,
|
||||
CloudArrowUpIcon,
|
||||
ServerStackIcon,
|
||||
CpuChipIcon,
|
||||
ClockIcon,
|
||||
CircleStackIcon,
|
||||
FolderIcon,
|
||||
TagIcon,
|
||||
DocumentDuplicateIcon,
|
||||
ArrowTrendingUpIcon,
|
||||
ShieldCheckIcon,
|
||||
GlobeAltIcon,
|
||||
MagnifyingGlassIcon,
|
||||
AdjustmentsHorizontalIcon,
|
||||
BugAntIcon
|
||||
} from '@heroicons/react/24/outline'
|
||||
|
||||
interface AdminLayoutProps {
|
||||
children: React.ReactNode
|
||||
title?: string
|
||||
}
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
name: 'Dashboard',
|
||||
href: '/admin',
|
||||
icon: HomeIcon,
|
||||
current: false,
|
||||
},
|
||||
{
|
||||
name: 'Správa zdrojov',
|
||||
icon: ShieldExclamationIcon,
|
||||
current: false,
|
||||
children: [
|
||||
{ name: 'Všetky zdroje', href: '/admin/sources' },
|
||||
{ name: 'Bulk import', href: '/admin/bulk-import' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Hlásenia',
|
||||
icon: DocumentTextIcon,
|
||||
current: false,
|
||||
children: [
|
||||
{ name: 'Všetky hlásenia', href: '/admin/reports' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Kategórie',
|
||||
href: '/admin/categories',
|
||||
icon: TagIcon,
|
||||
current: false,
|
||||
},
|
||||
{
|
||||
name: 'Používatelia',
|
||||
icon: UsersIcon,
|
||||
current: false,
|
||||
children: [
|
||||
{ name: 'Správa používateľov', href: '/admin/users' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'API & Integrácie',
|
||||
icon: KeyIcon,
|
||||
current: false,
|
||||
children: [
|
||||
{ name: 'API kľúče', href: '/admin/api-keys' },
|
||||
{ name: 'Webhooks', href: '/admin/webhooks' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Monitoring',
|
||||
icon: ChartBarIcon,
|
||||
current: false,
|
||||
children: [
|
||||
{ name: 'Prehľad výkonnosti', href: '/admin/monitoring' },
|
||||
{ name: 'Real-time monitor', href: '/admin/monitoring/realtime' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Exporty',
|
||||
icon: DocumentDuplicateIcon,
|
||||
current: false,
|
||||
children: [
|
||||
{ name: 'Export dát', href: '/admin/export' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Nastavenia',
|
||||
icon: Cog6ToothIcon,
|
||||
current: false,
|
||||
children: [
|
||||
{ name: 'Všeobecné', href: '/admin/settings/general' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Systém',
|
||||
icon: ServerStackIcon,
|
||||
current: false,
|
||||
children: [
|
||||
{ name: 'Stav systému', href: '/admin/system/status' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
function classNames(...classes: string[]) {
|
||||
return classes.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
export default function AdminLayout({ children, title = 'Administrácia' }: AdminLayoutProps) {
|
||||
const router = useRouter()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
const [expandedMenus, setExpandedMenus] = useState<string[]>([])
|
||||
|
||||
const toggleMenu = (menuName: string) => {
|
||||
setExpandedMenus(prev =>
|
||||
prev.includes(menuName)
|
||||
? prev.filter(name => name !== menuName)
|
||||
: [...prev, menuName]
|
||||
)
|
||||
}
|
||||
|
||||
const isCurrentPath = (href: string) => {
|
||||
if (href === '/admin') {
|
||||
return router.pathname === '/admin'
|
||||
}
|
||||
return router.pathname.startsWith(href)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
{/* Static sidebar for desktop */}
|
||||
<div className="hidden lg:flex lg:w-72 lg:flex-col lg:fixed lg:inset-y-0">
|
||||
<div className="flex flex-col flex-grow bg-primary-700 pt-5 pb-4 overflow-y-auto">
|
||||
<div className="flex items-center flex-shrink-0 px-4">
|
||||
<ShieldCheckIcon className="h-8 w-8 text-white" />
|
||||
<h1 className="ml-3 text-xl font-bold text-white">Hliadka.sk Admin</h1>
|
||||
</div>
|
||||
<nav className="mt-8 flex-1 flex flex-col divide-y divide-primary-800" aria-label="Sidebar">
|
||||
<div className="px-2 space-y-1">
|
||||
{navigation.map((item) =>
|
||||
!item.children ? (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href || '#'}
|
||||
className={classNames(
|
||||
isCurrentPath(item.href || '')
|
||||
? 'bg-primary-800 text-white'
|
||||
: 'text-primary-100 hover:text-white hover:bg-primary-600',
|
||||
'group flex items-center px-2 py-2 text-sm leading-6 font-medium rounded-md transition-colors duration-200'
|
||||
)}
|
||||
>
|
||||
<item.icon className="mr-3 flex-shrink-0 h-6 w-6" aria-hidden="true" />
|
||||
{item.name}
|
||||
</Link>
|
||||
) : (
|
||||
<div key={item.name} className="space-y-1">
|
||||
<button
|
||||
onClick={() => toggleMenu(item.name)}
|
||||
className={classNames(
|
||||
item.children.some(child => isCurrentPath(child.href))
|
||||
? 'bg-primary-800 text-white'
|
||||
: 'text-primary-100 hover:text-white hover:bg-primary-600',
|
||||
'group w-full flex items-center px-2 py-2 text-sm leading-6 font-medium rounded-md transition-colors duration-200'
|
||||
)}
|
||||
>
|
||||
<item.icon className="mr-3 flex-shrink-0 h-6 w-6" aria-hidden="true" />
|
||||
<span className="flex-1 text-left">{item.name}</span>
|
||||
<ChevronDownIcon
|
||||
className={classNames(
|
||||
expandedMenus.includes(item.name) ? 'rotate-180' : '',
|
||||
'ml-3 flex-shrink-0 h-5 w-5 transform transition-transform duration-200'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
{expandedMenus.includes(item.name) && (
|
||||
<div className="pl-4 space-y-1">
|
||||
{item.children.map((child) => (
|
||||
<Link
|
||||
key={child.name}
|
||||
href={child.href}
|
||||
className={classNames(
|
||||
isCurrentPath(child.href)
|
||||
? 'bg-primary-800 text-white'
|
||||
: 'text-primary-100 hover:text-white hover:bg-primary-600',
|
||||
'group flex items-center pl-8 pr-2 py-2 text-sm font-medium rounded-md transition-colors duration-200'
|
||||
)}
|
||||
>
|
||||
{child.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="lg:pl-72 flex flex-col flex-1">
|
||||
<div className="relative z-10 flex-shrink-0 flex h-16 bg-white border-b border-gray-200 lg:border-none">
|
||||
<button
|
||||
type="button"
|
||||
className="border-r border-gray-200 px-4 text-gray-400 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary-500 lg:hidden"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
>
|
||||
<span className="sr-only">Open sidebar</span>
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Search bar */}
|
||||
<div className="flex-1 px-4 flex justify-between sm:px-6 lg:max-w-6xl lg:mx-auto lg:px-8">
|
||||
<div className="flex-1 flex">
|
||||
<div className="w-full flex md:ml-0">
|
||||
<label htmlFor="search-field" className="sr-only">
|
||||
Hľadať
|
||||
</label>
|
||||
<div className="relative w-full text-gray-400 focus-within:text-gray-600">
|
||||
<div className="absolute inset-y-0 left-0 flex items-center pointer-events-none">
|
||||
<MagnifyingGlassIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
<input
|
||||
id="search-field"
|
||||
className="block w-full h-full pl-8 pr-3 py-2 border-transparent text-gray-900 placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-0 focus:border-transparent sm:text-sm"
|
||||
placeholder="Hľadať zdroje, domény, hlásenia..."
|
||||
type="search"
|
||||
name="search"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4 flex items-center md:ml-6">
|
||||
<button
|
||||
type="button"
|
||||
className="bg-white p-1 rounded-full text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||
>
|
||||
<span className="sr-only">View notifications</span>
|
||||
<BellIcon className="h-6 w-6" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{/* Profile dropdown */}
|
||||
<div className="ml-3 relative">
|
||||
<div>
|
||||
<button className="max-w-xs bg-white flex items-center text-sm rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
|
||||
<span className="sr-only">Open user menu</span>
|
||||
<div className="h-8 w-8 rounded-full bg-primary-600 flex items-center justify-center">
|
||||
<span className="text-sm font-medium text-white">A</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="flex-1 pb-8">
|
||||
{/* Page header */}
|
||||
<div className="bg-white shadow">
|
||||
<div className="px-4 sm:px-6 lg:max-w-6xl lg:mx-auto lg:px-8">
|
||||
<div className="py-6 md:flex md:items-center md:justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center">
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<h1 className="ml-3 text-2xl font-bold leading-7 text-gray-900 sm:leading-9 sm:truncate">
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page content */}
|
||||
<div className="mt-8">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
-- Antihoax Database Schema
|
||||
-- SQLite adaptation of PostgreSQL 15+ schema
|
||||
|
||||
-- Drop existing tables (for clean migration)
|
||||
DROP TABLE IF EXISTS source_categories;
|
||||
DROP TABLE IF EXISTS reports;
|
||||
DROP TABLE IF EXISTS sources;
|
||||
DROP TABLE IF EXISTS categories;
|
||||
DROP TABLE IF EXISTS users;
|
||||
DROP TABLE IF EXISTS api_keys;
|
||||
|
||||
-- Users table (admins, moderators)
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
role VARCHAR(20) DEFAULT 'moderator' CHECK (role IN ('admin', 'moderator')),
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
last_login DATETIME NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Categories table
|
||||
CREATE TABLE categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(100) UNIQUE NOT NULL,
|
||||
slug VARCHAR(100) UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
color VARCHAR(7) DEFAULT '#6B7280', -- hex color
|
||||
priority INTEGER DEFAULT 1 CHECK (priority BETWEEN 1 AND 5),
|
||||
icon VARCHAR(50), -- lucide icon name
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Sources table (main problematic websites/pages)
|
||||
CREATE TABLE sources (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
url VARCHAR(1000) UNIQUE NOT NULL,
|
||||
domain VARCHAR(255) NOT NULL,
|
||||
title VARCHAR(500),
|
||||
description TEXT,
|
||||
type VARCHAR(50) NOT NULL CHECK (type IN ('website', 'facebook_page', 'facebook_group', 'instagram', 'blog', 'news_site', 'youtube', 'tiktok', 'telegram', 'other')),
|
||||
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'verified', 'rejected', 'under_review')),
|
||||
risk_level INTEGER DEFAULT 1 CHECK (risk_level BETWEEN 1 AND 5),
|
||||
language VARCHAR(5) DEFAULT 'sk' CHECK (language IN ('sk', 'cs', 'en', 'other')),
|
||||
evidence_urls TEXT, -- JSON array of proof URLs
|
||||
reported_by VARCHAR(255), -- email or name
|
||||
verified_by INTEGER REFERENCES users(id),
|
||||
rejection_reason TEXT,
|
||||
follower_count INTEGER DEFAULT 0,
|
||||
last_checked DATETIME,
|
||||
metadata TEXT DEFAULT '{}', -- JSON data
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Junction table for many-to-many relationship between sources and categories
|
||||
CREATE TABLE source_categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
|
||||
category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
|
||||
confidence_score DECIMAL(3,2) DEFAULT 1.0 CHECK (confidence_score BETWEEN 0 AND 1),
|
||||
added_by INTEGER REFERENCES users(id),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(source_id, category_id)
|
||||
);
|
||||
|
||||
-- Reports table (user submissions)
|
||||
CREATE TABLE reports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_url VARCHAR(1000) NOT NULL,
|
||||
source_domain VARCHAR(255) NOT NULL,
|
||||
reporter_email VARCHAR(255),
|
||||
reporter_name VARCHAR(100),
|
||||
category_suggestions TEXT, -- JSON array of category IDs
|
||||
description TEXT NOT NULL,
|
||||
evidence_urls TEXT, -- JSON array: screenshots, articles, etc.
|
||||
priority VARCHAR(20) DEFAULT 'medium' CHECK (priority IN ('low', 'medium', 'high', 'urgent')),
|
||||
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'in_review', 'approved', 'rejected', 'duplicate')),
|
||||
assigned_to INTEGER REFERENCES users(id),
|
||||
admin_notes TEXT,
|
||||
processed_at DATETIME NULL,
|
||||
ip_address VARCHAR(45), -- IPv4 or IPv6
|
||||
user_agent TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- API Keys table (for external access)
|
||||
CREATE TABLE api_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key_hash VARCHAR(255) UNIQUE NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
owner_email VARCHAR(255) NOT NULL,
|
||||
permissions TEXT DEFAULT '["read"]', -- JSON array
|
||||
rate_limit INTEGER DEFAULT 1000, -- requests per hour
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
usage_count INTEGER DEFAULT 0,
|
||||
last_used DATETIME NULL,
|
||||
expires_at DATETIME NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX idx_sources_domain ON sources(domain);
|
||||
CREATE INDEX idx_sources_status ON sources(status);
|
||||
CREATE INDEX idx_sources_risk_level ON sources(risk_level);
|
||||
CREATE INDEX idx_sources_type ON sources(type);
|
||||
CREATE INDEX idx_sources_created_at ON sources(created_at);
|
||||
CREATE INDEX idx_sources_verified_by ON sources(verified_by);
|
||||
|
||||
CREATE INDEX idx_reports_status ON reports(status);
|
||||
CREATE INDEX idx_reports_source_domain ON reports(source_domain);
|
||||
CREATE INDEX idx_reports_priority ON reports(priority);
|
||||
CREATE INDEX idx_reports_created_at ON reports(created_at);
|
||||
CREATE INDEX idx_reports_assigned_to ON reports(assigned_to);
|
||||
|
||||
CREATE INDEX idx_categories_slug ON categories(slug);
|
||||
CREATE INDEX idx_categories_priority ON categories(priority);
|
||||
|
||||
CREATE INDEX idx_source_categories_source_id ON source_categories(source_id);
|
||||
CREATE INDEX idx_source_categories_category_id ON source_categories(category_id);
|
||||
|
||||
CREATE INDEX idx_api_keys_hash ON api_keys(key_hash);
|
||||
CREATE INDEX idx_api_keys_owner ON api_keys(owner_email);
|
||||
|
||||
-- SQLite FTS indexes (using FTS5)
|
||||
CREATE VIRTUAL TABLE sources_fts USING fts5(
|
||||
title,
|
||||
description,
|
||||
content='sources',
|
||||
content_rowid='id'
|
||||
);
|
||||
|
||||
CREATE VIRTUAL TABLE reports_fts USING fts5(
|
||||
description,
|
||||
content='reports',
|
||||
content_rowid='id'
|
||||
);
|
||||
|
||||
-- Triggers to keep FTS in sync
|
||||
CREATE TRIGGER sources_fts_insert AFTER INSERT ON sources BEGIN
|
||||
INSERT INTO sources_fts(rowid, title, description)
|
||||
VALUES (new.id, new.title, new.description);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER sources_fts_update AFTER UPDATE ON sources BEGIN
|
||||
UPDATE sources_fts SET title = new.title, description = new.description
|
||||
WHERE rowid = new.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER sources_fts_delete AFTER DELETE ON sources BEGIN
|
||||
DELETE FROM sources_fts WHERE rowid = old.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER reports_fts_insert AFTER INSERT ON reports BEGIN
|
||||
INSERT INTO reports_fts(rowid, description)
|
||||
VALUES (new.id, new.description);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER reports_fts_update AFTER UPDATE ON reports BEGIN
|
||||
UPDATE reports_fts SET description = new.description
|
||||
WHERE rowid = new.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER reports_fts_delete AFTER DELETE ON reports BEGIN
|
||||
DELETE FROM reports_fts WHERE rowid = old.id;
|
||||
END;
|
||||
|
||||
-- Triggers for updated_at (SQLite version)
|
||||
CREATE TRIGGER update_users_updated_at
|
||||
AFTER UPDATE ON users FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE users SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER update_sources_updated_at
|
||||
AFTER UPDATE ON sources FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE sources SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER update_categories_updated_at
|
||||
AFTER UPDATE ON categories FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE categories SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER update_reports_updated_at
|
||||
AFTER UPDATE ON reports FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE reports SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER update_api_keys_updated_at
|
||||
AFTER UPDATE ON api_keys FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE api_keys SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
-- Trigger to auto-populate domain field
|
||||
CREATE TRIGGER set_sources_domain
|
||||
BEFORE INSERT ON sources FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE sources SET domain = CASE
|
||||
WHEN NEW.url LIKE 'http%://www.%' THEN substr(NEW.url, instr(NEW.url, '://www.') + 7, instr(substr(NEW.url, instr(NEW.url, '://www.') + 7), '/') - 1)
|
||||
WHEN NEW.url LIKE 'http%://%' THEN substr(NEW.url, instr(NEW.url, '://') + 3, instr(substr(NEW.url, instr(NEW.url, '://') + 3), '/') - 1)
|
||||
ELSE NEW.url
|
||||
END WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER set_reports_domain
|
||||
BEFORE INSERT ON reports FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE reports SET source_domain = CASE
|
||||
WHEN NEW.source_url LIKE 'http%://www.%' THEN substr(NEW.source_url, instr(NEW.source_url, '://www.') + 7, instr(substr(NEW.source_url, instr(NEW.source_url, '://www.') + 7), '/') - 1)
|
||||
WHEN NEW.source_url LIKE 'http%://%' THEN substr(NEW.source_url, instr(NEW.source_url, '://') + 3, instr(substr(NEW.source_url, instr(NEW.source_url, '://') + 3), '/') - 1)
|
||||
ELSE NEW.source_url
|
||||
END WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
-- Insert default categories
|
||||
INSERT INTO categories (name, slug, description, color, priority, icon) VALUES
|
||||
('Hoax', 'hoax', 'Šírenie nepravdivých informácií a hoaxov', '#EF4444', 5, 'AlertTriangle'),
|
||||
('Hate Speech', 'hate-speech', 'Nenávistné prejavy proti skupinám ľudí', '#DC2626', 5, 'MessageSquareX'),
|
||||
('Violence', 'violence', 'Povzbudzovanie k násiliu', '#B91C1C', 5, 'Sword'),
|
||||
('Racism', 'racism', 'Rasistické a diskriminačné obsahy', '#991B1B', 5, 'Users'),
|
||||
('Conspiracy', 'conspiracy', 'Konšpiračné teórie', '#F59E0B', 3, 'Eye'),
|
||||
('Propaganda', 'propaganda', 'Politická propaganda a manipulácia', '#D97706', 2, 'Megaphone'),
|
||||
('Spam', 'spam', 'Spam a podvodné obsahy', '#6B7280', 1, 'Mail'),
|
||||
('Extremism', 'extremism', 'Extrémistické ideológie', '#7C2D12', 5, 'Flame'),
|
||||
('Medical Misinformation', 'medical-misinfo', 'Nepravdivé zdravotné informácie', '#EA580C', 4, 'Heart'),
|
||||
('Financial Scam', 'financial-scam', 'Finančné podvody a pyramid schemes', '#C2410C', 4, 'DollarSign');
|
||||
|
||||
-- Insert default admin user (password: admin123 - change in production!)
|
||||
INSERT INTO users (email, password_hash, name, role) VALUES
|
||||
('admin@antihoax.sk', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewLON5QhMlPsJiTi', 'System Admin', 'admin');
|
||||
|
||||
-- Insert some example API key (for testing)
|
||||
INSERT INTO api_keys (key_hash, name, description, owner_email, permissions, rate_limit) VALUES
|
||||
('$2b$12$example_hash_here', 'Chrome Extension', 'API key for browser extension', 'extension@antihoax.sk', '["read"]', 10000);
|
||||
|
||||
-- Views for easier querying (SQLite compatible)
|
||||
|
||||
-- View: Sources with categories
|
||||
CREATE VIEW sources_with_categories AS
|
||||
SELECT
|
||||
s.*,
|
||||
GROUP_CONCAT(c.name) as category_names,
|
||||
GROUP_CONCAT(c.slug) as category_slugs,
|
||||
GROUP_CONCAT(c.color) as category_colors
|
||||
FROM sources s
|
||||
LEFT JOIN source_categories sc ON s.id = sc.source_id
|
||||
LEFT JOIN categories c ON sc.category_id = c.id AND c.is_active = 1
|
||||
WHERE s.status = 'verified'
|
||||
GROUP BY s.id;
|
||||
|
||||
-- View: Dashboard statistics
|
||||
CREATE VIEW dashboard_stats AS
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM sources WHERE status = 'verified') as total_sources,
|
||||
(SELECT COUNT(*) FROM sources WHERE status = 'pending') as pending_sources,
|
||||
(SELECT COUNT(*) FROM reports WHERE status = 'pending') as pending_reports,
|
||||
(SELECT COUNT(*) FROM sources WHERE status = 'verified' AND risk_level >= 4) as high_risk_sources,
|
||||
(SELECT COUNT(*) FROM sources WHERE created_at > datetime('now', '-7 days')) as sources_added_week,
|
||||
(SELECT COUNT(*) FROM reports WHERE created_at > datetime('now', '-1 day')) as reports_today;
|
||||
|
||||
-- View: Top risky domains
|
||||
CREATE VIEW top_risky_domains AS
|
||||
SELECT
|
||||
domain,
|
||||
COUNT(*) as source_count,
|
||||
AVG(risk_level) as avg_risk_level,
|
||||
MAX(risk_level) as max_risk_level,
|
||||
GROUP_CONCAT(DISTINCT 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 domain
|
||||
HAVING AVG(risk_level) >= 3
|
||||
ORDER BY avg_risk_level DESC, source_count DESC;
|
||||
282
drizzle/0000_dazzling_the_professor.sql
Normal file
282
drizzle/0000_dazzling_the_professor.sql
Normal file
@@ -0,0 +1,282 @@
|
||||
CREATE TYPE "public"."language" AS ENUM('sk', 'cs', 'en', 'other');--> statement-breakpoint
|
||||
CREATE TYPE "public"."priority" AS ENUM('low', 'medium', 'high', 'urgent');--> statement-breakpoint
|
||||
CREATE TYPE "public"."report_status" AS ENUM('pending', 'in_review', 'approved', 'rejected', 'duplicate');--> statement-breakpoint
|
||||
CREATE TYPE "public"."role" AS ENUM('admin', 'moderator');--> statement-breakpoint
|
||||
CREATE TYPE "public"."source_status" AS ENUM('pending', 'verified', 'rejected', 'under_review');--> statement-breakpoint
|
||||
CREATE TYPE "public"."source_type" AS ENUM('website', 'facebook_page', 'facebook_group', 'instagram', 'blog', 'news_site', 'youtube', 'tiktok', 'telegram', 'other');--> statement-breakpoint
|
||||
CREATE TABLE "analytics_events" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"event_type" varchar(50) NOT NULL,
|
||||
"source_url" varchar(1000),
|
||||
"source_id" integer,
|
||||
"user_id" integer,
|
||||
"api_key_id" integer,
|
||||
"ip_address" varchar(45),
|
||||
"user_agent" text,
|
||||
"response_time" integer,
|
||||
"status_code" integer,
|
||||
"metadata" text DEFAULT '{}',
|
||||
"timestamp" timestamp DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "api_keys" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"key_hash" varchar(255) NOT NULL,
|
||||
"name" varchar(100) NOT NULL,
|
||||
"description" text,
|
||||
"owner_email" varchar(255) NOT NULL,
|
||||
"permissions" text DEFAULT '["read"]',
|
||||
"rate_limit" integer DEFAULT 1000,
|
||||
"is_active" boolean DEFAULT true,
|
||||
"usage_count" integer DEFAULT 0,
|
||||
"last_used" timestamp,
|
||||
"expires_at" timestamp,
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now(),
|
||||
CONSTRAINT "api_keys_key_hash_unique" UNIQUE("key_hash")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "audit_logs" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"user_id" integer,
|
||||
"action" varchar(50) NOT NULL,
|
||||
"resource_type" varchar(50) NOT NULL,
|
||||
"resource_id" integer,
|
||||
"details" text,
|
||||
"ip_address" varchar(45),
|
||||
"created_at" timestamp DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "categories" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar(100) NOT NULL,
|
||||
"slug" varchar(100) NOT NULL,
|
||||
"description" text,
|
||||
"color" varchar(7) DEFAULT '#6B7280',
|
||||
"priority" integer DEFAULT 1,
|
||||
"icon" varchar(50),
|
||||
"is_active" boolean DEFAULT true,
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now(),
|
||||
CONSTRAINT "categories_name_unique" UNIQUE("name"),
|
||||
CONSTRAINT "categories_slug_unique" UNIQUE("slug")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "email_templates" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar(100) NOT NULL,
|
||||
"subject" varchar(200) NOT NULL,
|
||||
"html_body" text NOT NULL,
|
||||
"text_body" text,
|
||||
"variables" text DEFAULT '[]',
|
||||
"is_active" boolean DEFAULT true,
|
||||
"created_by" integer,
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now(),
|
||||
CONSTRAINT "email_templates_name_unique" UNIQUE("name")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "notifications" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"title" varchar(200) NOT NULL,
|
||||
"message" text NOT NULL,
|
||||
"type" varchar(50) DEFAULT 'info',
|
||||
"user_id" integer,
|
||||
"is_read" boolean DEFAULT false,
|
||||
"action_url" varchar(500),
|
||||
"metadata" text DEFAULT '{}',
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"read_at" timestamp
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "reports" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"source_url" varchar(1000) NOT NULL,
|
||||
"source_domain" varchar(255) NOT NULL,
|
||||
"reporter_email" varchar(255),
|
||||
"reporter_name" varchar(100),
|
||||
"category_suggestions" text,
|
||||
"description" text NOT NULL,
|
||||
"evidence_urls" text,
|
||||
"priority" "priority" DEFAULT 'medium',
|
||||
"status" "report_status" DEFAULT 'pending',
|
||||
"assigned_to" integer,
|
||||
"admin_notes" text,
|
||||
"processed_at" timestamp,
|
||||
"ip_address" varchar(45),
|
||||
"user_agent" text,
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "scheduled_jobs" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar(100) NOT NULL,
|
||||
"type" varchar(50) NOT NULL,
|
||||
"schedule" varchar(100) NOT NULL,
|
||||
"is_active" boolean DEFAULT true,
|
||||
"last_run" timestamp,
|
||||
"next_run" timestamp,
|
||||
"last_result" varchar(50),
|
||||
"config" text DEFAULT '{}',
|
||||
"created_by" integer,
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "source_analytics" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"source_id" integer NOT NULL,
|
||||
"date" timestamp NOT NULL,
|
||||
"lookup_count" integer DEFAULT 0,
|
||||
"report_count" integer DEFAULT 0,
|
||||
"view_count" integer DEFAULT 0,
|
||||
"risk_score" numeric(3, 2),
|
||||
"metadata" text DEFAULT '{}'
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "source_categories" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"source_id" integer NOT NULL,
|
||||
"category_id" integer NOT NULL,
|
||||
"confidence_score" numeric(3, 2) DEFAULT '1.0',
|
||||
"added_by" integer,
|
||||
"created_at" timestamp DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sources" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"url" varchar(1000) NOT NULL,
|
||||
"domain" varchar(255) NOT NULL,
|
||||
"title" varchar(500),
|
||||
"description" text,
|
||||
"type" "source_type" NOT NULL,
|
||||
"status" "source_status" DEFAULT 'pending',
|
||||
"risk_level" integer DEFAULT 1,
|
||||
"language" "language" DEFAULT 'sk',
|
||||
"evidence_urls" text,
|
||||
"reported_by" varchar(255),
|
||||
"verified_by" integer,
|
||||
"rejection_reason" text,
|
||||
"follower_count" integer DEFAULT 0,
|
||||
"last_checked" timestamp,
|
||||
"metadata" text DEFAULT '{}',
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now(),
|
||||
CONSTRAINT "sources_url_unique" UNIQUE("url")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "system_metrics" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"metric_type" varchar(50) NOT NULL,
|
||||
"value" numeric(10, 2) NOT NULL,
|
||||
"unit" varchar(20),
|
||||
"timestamp" timestamp DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "system_settings" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"key" varchar(100) NOT NULL,
|
||||
"value" text,
|
||||
"type" varchar(50) DEFAULT 'string',
|
||||
"description" text,
|
||||
"category" varchar(50) DEFAULT 'general',
|
||||
"is_public" boolean DEFAULT false,
|
||||
"updated_by" integer,
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now(),
|
||||
CONSTRAINT "system_settings_key_unique" UNIQUE("key")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "users" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"email" varchar(255) NOT NULL,
|
||||
"password_hash" varchar(255) NOT NULL,
|
||||
"name" varchar(100) NOT NULL,
|
||||
"role" "role" DEFAULT 'moderator',
|
||||
"is_active" boolean DEFAULT true,
|
||||
"last_login" timestamp,
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now(),
|
||||
CONSTRAINT "users_email_unique" UNIQUE("email")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "webhooks" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar(100) NOT NULL,
|
||||
"url" varchar(1000) NOT NULL,
|
||||
"events" text NOT NULL,
|
||||
"secret" varchar(255),
|
||||
"is_active" boolean DEFAULT true,
|
||||
"headers" text DEFAULT '{}',
|
||||
"last_triggered" timestamp,
|
||||
"success_count" integer DEFAULT 0,
|
||||
"failure_count" integer DEFAULT 0,
|
||||
"created_by" integer,
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "analytics_events" ADD CONSTRAINT "analytics_events_source_id_sources_id_fk" FOREIGN KEY ("source_id") REFERENCES "public"."sources"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "analytics_events" ADD CONSTRAINT "analytics_events_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "analytics_events" ADD CONSTRAINT "analytics_events_api_key_id_api_keys_id_fk" FOREIGN KEY ("api_key_id") REFERENCES "public"."api_keys"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "email_templates" ADD CONSTRAINT "email_templates_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "reports" ADD CONSTRAINT "reports_assigned_to_users_id_fk" FOREIGN KEY ("assigned_to") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "scheduled_jobs" ADD CONSTRAINT "scheduled_jobs_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "source_analytics" ADD CONSTRAINT "source_analytics_source_id_sources_id_fk" FOREIGN KEY ("source_id") REFERENCES "public"."sources"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "source_categories" ADD CONSTRAINT "source_categories_source_id_sources_id_fk" FOREIGN KEY ("source_id") REFERENCES "public"."sources"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "source_categories" ADD CONSTRAINT "source_categories_category_id_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."categories"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "source_categories" ADD CONSTRAINT "source_categories_added_by_users_id_fk" FOREIGN KEY ("added_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sources" ADD CONSTRAINT "sources_verified_by_users_id_fk" FOREIGN KEY ("verified_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "system_settings" ADD CONSTRAINT "system_settings_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "webhooks" ADD CONSTRAINT "webhooks_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "idx_analytics_events_type" ON "analytics_events" USING btree ("event_type");--> statement-breakpoint
|
||||
CREATE INDEX "idx_analytics_events_timestamp" ON "analytics_events" USING btree ("timestamp");--> statement-breakpoint
|
||||
CREATE INDEX "idx_analytics_events_source_id" ON "analytics_events" USING btree ("source_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_analytics_events_api_key_id" ON "analytics_events" USING btree ("api_key_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_analytics_events_ip" ON "analytics_events" USING btree ("ip_address");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "idx_api_keys_hash" ON "api_keys" USING btree ("key_hash");--> statement-breakpoint
|
||||
CREATE INDEX "idx_api_keys_owner" ON "api_keys" USING btree ("owner_email");--> statement-breakpoint
|
||||
CREATE INDEX "idx_audit_logs_user_id" ON "audit_logs" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_audit_logs_created_at" ON "audit_logs" USING btree ("created_at");--> statement-breakpoint
|
||||
CREATE INDEX "idx_audit_logs_action" ON "audit_logs" USING btree ("action");--> statement-breakpoint
|
||||
CREATE INDEX "idx_audit_logs_resource_type" ON "audit_logs" USING btree ("resource_type");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "idx_categories_slug" ON "categories" USING btree ("slug");--> statement-breakpoint
|
||||
CREATE INDEX "idx_categories_priority" ON "categories" USING btree ("priority");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "idx_email_templates_name" ON "email_templates" USING btree ("name");--> statement-breakpoint
|
||||
CREATE INDEX "idx_email_templates_is_active" ON "email_templates" USING btree ("is_active");--> statement-breakpoint
|
||||
CREATE INDEX "idx_notifications_user_id" ON "notifications" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_notifications_is_read" ON "notifications" USING btree ("is_read");--> statement-breakpoint
|
||||
CREATE INDEX "idx_notifications_created_at" ON "notifications" USING btree ("created_at");--> statement-breakpoint
|
||||
CREATE INDEX "idx_notifications_type" ON "notifications" USING btree ("type");--> statement-breakpoint
|
||||
CREATE INDEX "idx_reports_status" ON "reports" USING btree ("status");--> statement-breakpoint
|
||||
CREATE INDEX "idx_reports_source_domain" ON "reports" USING btree ("source_domain");--> statement-breakpoint
|
||||
CREATE INDEX "idx_reports_priority" ON "reports" USING btree ("priority");--> statement-breakpoint
|
||||
CREATE INDEX "idx_reports_created_at" ON "reports" USING btree ("created_at");--> statement-breakpoint
|
||||
CREATE INDEX "idx_reports_assigned_to" ON "reports" USING btree ("assigned_to");--> statement-breakpoint
|
||||
CREATE INDEX "idx_scheduled_jobs_name" ON "scheduled_jobs" USING btree ("name");--> statement-breakpoint
|
||||
CREATE INDEX "idx_scheduled_jobs_type" ON "scheduled_jobs" USING btree ("type");--> statement-breakpoint
|
||||
CREATE INDEX "idx_scheduled_jobs_next_run" ON "scheduled_jobs" USING btree ("next_run");--> statement-breakpoint
|
||||
CREATE INDEX "idx_scheduled_jobs_is_active" ON "scheduled_jobs" USING btree ("is_active");--> statement-breakpoint
|
||||
CREATE INDEX "idx_source_analytics_source_id" ON "source_analytics" USING btree ("source_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_source_analytics_date" ON "source_analytics" USING btree ("date");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "unique_source_date" ON "source_analytics" USING btree ("source_id","date");--> statement-breakpoint
|
||||
CREATE INDEX "idx_source_categories_source_id" ON "source_categories" USING btree ("source_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_source_categories_category_id" ON "source_categories" USING btree ("category_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "unique_source_category" ON "source_categories" USING btree ("source_id","category_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_sources_domain" ON "sources" USING btree ("domain");--> statement-breakpoint
|
||||
CREATE INDEX "idx_sources_status" ON "sources" USING btree ("status");--> statement-breakpoint
|
||||
CREATE INDEX "idx_sources_risk_level" ON "sources" USING btree ("risk_level");--> statement-breakpoint
|
||||
CREATE INDEX "idx_sources_type" ON "sources" USING btree ("type");--> statement-breakpoint
|
||||
CREATE INDEX "idx_sources_created_at" ON "sources" USING btree ("created_at");--> statement-breakpoint
|
||||
CREATE INDEX "idx_sources_verified_by" ON "sources" USING btree ("verified_by");--> statement-breakpoint
|
||||
CREATE INDEX "idx_sources_status_risk" ON "sources" USING btree ("status","risk_level");--> statement-breakpoint
|
||||
CREATE INDEX "idx_system_metrics_type" ON "system_metrics" USING btree ("metric_type");--> statement-breakpoint
|
||||
CREATE INDEX "idx_system_metrics_timestamp" ON "system_metrics" USING btree ("timestamp");--> statement-breakpoint
|
||||
CREATE INDEX "idx_system_metrics_type_timestamp" ON "system_metrics" USING btree ("metric_type","timestamp");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "idx_system_settings_key" ON "system_settings" USING btree ("key");--> statement-breakpoint
|
||||
CREATE INDEX "idx_system_settings_category" ON "system_settings" USING btree ("category");--> statement-breakpoint
|
||||
CREATE INDEX "idx_webhooks_created_by" ON "webhooks" USING btree ("created_by");--> statement-breakpoint
|
||||
CREATE INDEX "idx_webhooks_is_active" ON "webhooks" USING btree ("is_active");
|
||||
2330
drizzle/meta/0000_snapshot.json
Normal file
2330
drizzle/meta/0000_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
13
drizzle/meta/_journal.json
Normal file
13
drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1757158579004,
|
||||
"tag": "0000_dazzling_the_professor",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -49,10 +49,10 @@ export async function validateApiKey(key: string): Promise<ApiKey | null> {
|
||||
keyHash: apiKey.keyHash,
|
||||
name: apiKey.name,
|
||||
permissions: apiKey.permissions ? JSON.parse(apiKey.permissions) : [],
|
||||
rateLimit: apiKey.rateLimit,
|
||||
isActive: apiKey.isActive,
|
||||
lastUsed: apiKey.lastUsed,
|
||||
createdAt: apiKey.createdAt
|
||||
rateLimit: apiKey.rateLimit || 1000,
|
||||
isActive: apiKey.isActive || false,
|
||||
lastUsed: apiKey.lastUsed || undefined,
|
||||
createdAt: apiKey.createdAt || new Date()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('API key validation error:', error)
|
||||
|
||||
221
lib/db/schema.ts
221
lib/db/schema.ts
@@ -226,3 +226,224 @@ export const auditLogsRelations = relations(auditLogs, ({ one }) => ({
|
||||
references: [users.id]
|
||||
})
|
||||
}));
|
||||
|
||||
// System Metrics table
|
||||
export const systemMetrics = pgTable('system_metrics', {
|
||||
id: serial('id').primaryKey(),
|
||||
metricType: varchar('metric_type', { length: 50 }).notNull(), // 'cpu', 'memory', 'disk', 'api_calls', 'db_connections'
|
||||
value: decimal('value', { precision: 10, scale: 2 }).notNull(),
|
||||
unit: varchar('unit', { length: 20 }), // '%', 'MB', 'GB', 'count', 'ms'
|
||||
timestamp: timestamp('timestamp').defaultNow()
|
||||
}, (table) => {
|
||||
return {
|
||||
typeIdx: index('idx_system_metrics_type').on(table.metricType),
|
||||
timestampIdx: index('idx_system_metrics_timestamp').on(table.timestamp),
|
||||
typeTimestampIdx: index('idx_system_metrics_type_timestamp').on(table.metricType, table.timestamp)
|
||||
};
|
||||
});
|
||||
|
||||
// Analytics Events table
|
||||
export const analyticsEvents = pgTable('analytics_events', {
|
||||
id: serial('id').primaryKey(),
|
||||
eventType: varchar('event_type', { length: 50 }).notNull(), // 'source_lookup', 'api_call', 'report_submitted', 'source_verified'
|
||||
sourceUrl: varchar('source_url', { length: 1000 }),
|
||||
sourceId: integer('source_id').references(() => sources.id),
|
||||
userId: integer('user_id').references(() => users.id),
|
||||
apiKeyId: integer('api_key_id').references(() => apiKeys.id),
|
||||
ipAddress: varchar('ip_address', { length: 45 }),
|
||||
userAgent: text('user_agent'),
|
||||
responseTime: integer('response_time'), // in milliseconds
|
||||
statusCode: integer('status_code'),
|
||||
metadata: text('metadata').default('{}'), // JSON
|
||||
timestamp: timestamp('timestamp').defaultNow()
|
||||
}, (table) => {
|
||||
return {
|
||||
eventTypeIdx: index('idx_analytics_events_type').on(table.eventType),
|
||||
timestampIdx: index('idx_analytics_events_timestamp').on(table.timestamp),
|
||||
sourceIdIdx: index('idx_analytics_events_source_id').on(table.sourceId),
|
||||
apiKeyIdIdx: index('idx_analytics_events_api_key_id').on(table.apiKeyId),
|
||||
ipAddressIdx: index('idx_analytics_events_ip').on(table.ipAddress)
|
||||
};
|
||||
});
|
||||
|
||||
// Notifications table
|
||||
export const notifications = pgTable('notifications', {
|
||||
id: serial('id').primaryKey(),
|
||||
title: varchar('title', { length: 200 }).notNull(),
|
||||
message: text('message').notNull(),
|
||||
type: varchar('type', { length: 50 }).default('info'), // 'info', 'warning', 'error', 'success'
|
||||
userId: integer('user_id').references(() => users.id),
|
||||
isRead: boolean('is_read').default(false),
|
||||
actionUrl: varchar('action_url', { length: 500 }),
|
||||
metadata: text('metadata').default('{}'), // JSON
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
readAt: timestamp('read_at')
|
||||
}, (table) => {
|
||||
return {
|
||||
userIdIdx: index('idx_notifications_user_id').on(table.userId),
|
||||
isReadIdx: index('idx_notifications_is_read').on(table.isRead),
|
||||
createdAtIdx: index('idx_notifications_created_at').on(table.createdAt),
|
||||
typeIdx: index('idx_notifications_type').on(table.type)
|
||||
};
|
||||
});
|
||||
|
||||
// System Settings table
|
||||
export const systemSettings = pgTable('system_settings', {
|
||||
id: serial('id').primaryKey(),
|
||||
key: varchar('key', { length: 100 }).notNull().unique(),
|
||||
value: text('value'),
|
||||
type: varchar('type', { length: 50 }).default('string'), // 'string', 'number', 'boolean', 'json'
|
||||
description: text('description'),
|
||||
category: varchar('category', { length: 50 }).default('general'),
|
||||
isPublic: boolean('is_public').default(false),
|
||||
updatedBy: integer('updated_by').references(() => users.id),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow()
|
||||
}, (table) => {
|
||||
return {
|
||||
keyIdx: uniqueIndex('idx_system_settings_key').on(table.key),
|
||||
categoryIdx: index('idx_system_settings_category').on(table.category)
|
||||
};
|
||||
});
|
||||
|
||||
// Webhooks table
|
||||
export const webhooks = pgTable('webhooks', {
|
||||
id: serial('id').primaryKey(),
|
||||
name: varchar('name', { length: 100 }).notNull(),
|
||||
url: varchar('url', { length: 1000 }).notNull(),
|
||||
events: text('events').notNull(), // JSON array of event types
|
||||
secret: varchar('secret', { length: 255 }),
|
||||
isActive: boolean('is_active').default(true),
|
||||
headers: text('headers').default('{}'), // JSON
|
||||
lastTriggered: timestamp('last_triggered'),
|
||||
successCount: integer('success_count').default(0),
|
||||
failureCount: integer('failure_count').default(0),
|
||||
createdBy: integer('created_by').references(() => users.id),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow()
|
||||
}, (table) => {
|
||||
return {
|
||||
createdByIdx: index('idx_webhooks_created_by').on(table.createdBy),
|
||||
isActiveIdx: index('idx_webhooks_is_active').on(table.isActive)
|
||||
};
|
||||
});
|
||||
|
||||
// Source Analytics table
|
||||
export const sourceAnalytics = pgTable('source_analytics', {
|
||||
id: serial('id').primaryKey(),
|
||||
sourceId: integer('source_id').notNull().references(() => sources.id, { onDelete: 'cascade' }),
|
||||
date: timestamp('date').notNull(),
|
||||
lookupCount: integer('lookup_count').default(0),
|
||||
reportCount: integer('report_count').default(0),
|
||||
viewCount: integer('view_count').default(0),
|
||||
riskScore: decimal('risk_score', { precision: 3, scale: 2 }),
|
||||
metadata: text('metadata').default('{}'), // JSON
|
||||
}, (table) => {
|
||||
return {
|
||||
sourceIdIdx: index('idx_source_analytics_source_id').on(table.sourceId),
|
||||
dateIdx: index('idx_source_analytics_date').on(table.date),
|
||||
uniqueSourceDate: uniqueIndex('unique_source_date').on(table.sourceId, table.date)
|
||||
};
|
||||
});
|
||||
|
||||
// Email Templates table
|
||||
export const emailTemplates = pgTable('email_templates', {
|
||||
id: serial('id').primaryKey(),
|
||||
name: varchar('name', { length: 100 }).notNull().unique(),
|
||||
subject: varchar('subject', { length: 200 }).notNull(),
|
||||
htmlBody: text('html_body').notNull(),
|
||||
textBody: text('text_body'),
|
||||
variables: text('variables').default('[]'), // JSON array of available variables
|
||||
isActive: boolean('is_active').default(true),
|
||||
createdBy: integer('created_by').references(() => users.id),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow()
|
||||
}, (table) => {
|
||||
return {
|
||||
nameIdx: uniqueIndex('idx_email_templates_name').on(table.name),
|
||||
isActiveIdx: index('idx_email_templates_is_active').on(table.isActive)
|
||||
};
|
||||
});
|
||||
|
||||
// Scheduled Jobs table
|
||||
export const scheduledJobs = pgTable('scheduled_jobs', {
|
||||
id: serial('id').primaryKey(),
|
||||
name: varchar('name', { length: 100 }).notNull(),
|
||||
type: varchar('type', { length: 50 }).notNull(), // 'report_generation', 'data_cleanup', 'backup', 'analytics'
|
||||
schedule: varchar('schedule', { length: 100 }).notNull(), // cron expression
|
||||
isActive: boolean('is_active').default(true),
|
||||
lastRun: timestamp('last_run'),
|
||||
nextRun: timestamp('next_run'),
|
||||
lastResult: varchar('last_result', { length: 50 }), // 'success', 'failure', 'running'
|
||||
config: text('config').default('{}'), // JSON
|
||||
createdBy: integer('created_by').references(() => users.id),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow()
|
||||
}, (table) => {
|
||||
return {
|
||||
nameIdx: index('idx_scheduled_jobs_name').on(table.name),
|
||||
typeIdx: index('idx_scheduled_jobs_type').on(table.type),
|
||||
nextRunIdx: index('idx_scheduled_jobs_next_run').on(table.nextRun),
|
||||
isActiveIdx: index('idx_scheduled_jobs_is_active').on(table.isActive)
|
||||
};
|
||||
});
|
||||
|
||||
// Additional Relations
|
||||
export const systemMetricsRelations = relations(systemMetrics, ({ one }) => ({}));
|
||||
|
||||
export const analyticsEventsRelations = relations(analyticsEvents, ({ one }) => ({
|
||||
source: one(sources, {
|
||||
fields: [analyticsEvents.sourceId],
|
||||
references: [sources.id]
|
||||
}),
|
||||
user: one(users, {
|
||||
fields: [analyticsEvents.userId],
|
||||
references: [users.id]
|
||||
}),
|
||||
apiKey: one(apiKeys, {
|
||||
fields: [analyticsEvents.apiKeyId],
|
||||
references: [apiKeys.id]
|
||||
})
|
||||
}));
|
||||
|
||||
export const notificationsRelations = relations(notifications, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [notifications.userId],
|
||||
references: [users.id]
|
||||
})
|
||||
}));
|
||||
|
||||
export const systemSettingsRelations = relations(systemSettings, ({ one }) => ({
|
||||
updatedBy: one(users, {
|
||||
fields: [systemSettings.updatedBy],
|
||||
references: [users.id]
|
||||
})
|
||||
}));
|
||||
|
||||
export const webhooksRelations = relations(webhooks, ({ one }) => ({
|
||||
createdBy: one(users, {
|
||||
fields: [webhooks.createdBy],
|
||||
references: [users.id]
|
||||
})
|
||||
}));
|
||||
|
||||
export const sourceAnalyticsRelations = relations(sourceAnalytics, ({ one }) => ({
|
||||
source: one(sources, {
|
||||
fields: [sourceAnalytics.sourceId],
|
||||
references: [sources.id]
|
||||
})
|
||||
}));
|
||||
|
||||
export const emailTemplatesRelations = relations(emailTemplates, ({ one }) => ({
|
||||
createdBy: one(users, {
|
||||
fields: [emailTemplates.createdBy],
|
||||
references: [users.id]
|
||||
})
|
||||
}));
|
||||
|
||||
export const scheduledJobsRelations = relations(scheduledJobs, ({ one }) => ({
|
||||
createdBy: one(users, {
|
||||
fields: [scheduledJobs.createdBy],
|
||||
references: [users.id]
|
||||
})
|
||||
}));
|
||||
2078
package-lock.json
generated
2078
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -14,22 +14,31 @@
|
||||
"db:seed": "npx tsx scripts/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.7",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/pg": "^8.15.5",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"chart.js": "^4.5.0",
|
||||
"dotenv": "^17.2.2",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"lucide-react": "^0.542.0",
|
||||
"next": "^14.2.32",
|
||||
"pg": "^8.16.3",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1"
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
"react-dom": "^19.1.1",
|
||||
"tailwindcss": "^3.4.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.3.1",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"eslint": "^9.35.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "14.2.15",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "^5.9.2"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AppProps } from 'next/app'
|
||||
import '../styles/globals.css'
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
return <Component {...pageProps} />
|
||||
|
||||
@@ -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>
|
||||
<>
|
||||
<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
|
||||
|
||||
@@ -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
|
||||
if (!file) return
|
||||
|
||||
setImporting(true)
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
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)
|
||||
|
||||
const response = await fetch('/api/admin/bulk-import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sources })
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setResult(data)
|
||||
} catch (error) {
|
||||
setResult({ error: 'Import failed' })
|
||||
setResults(data)
|
||||
setFile(null)
|
||||
} else {
|
||||
const error = await response.json()
|
||||
alert('Chyba pri importe: ' + error.error)
|
||||
}
|
||||
} catch (error) {
|
||||
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>
|
||||
<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>
|
||||
|
||||
<h1>Bulk Import Sources</h1>
|
||||
<p>Import multiple sources at once. Format: domain,risk_level (one per line)</p>
|
||||
<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>
|
||||
|
||||
<textarea
|
||||
value={importData}
|
||||
onChange={(e) => setImportData(e.target.value)}
|
||||
placeholder="example.com,4 badsite.sk,5 spam.org,2"
|
||||
rows={10}
|
||||
style={{ width: '100%', marginBottom: '10px' }}
|
||||
<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 až 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={loading || !importData.trim()}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: loading ? '#ccc' : '#007bff',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
disabled={importing}
|
||||
className="btn-primary"
|
||||
>
|
||||
{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>
|
||||
{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...
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<p>Total processed: {result.total}</p>
|
||||
<p>Successfully imported: {result.imported}</p>
|
||||
<p>Skipped (duplicates/invalid): {result.skipped}</p>
|
||||
</div>
|
||||
<>
|
||||
<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>
|
||||
</AdminLayout>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,63 +52,103 @@ 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>
|
||||
|
||||
<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>Loading...</div>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="text-gray-500">Načítavanie...</div>
|
||||
</div>
|
||||
</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>
|
||||
<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>
|
||||
<tbody className="divide-y divide-gray-200 bg-white">
|
||||
{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}
|
||||
<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 style={{ padding: '12px', border: '1px solid #d1d5db' }}>
|
||||
<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 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 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 style={{ padding: '12px', border: '1px solid #d1d5db' }}>
|
||||
{category.is_active ? '✅' : '❌'}
|
||||
<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 style={{ padding: '12px', border: '1px solid #d1d5db' }}>
|
||||
<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)}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
backgroundColor: category.is_active ? '#ef4444' : '#10b981',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
className={`${
|
||||
category.is_active ? 'btn-danger' : 'btn-secondary'
|
||||
} text-xs px-3 py-1`}
|
||||
>
|
||||
{category.is_active ? 'Deaktivovať' : 'Aktivovať'}
|
||||
</button>
|
||||
@@ -117,12 +158,13 @@ const CategoriesManagement: NextPage = () => {
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: '30px' }}>
|
||||
<Link href="/admin">← Späť na dashboard</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
396
pages/admin/export.tsx
Normal file
396
pages/admin/export.tsx
Normal 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
|
||||
@@ -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 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>
|
||||
{/* 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={{ 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>
|
||||
{/* 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>
|
||||
|
||||
<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>
|
||||
{/* 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>
|
||||
|
||||
<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>
|
||||
{/* 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>
|
||||
|
||||
<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>
|
||||
{/* 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 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 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 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>
|
||||
<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>
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
56
pages/admin/monitoring/index.tsx
Normal file
56
pages/admin/monitoring/index.tsx
Normal 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
|
||||
432
pages/admin/monitoring/realtime.tsx
Normal file
432
pages/admin/monitoring/realtime.tsx
Normal 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
|
||||
@@ -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>
|
||||
<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>
|
||||
|
||||
{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' }}>
|
||||
<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
|
||||
onClick={() => updateReport(report.id, 'approved')}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#10b981',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
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={() => updateReport(report.id, 'rejected', 'Insufficient evidence')}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#ef4444',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
421
pages/admin/settings/general.tsx
Normal file
421
pages/admin/settings/general.tsx
Normal 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
|
||||
@@ -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>
|
||||
<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)}
|
||||
style={{ padding: '8px', marginLeft: '10px' }}
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{loading ? (
|
||||
<div>Loading...</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>
|
||||
) : (
|
||||
<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>
|
||||
<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>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{sources.map((source) => (
|
||||
<tr key={source.id}>
|
||||
<td style={{ padding: '12px', border: '1px solid #d1d5db' }}>
|
||||
<a href={source.url} target="_blank" rel="noopener noreferrer">
|
||||
<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 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}
|
||||
<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 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 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 style={{ padding: '12px', border: '1px solid #d1d5db' }}>
|
||||
<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 style={{ display: 'flex', gap: '5px' }}>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => updateSource(source.id, 'verified', source.risk_level)}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#10b981',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
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)}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#ef4444',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
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>
|
||||
|
||||
<div style={{ marginTop: '30px' }}>
|
||||
<Link href="/admin" style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#6b7280',
|
||||
color: 'white',
|
||||
textDecoration: 'none',
|
||||
borderRadius: '6px'
|
||||
}}>
|
||||
← Späť na dashboard
|
||||
</Link>
|
||||
{/* 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>
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
74
pages/admin/system/status.tsx
Normal file
74
pages/admin/system/status.tsx
Normal 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 sú 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
|
||||
@@ -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>
|
||||
<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 style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||
<h1>Users Management</h1>
|
||||
<div className="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddForm(!showAddForm)}
|
||||
style={{ padding: '10px 15px', backgroundColor: '#28a745', color: 'white', border: 'none', borderRadius: '4px' }}
|
||||
className="btn-primary"
|
||||
>
|
||||
Add User
|
||||
<PlusIcon className="h-4 w-4 mr-2" />
|
||||
Pridať používateľa
|
||||
</button>
|
||||
</div>
|
||||
</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' }}>
|
||||
<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"
|
||||
placeholder="Email"
|
||||
value={newUser.email}
|
||||
onChange={(e) => setNewUser({...newUser, email: e.target.value})}
|
||||
required
|
||||
style={{ width: '200px', padding: '5px', marginRight: '10px' }}
|
||||
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"
|
||||
placeholder="Password"
|
||||
value={newUser.password}
|
||||
onChange={(e) => setNewUser({...newUser, password: e.target.value})}
|
||||
required
|
||||
style={{ width: '200px', padding: '5px', marginRight: '10px' }}
|
||||
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})}
|
||||
style={{ padding: '5px', marginRight: '10px' }}
|
||||
className="input mt-1"
|
||||
>
|
||||
<option value="moderator">Moderator</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="moderator">Moderátor</option>
|
||||
<option value="admin">Administrátor</option>
|
||||
</select>
|
||||
<button type="submit" style={{ padding: '5px 15px', backgroundColor: '#007bff', color: 'white', border: 'none' }}>
|
||||
Add
|
||||
</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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<tbody className="divide-y divide-gray-200 bg-white">
|
||||
{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}
|
||||
<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 style={{ padding: '10px', border: '1px solid #ddd' }}>
|
||||
<span style={{
|
||||
color: user.is_active ? 'green' : 'red'
|
||||
}}>
|
||||
{user.is_active ? 'Active' : 'Inactive'}
|
||||
<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 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 className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
<span className="font-medium">{user.sources_moderated}</span>
|
||||
</td>
|
||||
<td style={{ padding: '10px', border: '1px solid #ddd' }}>
|
||||
{user.last_login ? new Date(user.last_login).toLocaleDateString() : 'Never'}
|
||||
<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 && (
|
||||
<p style={{ textAlign: 'center', marginTop: '20px', color: '#666' }}>
|
||||
No users found.
|
||||
</p>
|
||||
{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
48
pages/admin/webhooks.tsx
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
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(
|
||||
// Pending reports
|
||||
db.select({ count: count() }).from(schema.reports).where(eq(schema.reports.status, 'pending')),
|
||||
|
||||
// Sources added this week
|
||||
db.select({ count: count() }).from(schema.sources).where(gte(schema.sources.createdAt, weekAgo)),
|
||||
|
||||
// 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.riskLevel, 4)
|
||||
)
|
||||
),
|
||||
gte(schema.sources.updatedAt, today)
|
||||
)),
|
||||
|
||||
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))
|
||||
// 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)
|
||||
))
|
||||
])
|
||||
|
||||
const stats: DashboardStats = {
|
||||
// Get trend data for charts (last 7 days) - using raw SQL for date grouping
|
||||
const sourcesTrend = []
|
||||
const reportsTrend = []
|
||||
|
||||
// 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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,40 @@
|
||||
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" })
|
||||
|
||||
if (req.method === "GET") {
|
||||
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 data: any[] = []
|
||||
let filename = ""
|
||||
|
||||
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
|
||||
`
|
||||
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') {
|
||||
query = `
|
||||
SELECT source_url, status, categories, description, created_at
|
||||
FROM reports
|
||||
WHERE status != 'spam'
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
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 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) {
|
||||
@@ -78,7 +70,28 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
} 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" })
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
// Pagination
|
||||
const pageNum = parseInt(page as string)
|
||||
const limitNum = parseInt(limit as string)
|
||||
const offset = (pageNum - 1) * limitNum
|
||||
|
||||
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({
|
||||
const results = await db.select({
|
||||
id: schema.sources.id,
|
||||
domain: schema.sources.domain,
|
||||
title: schema.sources.title,
|
||||
riskLevel: schema.sources.riskLevel,
|
||||
description: schema.sources.description,
|
||||
type: schema.sources.type,
|
||||
status: schema.sources.status,
|
||||
riskLevel: schema.sources.riskLevel,
|
||||
createdAt: schema.sources.createdAt,
|
||||
categories: sql<string>`string_agg(${schema.categories.name}, ',')`
|
||||
})
|
||||
.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)
|
||||
.where(eq(schema.sources.status, status as any))
|
||||
.orderBy(desc(schema.sources.createdAt))
|
||||
.limit(limitNum)
|
||||
.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}, ',')`
|
||||
})
|
||||
// Get total count
|
||||
const totalResult = await 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)))
|
||||
.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)
|
||||
}
|
||||
.where(eq(schema.sources.status, status as any))
|
||||
|
||||
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)
|
||||
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" })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
43
styles/globals.css
Normal file
43
styles/globals.css
Normal file
@@ -0,0 +1,43 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-gray-50 text-gray-900;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow-sm border border-gray-200;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-primary-600 hover:bg-primary-700 text-white font-medium px-4 py-2 rounded-md transition-colors duration-200 flex items-center justify-center;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-white hover:bg-gray-50 text-gray-900 font-medium px-4 py-2 rounded-md border border-gray-300 transition-colors duration-200 flex items-center justify-center;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-red-600 hover:bg-red-700 text-white font-medium px-4 py-2 rounded-md transition-colors duration-200 flex items-center justify-center;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
@apply card p-6 transition-all duration-200 hover:shadow-md;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
@apply border-b border-gray-200 hover:bg-gray-50 transition-colors duration-150;
|
||||
}
|
||||
}
|
||||
109
tailwind.config.js
Normal file
109
tailwind.config.js
Normal file
@@ -0,0 +1,109 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./lib/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
gray: {
|
||||
50: '#f9fafb',
|
||||
100: '#f3f4f6',
|
||||
200: '#e5e7eb',
|
||||
300: '#d1d5db',
|
||||
400: '#9ca3af',
|
||||
500: '#6b7280',
|
||||
600: '#4b5563',
|
||||
700: '#374151',
|
||||
800: '#1f2937',
|
||||
900: '#111827',
|
||||
},
|
||||
blue: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
},
|
||||
green: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
},
|
||||
red: {
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
},
|
||||
yellow: {
|
||||
50: '#fffbeb',
|
||||
100: '#fef3c7',
|
||||
200: '#fde68a',
|
||||
300: '#fcd34d',
|
||||
400: '#fbbf24',
|
||||
500: '#f59e0b',
|
||||
600: '#d97706',
|
||||
700: '#b45309',
|
||||
800: '#92400e',
|
||||
900: '#78350f',
|
||||
},
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
900: '#1e3a8a',
|
||||
},
|
||||
secondary: {
|
||||
50: '#f8fafc',
|
||||
100: '#f1f5f9',
|
||||
200: '#e2e8f0',
|
||||
500: '#64748b',
|
||||
600: '#475569',
|
||||
700: '#334155',
|
||||
800: '#1e293b',
|
||||
900: '#0f172a',
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.5s ease-in-out',
|
||||
'slide-up': 'slideUp 0.3s ease-out',
|
||||
'pulse-slow': 'pulse 3s ease-in-out infinite',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
Reference in New Issue
Block a user