v2
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface ChartProps {
|
||||
data: { time: string; count: number }[];
|
||||
}
|
||||
|
||||
export function ApiStatsChart({ data }: ChartProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || data.length === 0) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Set canvas size
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const width = rect.width;
|
||||
const height = rect.height;
|
||||
const padding = { top: 20, right: 20, bottom: 40, left: 50 };
|
||||
const chartWidth = width - padding.left - padding.right;
|
||||
const chartHeight = height - padding.top - padding.bottom;
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Get max value
|
||||
const maxCount = Math.max(...data.map((d) => d.count), 1);
|
||||
|
||||
// Draw grid lines
|
||||
ctx.strokeStyle = '#374151';
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i <= 5; i++) {
|
||||
const y = padding.top + (chartHeight / 5) * i;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding.left, y);
|
||||
ctx.lineTo(width - padding.right, y);
|
||||
ctx.stroke();
|
||||
|
||||
// Y-axis labels
|
||||
ctx.fillStyle = '#9CA3AF';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'right';
|
||||
const value = Math.round(maxCount - (maxCount / 5) * i);
|
||||
ctx.fillText(value.toString(), padding.left - 10, y + 4);
|
||||
}
|
||||
|
||||
// Draw bars
|
||||
const barWidth = chartWidth / data.length - 4;
|
||||
data.forEach((item, index) => {
|
||||
const x = padding.left + (chartWidth / data.length) * index + 2;
|
||||
const barHeight = (item.count / maxCount) * chartHeight;
|
||||
const y = padding.top + chartHeight - barHeight;
|
||||
|
||||
// Gradient fill
|
||||
const gradient = ctx.createLinearGradient(x, y, x, y + barHeight);
|
||||
gradient.addColorStop(0, '#10B981');
|
||||
gradient.addColorStop(1, '#059669');
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, barWidth, barHeight, 4);
|
||||
ctx.fill();
|
||||
|
||||
// X-axis labels (every 4th label to avoid crowding)
|
||||
if (index % 4 === 0 || data.length <= 6) {
|
||||
ctx.fillStyle = '#9CA3AF';
|
||||
ctx.font = '10px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
const time = item.time.split('T')[1]?.slice(0, 5) || item.time;
|
||||
ctx.fillText(time, x + barWidth / 2, height - 10);
|
||||
}
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="h-48 flex items-center justify-center text-gray-500">
|
||||
No data available yet
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">
|
||||
Requests Over Time (Last 24 Hours)
|
||||
</h3>
|
||||
<canvas ref={canvasRef} className="w-full h-48" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Admin - Primeira Liga Stats',
|
||||
description: 'Backoffice administration panel',
|
||||
};
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Settings,
|
||||
Key,
|
||||
BarChart3,
|
||||
Globe,
|
||||
ArrowLeft,
|
||||
Save,
|
||||
RefreshCw,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Check,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { ApiStatsChart } from './_components/api-stats-chart';
|
||||
|
||||
interface SettingsData {
|
||||
id: string;
|
||||
apiKey: string | null;
|
||||
selectedLeague: string;
|
||||
leagueId: number;
|
||||
season: number;
|
||||
siteName: string;
|
||||
primaryColor: string;
|
||||
secondaryColor: string;
|
||||
showLiveMatches: boolean;
|
||||
showStandings: boolean;
|
||||
showTopScorers: boolean;
|
||||
showRecentResults: boolean;
|
||||
showUpcoming: boolean;
|
||||
refreshInterval: number;
|
||||
}
|
||||
|
||||
interface ApiStats {
|
||||
totalRequests: number;
|
||||
requestsToday: number;
|
||||
requestsByEndpoint: { endpoint: string; count: number }[];
|
||||
chartData: { time: string; count: number }[];
|
||||
avgResponseTime: number;
|
||||
successRate: number;
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
const [settings, setSettings] = useState<SettingsData | null>(null);
|
||||
const [stats, setStats] = useState<ApiStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [settingsRes, statsRes] = await Promise.all([
|
||||
fetch('/api/admin/settings'),
|
||||
fetch('/api/admin/stats'),
|
||||
]);
|
||||
|
||||
if (settingsRes.ok) {
|
||||
const settingsData = await settingsRes.json();
|
||||
setSettings(settingsData);
|
||||
}
|
||||
|
||||
if (statsRes.ok) {
|
||||
const statsData = await statsRes.json();
|
||||
setStats(statsData);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to load data');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSettings = async () => {
|
||||
if (!settings) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
const res = await fetch('/api/admin/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const updatedSettings = await res.json();
|
||||
setSettings(updatedSettings);
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => setSaveSuccess(false), 3000);
|
||||
} else {
|
||||
setError('Failed to save settings');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to save settings');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSetting = <K extends keyof SettingsData>(
|
||||
key: K,
|
||||
value: SettingsData[K]
|
||||
) => {
|
||||
if (settings) {
|
||||
setSettings({ ...settings, [key]: value });
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-900 to-gray-800 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-green-500 border-t-transparent"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-900 to-gray-800">
|
||||
{/* Header */}
|
||||
<header className="bg-gray-900/80 backdrop-blur-md border-b border-gray-700 sticky top-0 z-50">
|
||||
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
<span>Back to Site</span>
|
||||
</Link>
|
||||
<div className="h-6 w-px bg-gray-700" />
|
||||
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<Settings className="h-6 w-6 text-green-500" />
|
||||
Backoffice
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={fetchData}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
onClick={saveSettings}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-500 text-white rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : saveSuccess ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
{saveSuccess ? 'Saved!' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<div className="max-w-7xl mx-auto px-4 pt-4">
|
||||
<div className="bg-red-500/20 border border-red-500 text-red-400 px-4 py-3 rounded-lg flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Layout Settings */}
|
||||
<section className="bg-gray-800/50 backdrop-blur-sm rounded-xl border border-gray-700 p-6">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2 mb-6">
|
||||
<Settings className="h-5 w-5 text-green-500" />
|
||||
Layout Settings
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Site Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings?.siteName || ''}
|
||||
onChange={(e) => updateSetting('siteName', e.target.value)}
|
||||
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Primary Color
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={settings?.primaryColor || '#E42518'}
|
||||
onChange={(e) =>
|
||||
updateSetting('primaryColor', e.target.value)
|
||||
}
|
||||
className="w-12 h-10 rounded cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={settings?.primaryColor || ''}
|
||||
onChange={(e) =>
|
||||
updateSetting('primaryColor', e.target.value)
|
||||
}
|
||||
className="flex-1 px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Secondary Color
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={settings?.secondaryColor || '#006600'}
|
||||
onChange={(e) =>
|
||||
updateSetting('secondaryColor', e.target.value)
|
||||
}
|
||||
className="w-12 h-10 rounded cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={settings?.secondaryColor || ''}
|
||||
onChange={(e) =>
|
||||
updateSetting('secondaryColor', e.target.value)
|
||||
}
|
||||
className="flex-1 px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Refresh Interval (seconds)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="60"
|
||||
max="3600"
|
||||
value={settings?.refreshInterval || 300}
|
||||
onChange={(e) =>
|
||||
updateSetting('refreshInterval', parseInt(e.target.value))
|
||||
}
|
||||
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
<label className="block text-sm font-medium text-gray-300">
|
||||
Visible Sections
|
||||
</label>
|
||||
{[
|
||||
{ key: 'showLiveMatches', label: 'Live Matches' },
|
||||
{ key: 'showStandings', label: 'Standings Table' },
|
||||
{ key: 'showTopScorers', label: 'Top Scorers' },
|
||||
{ key: 'showRecentResults', label: 'Recent Results' },
|
||||
{ key: 'showUpcoming', label: 'Upcoming Fixtures' },
|
||||
].map((item) => (
|
||||
<label
|
||||
key={item.key}
|
||||
className="flex items-center gap-3 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
settings?.[item.key as keyof SettingsData] as boolean
|
||||
}
|
||||
onChange={(e) =>
|
||||
updateSetting(
|
||||
item.key as keyof SettingsData,
|
||||
e.target.checked as never
|
||||
)
|
||||
}
|
||||
className="w-5 h-5 rounded bg-gray-700 border-gray-600 text-green-500 focus:ring-green-500"
|
||||
/>
|
||||
<span className="text-gray-300">{item.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* API Key Management */}
|
||||
<section className="bg-gray-800/50 backdrop-blur-sm rounded-xl border border-gray-700 p-6">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2 mb-6">
|
||||
<Key className="h-5 w-5 text-yellow-500" />
|
||||
API Key Management
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
API-Football API Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
value={settings?.apiKey || ''}
|
||||
onChange={(e) => updateSetting('apiKey', e.target.value)}
|
||||
placeholder="Enter your API key"
|
||||
className="w-full px-4 py-2 pr-12 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-yellow-500 focus:border-transparent font-mono"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white"
|
||||
>
|
||||
{showApiKey ? (
|
||||
<EyeOff className="h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
Get your API key from{' '}
|
||||
<a
|
||||
href="https://www.api-football.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-green-500 hover:underline"
|
||||
>
|
||||
api-football.com
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-700/50 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-2">
|
||||
API Status
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{settings?.apiKey ? (
|
||||
<>
|
||||
<div className="w-3 h-3 rounded-full bg-green-500"></div>
|
||||
<span className="text-green-400">API Key Configured</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-3 h-3 rounded-full bg-red-500"></div>
|
||||
<span className="text-red-400">No API Key Set</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* League Switcher */}
|
||||
<section className="bg-gray-800/50 backdrop-blur-sm rounded-xl border border-gray-700 p-6">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2 mb-6">
|
||||
<Globe className="h-5 w-5 text-blue-500" />
|
||||
League Selection
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={() => updateSetting('selectedLeague', 'primeira_liga')}
|
||||
className={`p-4 rounded-xl border-2 transition-all ${
|
||||
settings?.selectedLeague === 'primeira_liga'
|
||||
? 'border-green-500 bg-green-500/20'
|
||||
: 'border-gray-600 bg-gray-700/50 hover:border-gray-500'
|
||||
}`}
|
||||
>
|
||||
<div className="text-2xl mb-2">🇵🇹</div>
|
||||
<div className="font-semibold text-white">Primeira Liga</div>
|
||||
<div className="text-sm text-gray-400">Portuguese Championship</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => updateSetting('selectedLeague', 'euro_2026')}
|
||||
className={`p-4 rounded-xl border-2 transition-all ${
|
||||
settings?.selectedLeague === 'euro_2026'
|
||||
? 'border-blue-500 bg-blue-500/20'
|
||||
: 'border-gray-600 bg-gray-700/50 hover:border-gray-500'
|
||||
}`}
|
||||
>
|
||||
<div className="text-2xl mb-2">🇪🇺</div>
|
||||
<div className="font-semibold text-white">UEFA Euro</div>
|
||||
<div className="text-sm text-gray-400">European Championship</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-700/50 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-2">
|
||||
Current Configuration
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="text-gray-400">League ID:</div>
|
||||
<div className="text-white font-mono">{settings?.leagueId}</div>
|
||||
<div className="text-gray-400">Season:</div>
|
||||
<div className="text-white font-mono">{settings?.season}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* API Statistics */}
|
||||
<section className="bg-gray-800/50 backdrop-blur-sm rounded-xl border border-gray-700 p-6">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2 mb-6">
|
||||
<BarChart3 className="h-5 w-5 text-purple-500" />
|
||||
API Statistics
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||
<div className="bg-gray-700/50 rounded-lg p-4">
|
||||
<div className="text-3xl font-bold text-white">
|
||||
{stats?.totalRequests || 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">Total Requests</div>
|
||||
</div>
|
||||
<div className="bg-gray-700/50 rounded-lg p-4">
|
||||
<div className="text-3xl font-bold text-white">
|
||||
{stats?.requestsToday || 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">Requests Today</div>
|
||||
</div>
|
||||
<div className="bg-gray-700/50 rounded-lg p-4">
|
||||
<div className="text-3xl font-bold text-white">
|
||||
{stats?.avgResponseTime || 0}ms
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">Avg Response Time</div>
|
||||
</div>
|
||||
<div className="bg-gray-700/50 rounded-lg p-4">
|
||||
<div className="text-3xl font-bold text-green-400">
|
||||
{stats?.successRate || 100}%
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">Success Rate</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ApiStatsChart data={stats?.chartData || []} />
|
||||
|
||||
{stats?.requestsByEndpoint && stats.requestsByEndpoint.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">
|
||||
Requests by Endpoint
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{stats.requestsByEndpoint.map((item) => (
|
||||
<div
|
||||
key={item.endpoint}
|
||||
className="flex items-center justify-between bg-gray-700/50 rounded-lg px-3 py-2"
|
||||
>
|
||||
<span className="text-gray-300 font-mono text-sm">
|
||||
{item.endpoint}
|
||||
</span>
|
||||
<span className="text-white font-semibold">
|
||||
{item.count}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user