This commit is contained in:
jpmvaz
2026-09-13 20:20:06 +01:00
commit 6994063c71
93 changed files with 7613 additions and 0 deletions
@@ -0,0 +1,44 @@
'use client'
import { useState, useEffect } from 'react'
import { Clock } from 'lucide-react'
interface CountdownTimerProps {
refreshInterval?: number; // in seconds
}
export default function CountdownTimer({ refreshInterval = 300 }: CountdownTimerProps) {
const [timeLeft, setTimeLeft] = useState<number>(refreshInterval)
useEffect(() => {
// Reset when refresh interval changes
setTimeLeft(refreshInterval);
}, [refreshInterval]);
useEffect(() => {
const interval = setInterval(() => {
setTimeLeft((prev) => {
if (prev <= 1) {
// Trigger page reload for data refresh
window.location.reload();
return refreshInterval;
}
return prev - 1
})
}, 1000)
return () => clearInterval(interval)
}, [refreshInterval])
const minutes = Math.floor((timeLeft ?? 0) / 60)
const seconds = (timeLeft ?? 0) % 60
return (
<div className="flex items-center gap-2 px-4 py-2 bg-[#E42518]/10 rounded-lg border border-[#E42518]/20">
<Clock className="w-4 h-4 text-[#E42518]" />
<span className="text-sm font-medium text-gray-700">
Next update: {minutes}:{seconds?.toString()?.padStart(2, '0') ?? '00'}
</span>
</div>
)
}
@@ -0,0 +1,153 @@
'use client'
import { useState, useEffect } from 'react'
import { Radio, Loader2, AlertCircle } from 'lucide-react'
import Image from 'next/image'
interface Fixture {
fixture: {
id: number
status: {
short: string
elapsed: number | null
}
}
teams: {
home: {
id: number
name: string
logo: string
}
away: {
id: number
name: string
logo: string
}
}
goals: {
home: number | null
away: number | null
}
}
const REFRESH_INTERVAL = 5 * 60 * 1000 // 5 minutes
export default function LiveMatches() {
const [fixtures, setFixtures] = useState<Fixture[]>([])
const [loading, setLoading] = useState<boolean>(true)
const [error, setError] = useState<string | null>(null)
const fetchLiveMatches = async () => {
try {
setError(null)
const res = await fetch('/api/fixtures?live=true')
if (!res?.ok) throw new Error('Failed to fetch live matches')
const data = await res.json()
setFixtures(data?.response ?? [])
} catch (err) {
setError(err instanceof Error ? err?.message : 'Failed to load live matches')
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchLiveMatches()
const interval = setInterval(fetchLiveMatches, REFRESH_INTERVAL)
return () => clearInterval(interval)
}, [])
if (loading) {
return (
<section className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 text-[#E42518] animate-spin" />
</div>
</section>
)
}
if (error) {
return (
<section className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<div className="flex items-center justify-center gap-2 py-12 text-gray-500">
<AlertCircle className="w-5 h-5" />
<span>{error}</span>
</div>
</section>
)
}
if ((fixtures?.length ?? 0) === 0) {
return (
<section className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<div className="flex items-center gap-3 mb-6">
<Radio className="w-6 h-6 text-[#E42518]" />
<h2 className="text-2xl font-bold text-gray-800">Live Matches</h2>
</div>
<p className="text-center py-8 text-gray-500">No live matches at the moment</p>
</section>
)
}
return (
<section className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<div className="flex items-center gap-3 mb-6">
<Radio className="w-6 h-6 text-[#E42518] animate-pulse" />
<h2 className="text-2xl font-bold text-gray-800">Live Matches</h2>
<span className="ml-auto px-3 py-1 bg-red-500 text-white text-xs font-semibold rounded-full animate-pulse">
LIVE
</span>
</div>
<div className="space-y-4">
{fixtures?.map((fixture) => (
<div
key={fixture?.fixture?.id}
className="p-4 border border-gray-200 rounded-lg hover:shadow-md transition-shadow bg-gradient-to-r from-gray-50 to-white"
>
<div className="flex items-center justify-between">
{/* Home Team */}
<div className="flex items-center gap-3 flex-1">
<div className="relative w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center">
<Image
src={fixture?.teams?.home?.logo ?? ''}
alt={fixture?.teams?.home?.name ?? ''}
fill
className="object-contain p-1"
unoptimized
/>
</div>
<span className="font-semibold text-gray-800">{fixture?.teams?.home?.name}</span>
</div>
{/* Score */}
<div className="flex flex-col items-center mx-4">
<div className="flex items-center gap-2 text-2xl font-bold">
<span className="text-[#E42518]">{fixture?.goals?.home ?? 0}</span>
<span className="text-gray-400">-</span>
<span className="text-[#006600]">{fixture?.goals?.away ?? 0}</span>
</div>
<span className="text-xs text-gray-500 mt-1">{fixture?.fixture?.status?.elapsed ?? 0}'</span>
</div>
{/* Away Team */}
<div className="flex items-center gap-3 flex-1 justify-end">
<span className="font-semibold text-gray-800">{fixture?.teams?.away?.name}</span>
<div className="relative w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center">
<Image
src={fixture?.teams?.away?.logo ?? ''}
alt={fixture?.teams?.away?.name ?? ''}
fill
className="object-contain p-1"
unoptimized
/>
</div>
</div>
</div>
</div>
))}
</div>
</section>
)
}
@@ -0,0 +1,147 @@
'use client'
import { useState, useEffect } from 'react'
import { CheckCircle2, Loader2, AlertCircle } from 'lucide-react'
import Image from 'next/image'
interface Fixture {
fixture: {
id: number
date: string
}
teams: {
home: {
id: number
name: string
logo: string
}
away: {
id: number
name: string
logo: string
}
}
goals: {
home: number | null
away: number | null
}
}
const REFRESH_INTERVAL = 5 * 60 * 1000 // 5 minutes
export default function RecentResults() {
const [fixtures, setFixtures] = useState<Fixture[]>([])
const [loading, setLoading] = useState<boolean>(true)
const [error, setError] = useState<string | null>(null)
const fetchRecentResults = async () => {
try {
setError(null)
const res = await fetch('/api/fixtures?last=10')
if (!res?.ok) throw new Error('Failed to fetch recent results')
const data = await res.json()
setFixtures(data?.response ?? [])
} catch (err) {
setError(err instanceof Error ? err?.message : 'Failed to load recent results')
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchRecentResults()
const interval = setInterval(fetchRecentResults, REFRESH_INTERVAL)
return () => clearInterval(interval)
}, [])
if (loading) {
return (
<section className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 text-[#006600] animate-spin" />
</div>
</section>
)
}
if (error) {
return (
<section className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<div className="flex items-center justify-center gap-2 py-12 text-gray-500">
<AlertCircle className="w-5 h-5" />
<span>{error}</span>
</div>
</section>
)
}
return (
<section className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<div className="flex items-center gap-3 mb-6">
<CheckCircle2 className="w-6 h-6 text-[#006600]" />
<h2 className="text-xl font-bold text-gray-800">Recent Results</h2>
</div>
<div className="space-y-3">
{fixtures?.slice(0, 8)?.map((fixture) => {
const date = new Date(fixture?.fixture?.date ?? '')
const formattedDate = date?.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
return (
<div
key={fixture?.fixture?.id}
className="p-3 border border-gray-200 rounded-lg hover:shadow-md transition-shadow bg-gray-50"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 flex-1">
<div className="relative w-6 h-6">
<Image
src={fixture?.teams?.home?.logo ?? ''}
alt={fixture?.teams?.home?.name ?? ''}
fill
className="object-contain"
unoptimized
/>
</div>
<span className="text-sm font-medium text-gray-800 truncate">
{fixture?.teams?.home?.name}
</span>
</div>
<div className="flex items-center gap-2 mx-3">
<span className={`text-sm font-bold ${
(fixture?.goals?.home ?? 0) > (fixture?.goals?.away ?? 0) ? 'text-[#006600]' : 'text-gray-600'
}`}>
{fixture?.goals?.home ?? 0}
</span>
<span className="text-xs text-gray-400">-</span>
<span className={`text-sm font-bold ${
(fixture?.goals?.away ?? 0) > (fixture?.goals?.home ?? 0) ? 'text-[#006600]' : 'text-gray-600'
}`}>
{fixture?.goals?.away ?? 0}
</span>
</div>
<div className="flex items-center gap-2 flex-1 justify-end">
<span className="text-sm font-medium text-gray-800 truncate">
{fixture?.teams?.away?.name}
</span>
<div className="relative w-6 h-6">
<Image
src={fixture?.teams?.away?.logo ?? ''}
alt={fixture?.teams?.away?.name ?? ''}
fill
className="object-contain"
unoptimized
/>
</div>
</div>
</div>
<p className="text-xs text-gray-500 text-center mt-2">{formattedDate}</p>
</div>
)
})}
</div>
</section>
)
}
+147
View File
@@ -0,0 +1,147 @@
'use client'
import { useState, useEffect } from 'react'
import { Trophy, Loader2, AlertCircle } from 'lucide-react'
import Image from 'next/image'
interface Standing {
rank: number
team: {
id: number
name: string
logo: string
}
points: number
all: {
played: number
win: number
draw: number
lose: number
}
goalsDiff: number
form: string
}
const REFRESH_INTERVAL = 5 * 60 * 1000 // 5 minutes
export default function Standings() {
const [standings, setStandings] = useState<Standing[]>([])
const [loading, setLoading] = useState<boolean>(true)
const [error, setError] = useState<string | null>(null)
const fetchStandings = async () => {
try {
setError(null)
const res = await fetch('/api/standings')
if (!res?.ok) throw new Error('Failed to fetch standings')
const data = await res.json()
setStandings(data?.response?.[0]?.league?.standings?.[0] ?? [])
} catch (err) {
setError(err instanceof Error ? err?.message : 'Failed to load standings')
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchStandings()
const interval = setInterval(fetchStandings, REFRESH_INTERVAL)
return () => clearInterval(interval)
}, [])
if (loading) {
return (
<section className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 text-[#006600] animate-spin" />
</div>
</section>
)
}
if (error) {
return (
<section className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<div className="flex items-center justify-center gap-2 py-12 text-gray-500">
<AlertCircle className="w-5 h-5" />
<span>{error}</span>
</div>
</section>
)
}
return (
<section className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<div className="flex items-center gap-3 mb-6">
<Trophy className="w-6 h-6 text-[#006600]" />
<h2 className="text-2xl font-bold text-gray-800">League Standings</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b-2 border-gray-200">
<th className="text-left py-3 px-2 text-sm font-semibold text-gray-600">#</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-600">Team</th>
<th className="text-center py-3 px-2 text-sm font-semibold text-gray-600">P</th>
<th className="text-center py-3 px-2 text-sm font-semibold text-gray-600">W</th>
<th className="text-center py-3 px-2 text-sm font-semibold text-gray-600">D</th>
<th className="text-center py-3 px-2 text-sm font-semibold text-gray-600">L</th>
<th className="text-center py-3 px-2 text-sm font-semibold text-gray-600">GD</th>
<th className="text-center py-3 px-3 text-sm font-semibold text-gray-600">Pts</th>
<th className="text-left py-3 px-2 text-sm font-semibold text-gray-600">Form</th>
</tr>
</thead>
<tbody>
{standings?.map((team, idx) => (
<tr
key={team?.team?.id}
className={`border-b border-gray-100 hover:bg-gray-50 transition-colors ${
idx < 3 ? 'bg-green-50/50' : idx < 6 ? 'bg-blue-50/30' : idx >= (standings?.length ?? 0) - 2 ? 'bg-red-50/30' : ''
}`}
>
<td className="py-3 px-2 text-sm font-semibold text-gray-700">{team?.rank}</td>
<td className="py-3 px-4">
<div className="flex items-center gap-3">
<div className="relative w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center">
<Image
src={team?.team?.logo ?? ''}
alt={team?.team?.name ?? ''}
fill
className="object-contain p-1"
unoptimized
/>
</div>
<span className="font-medium text-gray-800">{team?.team?.name}</span>
</div>
</td>
<td className="text-center py-3 px-2 text-sm text-gray-600">{team?.all?.played ?? 0}</td>
<td className="text-center py-3 px-2 text-sm text-gray-600">{team?.all?.win ?? 0}</td>
<td className="text-center py-3 px-2 text-sm text-gray-600">{team?.all?.draw ?? 0}</td>
<td className="text-center py-3 px-2 text-sm text-gray-600">{team?.all?.lose ?? 0}</td>
<td className="text-center py-3 px-2 text-sm text-gray-600">{team?.goalsDiff ?? 0}</td>
<td className="text-center py-3 px-3 text-sm font-bold text-[#006600]">{team?.points ?? 0}</td>
<td className="py-3 px-2">
<div className="flex gap-1">
{team?.form?.split('')?.slice(-5)?.map((result, i) => (
<div
key={i}
className={`w-5 h-5 rounded-full text-xs flex items-center justify-center font-semibold ${
result === 'W' ? 'bg-green-500 text-white' :
result === 'D' ? 'bg-gray-400 text-white' :
result === 'L' ? 'bg-red-500 text-white' : 'bg-gray-200'
}`}
>
{result}
</div>
))}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)
}
@@ -0,0 +1,133 @@
'use client'
import { useState, useEffect } from 'react'
import { Target, Loader2, AlertCircle } from 'lucide-react'
import Image from 'next/image'
interface TopScorer {
player: {
id: number
name: string
photo: string
}
statistics: Array<{
team: {
id: number
name: string
logo: string
}
goals: {
total: number | null
}
}>
}
const REFRESH_INTERVAL = 5 * 60 * 1000 // 5 minutes
export default function TopScorers() {
const [scorers, setScorers] = useState<TopScorer[]>([])
const [loading, setLoading] = useState<boolean>(true)
const [error, setError] = useState<string | null>(null)
const fetchTopScorers = async () => {
try {
setError(null)
const res = await fetch('/api/topscorers')
if (!res?.ok) throw new Error('Failed to fetch top scorers')
const data = await res.json()
setScorers(data?.response ?? [])
} catch (err) {
setError(err instanceof Error ? err?.message : 'Failed to load top scorers')
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchTopScorers()
const interval = setInterval(fetchTopScorers, REFRESH_INTERVAL)
return () => clearInterval(interval)
}, [])
if (loading) {
return (
<section className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 text-[#E42518] animate-spin" />
</div>
</section>
)
}
if (error) {
return (
<section className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<div className="flex items-center justify-center gap-2 py-12 text-gray-500">
<AlertCircle className="w-5 h-5" />
<span>{error}</span>
</div>
</section>
)
}
return (
<section className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<div className="flex items-center gap-3 mb-6">
<Target className="w-6 h-6 text-[#E42518]" />
<h2 className="text-2xl font-bold text-gray-800">Top Scorers</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{scorers?.slice(0, 12)?.map((scorer, idx) => {
const stats = scorer?.statistics?.[0]
const goals = stats?.goals?.total ?? 0
return (
<div
key={scorer?.player?.id}
className="p-4 border border-gray-200 rounded-lg hover:shadow-md transition-all hover:-translate-y-1 bg-gradient-to-br from-white to-gray-50"
>
<div className="flex items-center gap-3 mb-3">
<div className="relative w-12 h-12 rounded-full overflow-hidden bg-gray-200">
<Image
src={scorer?.player?.photo ?? ''}
alt={scorer?.player?.name ?? ''}
fill
className="object-cover"
unoptimized
/>
</div>
<div className="flex-1">
<h3 className="font-semibold text-gray-800 text-sm">{scorer?.player?.name}</h3>
<div className="flex items-center gap-2 mt-1">
<div className="relative w-5 h-5">
<Image
src={stats?.team?.logo ?? ''}
alt={stats?.team?.name ?? ''}
fill
className="object-contain"
unoptimized
/>
</div>
<span className="text-xs text-gray-600">{stats?.team?.name}</span>
</div>
</div>
</div>
<div className="flex items-center justify-between pt-3 border-t border-gray-200">
<span className="text-sm text-gray-600">Goals</span>
<span className="text-2xl font-bold text-[#E42518]">{goals}</span>
</div>
{idx === 0 && (
<div className="mt-2 text-center">
<span className="inline-block px-2 py-1 bg-yellow-100 text-yellow-800 text-xs font-semibold rounded">
👑 Top Scorer
</span>
</div>
)}
</div>
)
})}
</div>
</section>
)
}
@@ -0,0 +1,136 @@
'use client'
import { useState, useEffect } from 'react'
import { Calendar, Loader2, AlertCircle } from 'lucide-react'
import Image from 'next/image'
interface Fixture {
fixture: {
id: number
date: string
}
teams: {
home: {
id: number
name: string
logo: string
}
away: {
id: number
name: string
logo: string
}
}
}
const REFRESH_INTERVAL = 5 * 60 * 1000 // 5 minutes
export default function UpcomingFixtures() {
const [fixtures, setFixtures] = useState<Fixture[]>([])
const [loading, setLoading] = useState<boolean>(true)
const [error, setError] = useState<string | null>(null)
const fetchUpcomingFixtures = async () => {
try {
setError(null)
const res = await fetch('/api/fixtures?next=10')
if (!res?.ok) throw new Error('Failed to fetch upcoming fixtures')
const data = await res.json()
setFixtures(data?.response ?? [])
} catch (err) {
setError(err instanceof Error ? err?.message : 'Failed to load upcoming fixtures')
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchUpcomingFixtures()
const interval = setInterval(fetchUpcomingFixtures, REFRESH_INTERVAL)
return () => clearInterval(interval)
}, [])
if (loading) {
return (
<section className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 text-[#E42518] animate-spin" />
</div>
</section>
)
}
if (error) {
return (
<section className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<div className="flex items-center justify-center gap-2 py-12 text-gray-500">
<AlertCircle className="w-5 h-5" />
<span>{error}</span>
</div>
</section>
)
}
return (
<section className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<div className="flex items-center gap-3 mb-6">
<Calendar className="w-6 h-6 text-[#E42518]" />
<h2 className="text-xl font-bold text-gray-800">Upcoming Fixtures</h2>
</div>
<div className="space-y-3">
{fixtures?.slice(0, 8)?.map((fixture) => {
const date = new Date(fixture?.fixture?.date ?? '')
const formattedDate = date?.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
const formattedTime = date?.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
return (
<div
key={fixture?.fixture?.id}
className="p-3 border border-gray-200 rounded-lg hover:shadow-md transition-shadow bg-gradient-to-r from-white to-gray-50"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 flex-1">
<div className="relative w-6 h-6">
<Image
src={fixture?.teams?.home?.logo ?? ''}
alt={fixture?.teams?.home?.name ?? ''}
fill
className="object-contain"
unoptimized
/>
</div>
<span className="text-sm font-medium text-gray-800 truncate">
{fixture?.teams?.home?.name}
</span>
</div>
<div className="mx-3 text-sm text-gray-500 font-medium">
vs
</div>
<div className="flex items-center gap-2 flex-1 justify-end">
<span className="text-sm font-medium text-gray-800 truncate">
{fixture?.teams?.away?.name}
</span>
<div className="relative w-6 h-6">
<Image
src={fixture?.teams?.away?.logo ?? ''}
alt={fixture?.teams?.away?.name ?? ''}
fill
className="object-contain"
unoptimized
/>
</div>
</div>
</div>
<p className="text-xs text-gray-500 text-center mt-2">
{formattedDate} {formattedTime}
</p>
</div>
)
})}
</div>
</section>
)
}
@@ -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>
);
}
+14
View File
@@ -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}</>;
}
+485
View File
@@ -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>
);
}
@@ -0,0 +1,65 @@
export const dynamic = 'force-dynamic';
import { NextResponse } from 'next/server';
import prisma from '@/lib/db';
import { invalidateSettingsCache } from '@/lib/settings';
export async function GET() {
try {
let settings = await prisma.settings.findUnique({
where: { id: 'main' },
});
if (!settings) {
settings = await prisma.settings.create({
data: {
id: 'main',
apiKey: process.env.API_FOOTBALL_API_KEY || null,
},
});
}
return NextResponse.json(settings);
} catch (error) {
console.error('Failed to get settings:', error);
return NextResponse.json(
{ error: 'Failed to get settings' },
{ status: 500 }
);
}
}
export async function PUT(request: Request) {
try {
const data = await request.json();
// Update league settings based on selection
let leagueId = data.leagueId;
let season = data.season;
if (data.selectedLeague === 'euro_2026') {
leagueId = 4; // EURO Championship
season = 2024;
} else if (data.selectedLeague === 'primeira_liga') {
leagueId = 94;
season = 2024;
}
const settings = await prisma.settings.upsert({
where: { id: 'main' },
update: { ...data, leagueId, season },
create: { id: 'main', ...data, leagueId, season },
});
// Bust the settings cache so API routes pick up the new values immediately
invalidateSettingsCache();
return NextResponse.json(settings);
} catch (error) {
console.error('Failed to update settings:', error);
return NextResponse.json(
{ error: 'Failed to update settings' },
{ status: 500 }
);
}
}
+69
View File
@@ -0,0 +1,69 @@
export const dynamic = 'force-dynamic';
import { NextResponse } from 'next/server';
import prisma from '@/lib/db';
export async function GET() {
try {
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const startOfDay = new Date();
startOfDay.setHours(0, 0, 0, 0);
// Run all queries in parallel instead of sequentially
const [
totalRequests,
requestsByEndpoint,
recentRequests,
avgDuration,
successfulRequests,
requestsToday,
] = await Promise.all([
prisma.apiRequestLog.count(),
prisma.apiRequestLog.groupBy({
by: ['endpoint'],
_count: { id: true },
}),
prisma.apiRequestLog.findMany({
where: { createdAt: { gte: twentyFourHoursAgo } },
orderBy: { createdAt: 'asc' },
select: { createdAt: true }, // only fetch what we need
}),
prisma.apiRequestLog.aggregate({ _avg: { duration: true } }),
prisma.apiRequestLog.count({ where: { status: { gte: 200, lt: 300 } } }),
prisma.apiRequestLog.count({ where: { createdAt: { gte: startOfDay } } }),
]);
// Group by hour
const hourlyStats: Record<string, number> = {};
for (const req of recentRequests) {
const hour = new Date(req.createdAt).toISOString().slice(0, 13) + ':00';
hourlyStats[hour] = (hourlyStats[hour] ?? 0) + 1;
}
const chartData = Object.entries(hourlyStats).map(([time, count]) => ({
time,
count,
}));
const successRate =
totalRequests > 0 ? (successfulRequests / totalRequests) * 100 : 100;
return NextResponse.json({
totalRequests,
requestsToday,
requestsByEndpoint: requestsByEndpoint.map((r) => ({
endpoint: r.endpoint,
count: r._count.id,
})),
chartData,
avgResponseTime: Math.round(avgDuration._avg.duration ?? 0),
successRate: Math.round(successRate * 100) / 100,
});
} catch (error) {
console.error('Failed to get stats:', error);
return NextResponse.json(
{ error: 'Failed to get stats' },
{ status: 500 }
);
}
}
+76
View File
@@ -0,0 +1,76 @@
export const dynamic = 'force-dynamic';
import { NextResponse } from 'next/server';
import { getSettings } from '@/lib/settings';
import { logApiRequest } from '@/lib/api-logger';
// Revalidation times per fixture type
const REVALIDATE: Record<string, number> = {
live: 30, // live scores — refresh every 30s
last: 300, // recent results — refresh every 5 min
next: 300, // upcoming — refresh every 5 min
};
export async function GET(request: Request) {
const startTime = Date.now();
const { searchParams } = new URL(request.url);
const type = searchParams.get('type') || 'live';
try {
const settings = await getSettings();
const apiKey = settings.apiKey || process.env.API_FOOTBALL_API_KEY;
if (!apiKey) {
return NextResponse.json(
{ error: 'API key not configured' },
{ status: 500 }
);
}
const params = new URLSearchParams({
league: settings.leagueId.toString(),
season: settings.season.toString(),
});
if (type === 'live') {
params.set('live', 'all');
} else if (type === 'last') {
params.set('last', '10');
} else if (type === 'next') {
params.set('next', '10');
}
const revalidate = REVALIDATE[type] ?? 60;
const response = await fetch(
`https://v3.football.api-sports.io/fixtures?${params.toString()}`,
{
headers: {
'x-rapidapi-key': apiKey,
'x-rapidapi-host': 'v3.football.api-sports.io',
},
next: { revalidate },
}
);
const data = await response.json();
const duration = Date.now() - startTime;
// Fire-and-forget log (non-blocking)
logApiRequest(`/fixtures?type=${type}`, response.status, duration);
return NextResponse.json(data, {
headers: {
'Cache-Control': `public, s-maxage=${revalidate}, stale-while-revalidate=${revalidate * 2}`,
},
});
} catch (error) {
const duration = Date.now() - startTime;
logApiRequest(`/fixtures?type=${type}`, 500, duration);
console.error('Fixtures API error:', error);
return NextResponse.json(
{ error: 'Failed to fetch fixtures' },
{ status: 500 }
);
}
}
+30
View File
@@ -0,0 +1,30 @@
export const dynamic = 'force-dynamic';
import { NextResponse } from 'next/server';
import { getSettings } from '@/lib/settings';
export async function GET() {
try {
const settings = await getSettings();
// Return only public settings (no API key)
return NextResponse.json({
siteName: settings.siteName,
selectedLeague: settings.selectedLeague,
primaryColor: settings.primaryColor,
secondaryColor: settings.secondaryColor,
showLiveMatches: settings.showLiveMatches,
showStandings: settings.showStandings,
showTopScorers: settings.showTopScorers,
showRecentResults: settings.showRecentResults,
showUpcoming: settings.showUpcoming,
refreshInterval: settings.refreshInterval,
});
} catch (error) {
console.error('Settings API error:', error);
return NextResponse.json(
{ error: 'Failed to fetch settings' },
{ status: 500 }
);
}
}
+53
View File
@@ -0,0 +1,53 @@
export const dynamic = 'force-dynamic';
import { NextResponse } from 'next/server';
import { getSettings } from '@/lib/settings';
import { logApiRequest } from '@/lib/api-logger';
const REVALIDATE = 300; // 5 minutes
export async function GET() {
const startTime = Date.now();
try {
const settings = await getSettings();
const apiKey = settings.apiKey || process.env.API_FOOTBALL_API_KEY;
if (!apiKey) {
return NextResponse.json(
{ error: 'API key not configured' },
{ status: 500 }
);
}
const response = await fetch(
`https://v3.football.api-sports.io/standings?league=${settings.leagueId}&season=${settings.season}`,
{
headers: {
'x-rapidapi-key': apiKey,
'x-rapidapi-host': 'v3.football.api-sports.io',
},
next: { revalidate: REVALIDATE },
}
);
const data = await response.json();
const duration = Date.now() - startTime;
logApiRequest('/standings', response.status, duration);
return NextResponse.json(data, {
headers: {
'Cache-Control': `public, s-maxage=${REVALIDATE}, stale-while-revalidate=${REVALIDATE * 2}`,
},
});
} catch (error) {
const duration = Date.now() - startTime;
logApiRequest('/standings', 500, duration);
console.error('Standings API error:', error);
return NextResponse.json(
{ error: 'Failed to fetch standings' },
{ status: 500 }
);
}
}
+53
View File
@@ -0,0 +1,53 @@
export const dynamic = 'force-dynamic';
import { NextResponse } from 'next/server';
import { getSettings } from '@/lib/settings';
import { logApiRequest } from '@/lib/api-logger';
const REVALIDATE = 300; // 5 minutes
export async function GET() {
const startTime = Date.now();
try {
const settings = await getSettings();
const apiKey = settings.apiKey || process.env.API_FOOTBALL_API_KEY;
if (!apiKey) {
return NextResponse.json(
{ error: 'API key not configured' },
{ status: 500 }
);
}
const response = await fetch(
`https://v3.football.api-sports.io/players/topscorers?league=${settings.leagueId}&season=${settings.season}`,
{
headers: {
'x-rapidapi-key': apiKey,
'x-rapidapi-host': 'v3.football.api-sports.io',
},
next: { revalidate: REVALIDATE },
}
);
const data = await response.json();
const duration = Date.now() - startTime;
logApiRequest('/topscorers', response.status, duration);
return NextResponse.json(data, {
headers: {
'Cache-Control': `public, s-maxage=${REVALIDATE}, stale-while-revalidate=${REVALIDATE * 2}`,
},
});
} catch (error) {
const duration = Date.now() - startTime;
logApiRequest('/topscorers', 500, duration);
console.error('Top Scorers API error:', error);
return NextResponse.json(
{ error: 'Failed to fetch top scorers' },
{ status: 500 }
);
}
}
+34
View File
@@ -0,0 +1,34 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--primeira-red: #E42518;
--primeira-green: #006600;
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
color: var(--foreground);
background: linear-gradient(to bottom, #f5f5f5, #ffffff);
min-height: 100vh;
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}
/* Suppress hydration errors */
[data-hydration-error] {
display: none !important;
}
+37
View File
@@ -0,0 +1,37 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const dynamic = 'force-dynamic'
export const metadata: Metadata = {
title: 'Primeira Liga Stats - Live Football Statistics',
description: 'Real-time statistics, standings, and scores for Portuguese Primeira Liga',
icons: {
icon: '/favicon.svg',
shortcut: '/favicon.svg',
},
metadataBase: new URL(process.env.NEXTAUTH_URL ?? 'http://localhost:3000'),
openGraph: {
title: 'Primeira Liga Stats',
description: 'Real-time statistics for Portuguese Primeira Liga',
images: ['/og-image.png'],
},
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<head>
<script src="https://apps.abacus.ai/chatllm/appllm-lib.js"></script>
</head>
<body className={inter.className}>{children}</body>
</html>
)
}
+147
View File
@@ -0,0 +1,147 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Settings } from 'lucide-react';
import LiveMatches from './_components/live-matches';
import Standings from './_components/standings';
import TopScorers from './_components/top-scorers';
import RecentResults from './_components/recent-results';
import UpcomingFixtures from './_components/upcoming-fixtures';
import CountdownTimer from './_components/countdown-timer';
import { SettingsContext, defaultSettings, type AppSettings } from '@/lib/settings-context';
export default function HomePage() {
const [settings, setSettings] = useState<AppSettings>(defaultSettings);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchSettings = async () => {
try {
const res = await fetch('/api/settings');
if (res.ok) {
const data = await res.json();
setSettings(data);
}
} catch (error) {
console.error('Failed to fetch settings:', error);
} finally {
setLoading(false);
}
};
fetchSettings();
}, []);
const leagueName =
settings.selectedLeague === 'euro_2026'
? 'UEFA Euro 2024'
: 'Primeira Liga';
const leagueSubtitle =
settings.selectedLeague === 'euro_2026'
? 'European Championship'
: 'Portuguese Championship 2024/2025';
const heroText =
settings.selectedLeague === 'euro_2026'
? 'European Football at Your Fingertips'
: 'Portuguese Football at Your Fingertips';
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-[#006600] border-t-transparent"></div>
</div>
);
}
return (
<SettingsContext.Provider value={settings}>
<main className="min-h-screen pb-16">
{/* Header */}
<header className="sticky top-0 z-50 backdrop-blur-md bg-white/80 border-b border-gray-200 shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-20">
<div className="flex items-center gap-3">
<div
className="w-12 h-12 rounded-full flex items-center justify-center shadow-lg"
style={{
background: `linear-gradient(135deg, ${settings.primaryColor}, ${settings.secondaryColor})`,
}}
>
<svg
className="w-7 h-7 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<circle cx="12" cy="12" r="10" strokeWidth="2" />
<path d="M12 2v20M2 12h20" strokeWidth="2" />
</svg>
</div>
<div>
<h1
className="text-2xl font-bold bg-clip-text text-transparent"
style={{
backgroundImage: `linear-gradient(to right, ${settings.primaryColor}, ${settings.secondaryColor})`,
}}
>
{leagueName}
</h1>
<p className="text-sm text-gray-600">{leagueSubtitle}</p>
</div>
</div>
<div className="flex items-center gap-4">
<CountdownTimer refreshInterval={settings.refreshInterval} />
<Link
href="/admin"
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
title="Admin Panel"
>
<Settings className="h-5 w-5 text-gray-500" />
</Link>
</div>
</div>
</div>
</header>
{/* Hero Section */}
<section
className="text-white py-12"
style={{
background: `linear-gradient(135deg, ${settings.primaryColor}, ${settings.secondaryColor})`,
}}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 className="text-4xl font-bold mb-3">{heroText}</h2>
<p className="text-xl opacity-90">
Real-time scores, standings, and statistics
</p>
</div>
</section>
{/* Main Content */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
{settings.showLiveMatches && <LiveMatches />}
{settings.showStandings && <Standings />}
{settings.showTopScorers && <TopScorers />}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{settings.showRecentResults && <RecentResults />}
{settings.showUpcoming && <UpcomingFixtures />}
</div>
</div>
{/* Footer */}
<footer className="mt-16 py-8 border-t border-gray-200 bg-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center text-gray-600">
<p className="text-sm">
Data updates every {Math.round(settings.refreshInterval / 60)}{' '}
minutes Powered by API-Football
</p>
</div>
</footer>
</main>
</SettingsContext.Provider>
);
}