From 6994063c71dd85eb53ef99ddd6a1308b62411203 Mon Sep 17 00:00:00 2001 From: jpmvaz Date: Sun, 13 Sep 2026 20:20:06 +0100 Subject: [PATCH] v2 --- .dockerignore | 41 ++ Dockerfile | 70 ++ PROJECT_README.txt | 203 +++++ README.md | 697 ++++++++++++++++++ docker-compose.yml | 57 ++ init-db/01-init.sql | 41 ++ nextjs_space/.env.example | 9 + .../app/_components/countdown-timer.tsx | 44 ++ nextjs_space/app/_components/live-matches.tsx | 153 ++++ .../app/_components/recent-results.tsx | 147 ++++ nextjs_space/app/_components/standings.tsx | 147 ++++ nextjs_space/app/_components/top-scorers.tsx | 133 ++++ .../app/_components/upcoming-fixtures.tsx | 136 ++++ .../app/admin/_components/api-stats-chart.tsx | 100 +++ nextjs_space/app/admin/layout.tsx | 14 + nextjs_space/app/admin/page.tsx | 485 ++++++++++++ nextjs_space/app/api/admin/settings/route.ts | 65 ++ nextjs_space/app/api/admin/stats/route.ts | 69 ++ nextjs_space/app/api/fixtures/route.ts | 76 ++ nextjs_space/app/api/settings/route.ts | 30 + nextjs_space/app/api/standings/route.ts | 53 ++ nextjs_space/app/api/topscorers/route.ts | 53 ++ nextjs_space/app/globals.css | 34 + nextjs_space/app/layout.tsx | 37 + nextjs_space/app/page.tsx | 147 ++++ nextjs_space/components.json | 20 + nextjs_space/components/theme-provider.tsx | 9 + nextjs_space/components/ui/accordion.tsx | 58 ++ nextjs_space/components/ui/alert-dialog.tsx | 141 ++++ nextjs_space/components/ui/alert.tsx | 59 ++ nextjs_space/components/ui/aspect-ratio.tsx | 7 + nextjs_space/components/ui/avatar.tsx | 50 ++ nextjs_space/components/ui/badge.tsx | 36 + nextjs_space/components/ui/breadcrumb.tsx | 115 +++ nextjs_space/components/ui/button.tsx | 56 ++ nextjs_space/components/ui/calendar.tsx | 66 ++ nextjs_space/components/ui/card.tsx | 79 ++ nextjs_space/components/ui/carousel.tsx | 262 +++++++ nextjs_space/components/ui/checkbox.tsx | 30 + nextjs_space/components/ui/collapsible.tsx | 11 + nextjs_space/components/ui/command.tsx | 155 ++++ nextjs_space/components/ui/context-menu.tsx | 200 +++++ .../components/ui/date-range-picker.tsx | 65 ++ nextjs_space/components/ui/dialog.tsx | 122 +++ nextjs_space/components/ui/drawer.tsx | 118 +++ nextjs_space/components/ui/dropdown-menu.tsx | 200 +++++ nextjs_space/components/ui/form.tsx | 179 +++++ nextjs_space/components/ui/hover-card.tsx | 29 + nextjs_space/components/ui/input-otp.tsx | 71 ++ nextjs_space/components/ui/input.tsx | 25 + nextjs_space/components/ui/label.tsx | 26 + nextjs_space/components/ui/menubar.tsx | 236 ++++++ .../components/ui/navigation-menu.tsx | 128 ++++ nextjs_space/components/ui/pagination.tsx | 117 +++ nextjs_space/components/ui/popover.tsx | 31 + nextjs_space/components/ui/progress.tsx | 28 + nextjs_space/components/ui/radio-group.tsx | 44 ++ nextjs_space/components/ui/resizable.tsx | 45 ++ nextjs_space/components/ui/scroll-area.tsx | 48 ++ nextjs_space/components/ui/select.tsx | 160 ++++ nextjs_space/components/ui/separator.tsx | 31 + nextjs_space/components/ui/sheet.tsx | 140 ++++ nextjs_space/components/ui/skeleton.tsx | 15 + nextjs_space/components/ui/slider.tsx | 28 + nextjs_space/components/ui/sonner.tsx | 31 + nextjs_space/components/ui/switch.tsx | 29 + nextjs_space/components/ui/table.tsx | 117 +++ nextjs_space/components/ui/tabs.tsx | 55 ++ nextjs_space/components/ui/task-card.tsx | 66 ++ nextjs_space/components/ui/textarea.tsx | 24 + nextjs_space/components/ui/toast.tsx | 129 ++++ nextjs_space/components/ui/toaster.tsx | 35 + nextjs_space/components/ui/toggle-group.tsx | 61 ++ nextjs_space/components/ui/toggle.tsx | 45 ++ nextjs_space/components/ui/tooltip.tsx | 30 + nextjs_space/components/ui/use-toast.ts | 191 +++++ nextjs_space/lib/api-logger.ts | 31 + nextjs_space/lib/db.ts | 18 + nextjs_space/lib/settings-context.tsx | 35 + nextjs_space/lib/settings.ts | 94 +++ nextjs_space/lib/types.ts | 28 + nextjs_space/lib/utils.ts | 14 + nextjs_space/next-env.d.ts | 5 + nextjs_space/next.config.js | 44 ++ nextjs_space/package.json.docker | 49 ++ nextjs_space/postcss.config.js | 6 + nextjs_space/prisma/schema.prisma | 38 + nextjs_space/public/favicon.svg | 12 + nextjs_space/public/og-image.png | Bin 0 -> 63002 bytes nextjs_space/public/robots.txt | 3 + nextjs_space/tailwind.config.ts | 90 +++ nextjs_space/tsconfig.json | 42 ++ start.sh | 10 + 93 files changed, 7613 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 PROJECT_README.txt create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 init-db/01-init.sql create mode 100644 nextjs_space/.env.example create mode 100644 nextjs_space/app/_components/countdown-timer.tsx create mode 100644 nextjs_space/app/_components/live-matches.tsx create mode 100644 nextjs_space/app/_components/recent-results.tsx create mode 100644 nextjs_space/app/_components/standings.tsx create mode 100644 nextjs_space/app/_components/top-scorers.tsx create mode 100644 nextjs_space/app/_components/upcoming-fixtures.tsx create mode 100644 nextjs_space/app/admin/_components/api-stats-chart.tsx create mode 100644 nextjs_space/app/admin/layout.tsx create mode 100644 nextjs_space/app/admin/page.tsx create mode 100644 nextjs_space/app/api/admin/settings/route.ts create mode 100644 nextjs_space/app/api/admin/stats/route.ts create mode 100644 nextjs_space/app/api/fixtures/route.ts create mode 100644 nextjs_space/app/api/settings/route.ts create mode 100644 nextjs_space/app/api/standings/route.ts create mode 100644 nextjs_space/app/api/topscorers/route.ts create mode 100644 nextjs_space/app/globals.css create mode 100644 nextjs_space/app/layout.tsx create mode 100644 nextjs_space/app/page.tsx create mode 100644 nextjs_space/components.json create mode 100644 nextjs_space/components/theme-provider.tsx create mode 100644 nextjs_space/components/ui/accordion.tsx create mode 100644 nextjs_space/components/ui/alert-dialog.tsx create mode 100644 nextjs_space/components/ui/alert.tsx create mode 100644 nextjs_space/components/ui/aspect-ratio.tsx create mode 100644 nextjs_space/components/ui/avatar.tsx create mode 100644 nextjs_space/components/ui/badge.tsx create mode 100644 nextjs_space/components/ui/breadcrumb.tsx create mode 100644 nextjs_space/components/ui/button.tsx create mode 100644 nextjs_space/components/ui/calendar.tsx create mode 100644 nextjs_space/components/ui/card.tsx create mode 100644 nextjs_space/components/ui/carousel.tsx create mode 100644 nextjs_space/components/ui/checkbox.tsx create mode 100644 nextjs_space/components/ui/collapsible.tsx create mode 100644 nextjs_space/components/ui/command.tsx create mode 100644 nextjs_space/components/ui/context-menu.tsx create mode 100644 nextjs_space/components/ui/date-range-picker.tsx create mode 100644 nextjs_space/components/ui/dialog.tsx create mode 100644 nextjs_space/components/ui/drawer.tsx create mode 100644 nextjs_space/components/ui/dropdown-menu.tsx create mode 100644 nextjs_space/components/ui/form.tsx create mode 100644 nextjs_space/components/ui/hover-card.tsx create mode 100644 nextjs_space/components/ui/input-otp.tsx create mode 100644 nextjs_space/components/ui/input.tsx create mode 100644 nextjs_space/components/ui/label.tsx create mode 100644 nextjs_space/components/ui/menubar.tsx create mode 100644 nextjs_space/components/ui/navigation-menu.tsx create mode 100644 nextjs_space/components/ui/pagination.tsx create mode 100644 nextjs_space/components/ui/popover.tsx create mode 100644 nextjs_space/components/ui/progress.tsx create mode 100644 nextjs_space/components/ui/radio-group.tsx create mode 100644 nextjs_space/components/ui/resizable.tsx create mode 100644 nextjs_space/components/ui/scroll-area.tsx create mode 100644 nextjs_space/components/ui/select.tsx create mode 100644 nextjs_space/components/ui/separator.tsx create mode 100644 nextjs_space/components/ui/sheet.tsx create mode 100644 nextjs_space/components/ui/skeleton.tsx create mode 100644 nextjs_space/components/ui/slider.tsx create mode 100644 nextjs_space/components/ui/sonner.tsx create mode 100644 nextjs_space/components/ui/switch.tsx create mode 100644 nextjs_space/components/ui/table.tsx create mode 100644 nextjs_space/components/ui/tabs.tsx create mode 100644 nextjs_space/components/ui/task-card.tsx create mode 100644 nextjs_space/components/ui/textarea.tsx create mode 100644 nextjs_space/components/ui/toast.tsx create mode 100644 nextjs_space/components/ui/toaster.tsx create mode 100644 nextjs_space/components/ui/toggle-group.tsx create mode 100644 nextjs_space/components/ui/toggle.tsx create mode 100644 nextjs_space/components/ui/tooltip.tsx create mode 100644 nextjs_space/components/ui/use-toast.ts create mode 100644 nextjs_space/lib/api-logger.ts create mode 100644 nextjs_space/lib/db.ts create mode 100644 nextjs_space/lib/settings-context.tsx create mode 100644 nextjs_space/lib/settings.ts create mode 100644 nextjs_space/lib/types.ts create mode 100644 nextjs_space/lib/utils.ts create mode 100644 nextjs_space/next-env.d.ts create mode 100644 nextjs_space/next.config.js create mode 100644 nextjs_space/package.json.docker create mode 100644 nextjs_space/postcss.config.js create mode 100644 nextjs_space/prisma/schema.prisma create mode 100644 nextjs_space/public/favicon.svg create mode 100644 nextjs_space/public/og-image.png create mode 100644 nextjs_space/public/robots.txt create mode 100644 nextjs_space/tailwind.config.ts create mode 100644 nextjs_space/tsconfig.json create mode 100644 start.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..49f93ea --- /dev/null +++ b/.dockerignore @@ -0,0 +1,41 @@ +# Dependencies +node_modules +nextjs_space/node_modules + +# Build outputs +.next +nextjs_space/.next +nextjs_space/.build + +# Git +.git +.gitignore + +# IDE +.vscode +.idea + +# Misc +*.log +.DS_Store +*.md +!PROJECT_README.txt + +# Docker +docker-compose.yml +Dockerfile +.dockerignore + +# Test / coverage +coverage +*.test.ts +*.spec.ts +__tests__ + +# Abacus specific +.abacus.donotdelete +*.7z +*.zip + +# TypeScript source maps (not needed in build output) +*.map diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..66bd023 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,70 @@ +# Build stage +FROM node:22-alpine AS builder + +WORKDIR /app + +# Install system deps for Prisma +RUN apk add --no-cache libc6-compat openssl + +# Copy package manifest first (better layer caching) +COPY nextjs_space/package.json.docker ./package.json + +# Reproducible, faster install (omits devDeps in production) +RUN npm ci + +# Copy source code +COPY nextjs_space/app ./app +COPY nextjs_space/lib ./lib +COPY nextjs_space/components ./components +COPY nextjs_space/prisma ./prisma +COPY nextjs_space/public ./public +COPY nextjs_space/next.config.js ./ +COPY nextjs_space/tailwind.config.ts ./ +COPY nextjs_space/tsconfig.json ./ +COPY nextjs_space/postcss.config.js ./ +COPY nextjs_space/next-env.d.ts ./ +COPY nextjs_space/components.json ./ + +# Generate Prisma client +RUN npx prisma generate + +# Build the application +ENV NEXT_TELEMETRY_DISABLED=1 +ENV NEXT_OUTPUT_MODE=standalone +RUN npm run build + +# ── Production stage ───────────────────────────────────────────────────────── +FROM node:22-alpine AS runner + +WORKDIR /app + +# Install OpenSSL for Prisma +RUN apk add --no-cache libc6-compat openssl + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +# Non-root user for security +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 nextjs + +# Copy built assets +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma +COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma + +# Copy startup script +COPY start.sh ./ +RUN chmod +x start.sh + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["./start.sh"] diff --git a/PROJECT_README.txt b/PROJECT_README.txt new file mode 100644 index 0000000..53a46c4 --- /dev/null +++ b/PROJECT_README.txt @@ -0,0 +1,203 @@ +================================================================================ + PRIMEIRA LIGA STATS - DOCKER DEPLOYMENT +================================================================================ + +Project Name: Primeira Liga Stats +Version: 1.0.0 +Created: February 2026 +Framework: Next.js 14 with TypeScript +Database: PostgreSQL 15 (Self-hosted via Docker) + +================================================================================ + QUICK START +================================================================================ + +To deploy the entire application, simply run: + + docker compose up -d + +That's it! The application will be available at: + + Main Website: http://localhost:3000 + Admin Panel: http://localhost:3000/admin + +================================================================================ + WHAT HAPPENS +================================================================================ + +When you run `docker compose up -d`, Docker will: + +1. Create a PostgreSQL 15 database container +2. Initialize the database with all required tables +3. Pre-configure the API key and default settings +4. Build the Next.js application +5. Start the web server on port 3000 + +No manual configuration is required! + +================================================================================ + FEATURES +================================================================================ + +MAIN WEBSITE (/): +- Live match scores with real-time updates +- League standings table with team positions +- Top scorers leaderboard +- Recent match results +- Upcoming fixtures schedule +- Auto-refresh countdown timer (5 minutes default) +- Portuguese national colors theme (red/green) + +ADMIN PANEL (/admin): +- Layout Settings (colors, site name, sections visibility) +- API Key Management (view/change API key) +- League Switcher (Primeira Liga / UEFA Euro) +- API Statistics (request tracking, charts) + +================================================================================ + DOCKER COMMANDS +================================================================================ + +Start the application: + docker compose up -d + +Stop the application: + docker compose down + +View logs: + docker compose logs -f + +View app logs only: + docker compose logs -f app + +View database logs only: + docker compose logs -f db + +Rebuild after changes: + docker compose up -d --build + +Remove everything (including data): + docker compose down -v + +================================================================================ + CONFIGURATION +================================================================================ + +The following environment variables are pre-configured in docker-compose.yml: + + DATABASE_URL - PostgreSQL connection string + API_FOOTBALL_API_KEY - Your API-Football API key + NEXTAUTH_URL - Application URL + NEXTAUTH_SECRET - Session encryption key + +To change the API key: +1. Edit docker-compose.yml and update API_FOOTBALL_API_KEY +2. Run: docker compose up -d + +Or use the Admin Panel at /admin to change it without restarting. + +================================================================================ + DATA PERSISTENCE +================================================================================ + +Database data is stored in a Docker volume named 'postgres_data'. +This means your data persists even when containers are stopped or removed. + +To backup the database: + docker exec primeira_liga_db pg_dump -U primeiraliga primeiraliga > backup.sql + +To restore from backup: + docker exec -i primeira_liga_db psql -U primeiraliga primeiraliga < backup.sql + +================================================================================ + PORT CONFIGURATION +================================================================================ + +Default port: 3000 + +To change the port, edit docker-compose.yml: + + ports: + - "8080:3000" # Change 8080 to your desired port + +================================================================================ + SERVICES +================================================================================ + +The docker-compose setup includes two services: + +1. db (PostgreSQL 15) + - Container name: primeira_liga_db + - Internal port: 5432 + - Database: primeiraliga + - User: primeiraliga + - Password: primeiraliga2024 + +2. app (Next.js Application) + - Container name: primeira_liga_app + - External port: 3000 + - Auto-restarts on failure + +================================================================================ + TROUBLESHOOTING +================================================================================ + +PROBLEM: Container won't start +SOLUTION: Check logs with `docker compose logs -f` + +PROBLEM: Database connection failed +SOLUTION: Wait a few seconds for the database to initialize, then try again + +PROBLEM: Port 3000 already in use +SOLUTION: Change the port in docker-compose.yml or stop the conflicting service + +PROBLEM: API data not loading +SOLUTION: +1. Check API key is valid at api-football.com +2. Check daily API limits (free tier: 100 requests/day) +3. View logs: docker compose logs -f app + +PROBLEM: Changes not appearing after edit +SOLUTION: Rebuild with `docker compose up -d --build` + +================================================================================ + SYSTEM REQUIREMENTS +================================================================================ + +- Docker Engine 20.10+ +- Docker Compose 2.0+ +- 1GB RAM minimum +- 2GB disk space + +================================================================================ + ACCESSING THE DATABASE +================================================================================ + +Connect to PostgreSQL directly: + docker exec -it primeira_liga_db psql -U primeiraliga -d primeiraliga + +Useful SQL commands: + \dt -- List all tables + SELECT * FROM "Settings"; -- View settings + SELECT * FROM "ApiRequestLog" ORDER BY "createdAt" DESC LIMIT 10; + +================================================================================ + API LIMITS +================================================================================ + +API-Football Free Tier: +- 100 requests per day +- Data updates every 15 minutes + +The application refreshes every 5 minutes by default. +You can increase this interval in the Admin Panel to conserve API calls. + +================================================================================ + SUPPORT +================================================================================ + +API-Football: https://www.api-football.com/documentation-v3 +Docker: https://docs.docker.com/ +Next.js: https://nextjs.org/docs + +================================================================================ diff --git a/README.md b/README.md new file mode 100644 index 0000000..e652ea1 --- /dev/null +++ b/README.md @@ -0,0 +1,697 @@ +# Primeira Liga Stats + +A self-hosted, real-time football statistics dashboard for the **Portuguese Primeira Liga** (and optionally UEFA Euro). Built with Next.js 15, PostgreSQL 17, and the [API-Football](https://www.api-football.com/) data provider. The entire stack runs as Docker containers — one command to deploy. + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Tech Stack](#tech-stack) +3. [Architecture](#architecture) +4. [Project Structure](#project-structure) +5. [Prerequisites](#prerequisites) +6. [Quick Start](#quick-start) +7. [Configuration](#configuration) +8. [Environment Variables](#environment-variables) +9. [Features](#features) +10. [Pages & Routes](#pages--routes) +11. [API Endpoints](#api-endpoints) +12. [Database](#database) +13. [Admin Panel](#admin-panel) +14. [Performance & Caching](#performance--caching) +15. [Docker Reference](#docker-reference) +16. [Data Persistence & Backups](#data-persistence--backups) +17. [Changing the Port](#changing-the-port) +18. [API Usage & Rate Limits](#api-usage--rate-limits) +19. [Troubleshooting](#troubleshooting) +20. [Development (Local, without Docker)](#development-local-without-docker) +21. [System Requirements](#system-requirements) + +--- + +## Overview + +Primeira Liga Stats is a production-ready web dashboard that displays live and historical football data. It polls [API-Football v3](https://www.api-football.com/documentation-v3) for match data and presents it in a clean, auto-refreshing UI. All configuration — including the API key, color theme, visible sections, and refresh interval — can be changed at runtime through the built-in Admin Panel without restarting the application. + +--- + +## Tech Stack + +| Layer | Technology | Version | +|---|---|---| +| Framework | Next.js | 15.1.0 | +| Language | TypeScript | 5.7 | +| Runtime | Node.js | 22 LTS (Alpine) | +| Database | PostgreSQL | 17 Alpine | +| ORM | Prisma | 5.22 | +| UI Components | Radix UI | Latest | +| Styling | Tailwind CSS | 3.4 | +| Icons | Lucide React | 0.460 | +| Container Runtime | Docker / Docker Compose | — | +| Data Source | API-Football v3 | — | + +--- + +## Architecture + +``` +┌─────────────────────────────────────────┐ +│ Docker Network │ +│ │ +│ ┌──────────────────┐ ┌─────────────┐ │ +│ │ app container │ │ db container│ │ +│ │ (Next.js 15) │◄─┤ (Postgres17)│ │ +│ │ Node 22 Alpine │ │ │ │ +│ │ Port 3000 │ │ Port 5432 │ │ +│ └────────┬─────────┘ └─────────────┘ │ +│ │ │ +└───────────┼─────────────────────────────┘ + │ + ▼ HTTP + http://localhost:3000 + + ▲ HTTPS + │ + API-Football v3 (external) + v3.football.api-sports.io +``` + +The Next.js app acts as a **backend-for-frontend**: all API-Football requests are proxied through Next.js API routes. The browser never sees the API key. Settings and request logs are stored in PostgreSQL via Prisma. + +--- + +## Project Structure + +``` +primeira_liga_stats/ +├── Dockerfile # Multi-stage Docker build (Node 22) +├── docker-compose.yml # Orchestrates app + db services +├── .dockerignore # Excludes node_modules, .next, etc. +├── start.sh # Container entrypoint: migrate + start +├── init-db/ +│ └── 01-init.sql # Auto-runs on first DB container start +└── nextjs_space/ + ├── package.json.docker # Dependencies (used by Dockerfile) + ├── next.config.js # Next.js config (standalone, compression) + ├── tailwind.config.ts # Tailwind theme + dark mode config + ├── tsconfig.json # TypeScript compiler config + ├── postcss.config.js # PostCSS config + ├── components.json # shadcn/ui component registry + ├── prisma/ + │ └── schema.prisma # Data models: Settings, ApiRequestLog + ├── lib/ + │ ├── db.ts # Prisma client singleton (with dev logging) + │ ├── settings.ts # Settings fetch + 30s in-memory cache + │ ├── settings-context.tsx # React context for settings + │ ├── api-logger.ts # Non-blocking request logger + pruning + │ ├── types.ts # Shared TypeScript types + │ └── utils.ts # Utility helpers (cn, etc.) + ├── app/ + │ ├── layout.tsx # Root layout (Inter font, metadata) + │ ├── page.tsx # Main dashboard page (client component) + │ ├── globals.css # Global CSS + Tailwind directives + │ ├── theme-provider.tsx # next-themes provider + │ ├── _components/ # Page-level UI components + │ │ ├── live-matches.tsx # Live score cards with auto-refresh + │ │ ├── standings.tsx # Full league table + │ │ ├── top-scorers.tsx # Top scorers leaderboard + │ │ ├── recent-results.tsx # Last 10 match results + │ │ ├── upcoming-fixtures.tsx # Next 10 fixtures + │ │ └── countdown-timer.tsx # Refresh countdown in header + │ ├── admin/ + │ │ ├── layout.tsx # Admin layout wrapper + │ │ ├── page.tsx # Admin dashboard + │ │ └── _components/ + │ │ └── api-stats-chart.tsx # Request volume chart + │ └── api/ + │ ├── fixtures/route.ts # GET /api/fixtures?type=live|last|next + │ ├── standings/route.ts # GET /api/standings + │ ├── topscorers/route.ts # GET /api/topscorers + │ ├── settings/route.ts # GET /api/settings (public, no API key) + │ └── admin/ + │ ├── settings/route.ts # GET|PUT /api/admin/settings + │ └── stats/route.ts # GET /api/admin/stats + ├── components/ + │ ├── theme-provider.tsx + │ └── ui/ # Radix-based shadcn/ui components + └── public/ + ├── favicon.svg + └── og-image.png +``` + +--- + +## Prerequisites + +- **Docker Engine** 20.10 or later +- **Docker Compose** v2.0 or later (`docker compose` not `docker-compose`) +- At least **1 GB RAM** available to Docker +- At least **2 GB free disk space** +- An **API-Football API key** (free tier available at [api-football.com](https://www.api-football.com/)) + +--- + +## Quick Start + +```bash +# 1. Clone or unzip the project +cd primeira_liga_stats + +# 2. Start everything +docker compose up -d + +# 3. Open the app (give it ~30 seconds on first run for the build) +open http://localhost:3000 +``` + +The Admin Panel is at `http://localhost:3000/admin`. + +That's it. No other setup is needed. The API key is pre-configured in `docker-compose.yml`. + +--- + +## Configuration + +All configuration lives in two places: + +### 1. `docker-compose.yml` — environment variables (startup time) + +Edit this file to change the API key, port, or secrets before starting the containers. Requires a container restart to take effect. + +### 2. Admin Panel at `/admin` — runtime settings + +Change site name, colors, visible sections, refresh interval, API key, and league without restarting. Settings are persisted to the database and take effect immediately. + +--- + +## Environment Variables + +These are set in the `app` service of `docker-compose.yml`: + +| Variable | Default | Description | +|---|---|---| +| `DATABASE_URL` | `postgresql://primeiraliga:primeiraliga2024@db:5432/primeiraliga` | Full Postgres connection string. Includes pool settings. | +| `API_FOOTBALL_API_KEY` | *(pre-configured)* | Your API-Football v3 key. Can also be set via Admin Panel. | +| `NEXTAUTH_URL` | `http://localhost:3000` | Public URL of the app. Change this when deploying to a domain. | +| `NEXTAUTH_SECRET` | *(pre-configured)* | Secret used to sign sessions. Change this in production. | +| `NODE_ENV` | `production` | Set automatically. Do not change. | + +> **Security note:** Before deploying publicly, generate a new `NEXTAUTH_SECRET` with `openssl rand -base64 32` and update `docker-compose.yml`. + +--- + +## Features + +### Main Dashboard (`/`) + +- **Live Matches** — real-time scores with elapsed time indicator; auto-refreshes every 30 seconds when there are live games +- **League Standings** — full table with position, team logo, played/won/drawn/lost, goal difference, and points +- **Top Scorers** — ranked list of top goal scorers with player photo, team, and statistics +- **Recent Results** — last 10 completed match results +- **Upcoming Fixtures** — next 10 scheduled matches with date and time +- **Countdown Timer** — header widget showing time until next data refresh +- **Configurable theme** — primary and secondary colors applied across gradients and accents + +### Admin Panel (`/admin`) + +- Toggle each dashboard section on/off +- Change site name and brand colors +- Switch between **Primeira Liga** (League ID 94, season 2024) and **UEFA Euro 2024** (League ID 4) +- Update the API-Football API key at runtime +- View API request statistics: total requests, today's requests, average response time, success rate, and an hourly request volume chart + +--- + +## Pages & Routes + +| Route | Type | Description | +|---|---|---| +| `/` | Client page | Main stats dashboard | +| `/admin` | Client page | Admin configuration panel | + +--- + +## API Endpoints + +All routes are internal — they proxy requests to API-Football and return JSON. The browser never sends the API key directly. + +### `GET /api/fixtures?type=` + +Fetches fixtures from API-Football. + +| `type` param | Data returned | Revalidation | +|---|---|---| +| `live` | Currently live matches | 30 seconds | +| `last` | Last 10 completed results | 5 minutes | +| `next` | Next 10 upcoming fixtures | 5 minutes | + +Responses include `Cache-Control: public, s-maxage=N, stale-while-revalidate=2N` headers. + +### `GET /api/standings` + +Returns the full league standings table. Cached for 5 minutes. + +### `GET /api/topscorers` + +Returns the top scorers list. Cached for 5 minutes. + +### `GET /api/settings` + +Returns public settings (no API key). Used by the frontend to load theme, visibility flags, and refresh interval. + +### `GET /api/admin/settings` + +Returns full settings including the API key. Admin use only. + +### `PUT /api/admin/settings` + +Updates settings. Accepts a JSON body with any subset of the settings fields. Automatically busts the server-side settings cache. + +### `GET /api/admin/stats` + +Returns API request analytics: total count, today's count, per-endpoint breakdown, hourly chart data (last 24h), average response time, and success rate. All database queries run in parallel via `Promise.all`. + +--- + +## Database + +### Engine + +PostgreSQL 17 (Alpine), running in its own container (`primeira_liga_db`). + +### Connection + +| Property | Value | +|---|---| +| Host (from app container) | `db` | +| Port | `5432` | +| Database | `primeiraliga` | +| User | `primeiraliga` | +| Password | `primeiraliga2024` | + +### Schema + +Managed by Prisma. There are two tables: + +**`Settings`** — singleton row (`id = 'main'`) storing all runtime configuration. + +| Column | Type | Default | +|---|---|---| +| `id` | TEXT (PK) | `'main'` | +| `apiKey` | TEXT? | `null` | +| `selectedLeague` | TEXT | `'primeira_liga'` | +| `leagueId` | INT | `94` | +| `season` | INT | `2024` | +| `siteName` | TEXT | `'Primeira Liga Stats'` | +| `primaryColor` | TEXT | `'#E42518'` | +| `secondaryColor` | TEXT | `'#006600'` | +| `showLiveMatches` | BOOL | `true` | +| `showStandings` | BOOL | `true` | +| `showTopScorers` | BOOL | `true` | +| `showRecentResults` | BOOL | `true` | +| `showUpcoming` | BOOL | `true` | +| `refreshInterval` | INT | `300` (seconds) | +| `createdAt` | TIMESTAMP | auto | +| `updatedAt` | TIMESTAMP | auto | + +**`ApiRequestLog`** — append-only log of every outbound API-Football call. + +| Column | Type | Description | +|---|---|---| +| `id` | TEXT (PK) | cuid() | +| `endpoint` | TEXT | e.g. `/fixtures?type=live` | +| `status` | INT | HTTP status code | +| `duration` | INT | Milliseconds | +| `createdAt` | TIMESTAMP | Indexed for fast range queries | + +Logs older than 7 days are automatically pruned in the background (at most once per hour). + +### Initialization + +On the very first `docker compose up`, PostgreSQL runs `init-db/01-init.sql` which creates both tables and inserts a default `Settings` row. Subsequent starts skip this file. Prisma's `db push` in `start.sh` ensures the schema stays in sync. + +### Connecting directly + +```bash +docker exec -it primeira_liga_db psql -U primeiraliga -d primeiraliga +``` + +Useful SQL: + +```sql +-- View current settings +SELECT * FROM "Settings"; + +-- View last 20 API requests +SELECT endpoint, status, duration, "createdAt" +FROM "ApiRequestLog" +ORDER BY "createdAt" DESC +LIMIT 20; + +-- Count requests today +SELECT COUNT(*) FROM "ApiRequestLog" +WHERE "createdAt" >= CURRENT_DATE; +``` + +--- + +## Admin Panel + +Navigate to `http://localhost:3000/admin`. + +### Layout Settings tab + +- **Site Name** — displayed in the browser tab and page header +- **Primary Color** — used for live indicators, headings, and gradient left side +- **Secondary Color** — used for gradient right side and accent elements +- **Section toggles** — individually enable/disable Live Matches, Standings, Top Scorers, Recent Results, Upcoming Fixtures +- **Refresh Interval** — how often the dashboard auto-refreshes (in seconds; default 300) + +### API Configuration tab + +- **API Key** — view the currently configured key (masked) or enter a new one. Saved to the database immediately; no restart needed. +- **League Switcher** — toggle between Primeira Liga 2024/25 (League ID 94) and UEFA Euro 2024 (League ID 4) + +### Statistics tab + +- Total API requests logged +- Requests made today +- Average response time (ms) +- Success rate (%) +- Hourly bar chart of the last 24 hours +- Per-endpoint request breakdown + +--- + +## Performance & Caching + +### Server-side settings cache + +`lib/settings.ts` caches the Settings row in process memory for 30 seconds. This means API routes don't make a database round-trip on every request. The cache is immediately invalidated whenever settings are updated via `PUT /api/admin/settings`. + +### Next.js `fetch` cache + +All outbound calls to API-Football use Next.js's extended `fetch` with `next: { revalidate: N }`: + +- Live fixtures: revalidate every **30 seconds** +- Standings, top scorers, last/next fixtures: revalidate every **5 minutes** + +### HTTP Cache-Control headers + +API route responses include `Cache-Control: public, s-maxage=N, stale-while-revalidate=2N`, making them compatible with CDN edge caching if you front the app with Cloudflare or a similar proxy. + +### Non-blocking logging + +`logApiRequest()` in `lib/api-logger.ts` is fire-and-forget — it returns immediately and never adds latency to the response. DB write failures are caught silently. + +### Parallel database queries + +The admin stats endpoint runs all 6 database queries simultaneously with `Promise.all`, reducing response time from ~6× (sequential) to ~1× (parallel) query latency. + +### Docker build layer caching + +`package.json` is copied and `npm ci` is run before any source code is copied, so dependency installation is only re-run when `package.json` changes (not on every code change). + +--- + +## Docker Reference + +### Start + +```bash +docker compose up -d +``` + +### Stop (keep data) + +```bash +docker compose down +``` + +### Stop and delete all data + +```bash +docker compose down -v +``` + +### View all logs (live) + +```bash +docker compose logs -f +``` + +### View app logs only + +```bash +docker compose logs -f app +``` + +### View database logs only + +```bash +docker compose logs -f db +``` + +### Rebuild after code changes + +```bash +docker compose up -d --build +``` + +### Restart only the app (not the DB) + +```bash +docker compose restart app +``` + +### Check container status + +```bash +docker compose ps +``` + +### Container names + +| Container | Service | +|---|---| +| `primeira_liga_app` | Next.js application | +| `primeira_liga_db` | PostgreSQL 17 | + +--- + +## Data Persistence & Backups + +Database data lives in the `postgres_data` Docker volume and persists across `docker compose down` (without `-v`). + +### Backup + +```bash +docker exec primeira_liga_db \ + pg_dump -U primeiraliga primeiraliga > backup_$(date +%Y%m%d).sql +``` + +### Restore + +```bash +docker exec -i primeira_liga_db \ + psql -U primeiraliga primeiraliga < backup_20260101.sql +``` + +### Full reset (wipe and start fresh) + +```bash +docker compose down -v +docker compose up -d +``` + +--- + +## Changing the Port + +Edit `docker-compose.yml` under the `app` service: + +```yaml +ports: + - "8080:3000" # exposes on host port 8080 instead of 3000 +``` + +Also update `NEXTAUTH_URL` to match: + +```yaml +environment: + - NEXTAUTH_URL=http://localhost:8080 +``` + +Then restart: + +```bash +docker compose up -d +``` + +--- + +## API Usage & Rate Limits + +The app uses [API-Football v3](https://www.api-football.com/documentation-v3). + +### Free tier limits + +- **100 requests per day** +- Data updates available every ~15 minutes on the provider side + +### Default request rate in this app + +With the default 5-minute refresh interval and 4 data sections (standings, top scorers, live, last/next fixtures), the app makes roughly **4–5 requests per refresh cycle** = ~58–72 requests per 24 hours. This fits within the free tier. + +### Conserving requests + +- Increase the refresh interval in the Admin Panel (e.g. 600 seconds = 10 minutes) +- Disable sections you don't need (e.g. Top Scorers, Upcoming Fixtures) +- The server-side Next.js `fetch` cache means multiple browser clients hitting the same route share one upstream request + +### Monitoring usage + +The Admin Panel Statistics tab shows how many requests have been made today and in total. + +--- + +## Troubleshooting + +### App won't start + +```bash +docker compose logs -f app +``` + +Common causes: Prisma migration failed (DB not ready yet — `start.sh` waits 5 seconds and the healthcheck retries up to 10 times), or a port conflict. + +### Database connection error + +```bash +docker compose logs -f db +``` + +Wait 10–15 seconds for the DB to initialise on first run. The app container will retry. If it keeps failing, run `docker compose down -v && docker compose up -d` to start fresh. + +### Port 3000 already in use + +```bash +# Find what's using it +lsof -i :3000 + +# Or just change the port in docker-compose.yml +``` + +### API data not loading / blank sections + +1. Verify your API key is valid at [api-football.com](https://www.api-football.com/) +2. Check your daily request limit in the API-Football dashboard +3. Check app logs: `docker compose logs -f app` +4. Open browser DevTools → Network tab and look for failed requests to `/api/*` + +### Changes in `docker-compose.yml` not taking effect + +```bash +docker compose up -d --force-recreate +``` + +### Code changes not reflected + +```bash +docker compose up -d --build +``` + +### Prisma client out of sync after schema change + +The `start.sh` script runs `prisma db push --skip-generate` on every container start, so schema changes are automatically applied. If you update `schema.prisma`, rebuild the image. + +--- + +## Development (Local, without Docker) + +You can run the Next.js app locally for development while pointing at a local or remote Postgres instance. + +### 1. Install dependencies + +```bash +cd nextjs_space + +# Copy the Docker package.json as the local one +cp package.json.docker package.json + +npm install +``` + +### 2. Set up environment + +Create `nextjs_space/.env.local`: + +```env +DATABASE_URL="postgresql://primeiraliga:primeiraliga2024@localhost:5432/primeiraliga" +API_FOOTBALL_API_KEY="your_key_here" +NEXTAUTH_URL="http://localhost:3000" +NEXTAUTH_SECRET="dev-secret-change-in-production" +``` + +### 3. Start a local Postgres (optional — if not using Docker DB) + +```bash +docker run -d \ + --name pg-dev \ + -e POSTGRES_USER=primeiraliga \ + -e POSTGRES_PASSWORD=primeiraliga2024 \ + -e POSTGRES_DB=primeiraliga \ + -p 5432:5432 \ + postgres:17-alpine +``` + +### 4. Push the schema + +```bash +cd nextjs_space +npx prisma db push +``` + +### 5. Start the dev server + +```bash +npm run dev +``` + +App available at `http://localhost:3000`. + +### Available scripts + +| Script | Description | +|---|---| +| `npm run dev` | Start Next.js dev server with hot reload | +| `npm run build` | Generates Prisma client and builds for production | +| `npm run start` | Start the production build | +| `npm run lint` | Run ESLint | +| `npm run db:push` | Sync Prisma schema to DB (no migration files) | +| `npm run db:migrate` | Apply pending Prisma migrations | + +--- + +## System Requirements + +| Requirement | Minimum | Recommended | +|---|---|---| +| Docker Engine | 20.10 | Latest stable | +| Docker Compose | v2.0 | Latest stable | +| RAM (for Docker) | 1 GB | 2 GB | +| Disk space | 2 GB | 4 GB | +| CPU | 1 core | 2 cores | +| OS | Linux, macOS, Windows (WSL2) | Linux | + +--- + +## External Links + +- [API-Football Documentation](https://www.api-football.com/documentation-v3) +- [API-Football Dashboard (check usage)](https://dashboard.api-football.com/) +- [Next.js 15 Docs](https://nextjs.org/docs) +- [Prisma Docs](https://www.prisma.io/docs) +- [Docker Docs](https://docs.docker.com/) +- [Tailwind CSS Docs](https://tailwindcss.com/docs) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..86fda6e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,57 @@ +version: '3.8' + +services: + db: + image: postgres:17-alpine + container_name: primeira_liga_db + restart: unless-stopped + environment: + POSTGRES_USER: primeiraliga + POSTGRES_PASSWORD: primeiraliga2024 + POSTGRES_DB: primeiraliga + volumes: + - postgres_data:/var/lib/postgresql/data + - ./init-db:/docker-entrypoint-initdb.d + healthcheck: + test: ["CMD-SHELL", "pg_isready -U primeiraliga -d primeiraliga"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 10s + deploy: + resources: + limits: + memory: 512m + networks: + - app_network + + app: + build: + context: . + dockerfile: Dockerfile + container_name: primeira_liga_app + restart: unless-stopped + ports: + - "3000:3000" + environment: + - DATABASE_URL=postgresql://primeiraliga:primeiraliga2024@db:5432/primeiraliga?connect_timeout=15&pool_timeout=20&connection_limit=10 + - API_FOOTBALL_API_KEY=54a71050279ccaa37d29625c16f2ef70 + - NEXTAUTH_URL=http://localhost:3000 + - NEXTAUTH_SECRET=primeira-liga-stats-secret-key-2024 + - NODE_ENV=production + depends_on: + db: + condition: service_healthy + deploy: + resources: + limits: + memory: 1g + networks: + - app_network + +volumes: + postgres_data: + +networks: + app_network: + driver: bridge diff --git a/init-db/01-init.sql b/init-db/01-init.sql new file mode 100644 index 0000000..271619a --- /dev/null +++ b/init-db/01-init.sql @@ -0,0 +1,41 @@ +-- Initialize database schema +-- This runs automatically when the PostgreSQL container starts for the first time + +-- Create Settings table +CREATE TABLE IF NOT EXISTS "Settings" ( + "id" TEXT NOT NULL DEFAULT 'main', + "apiKey" TEXT, + "selectedLeague" TEXT NOT NULL DEFAULT 'primeira_liga', + "leagueId" INTEGER NOT NULL DEFAULT 94, + "season" INTEGER NOT NULL DEFAULT 2024, + "siteName" TEXT NOT NULL DEFAULT 'Primeira Liga Stats', + "primaryColor" TEXT NOT NULL DEFAULT '#E42518', + "secondaryColor" TEXT NOT NULL DEFAULT '#006600', + "showLiveMatches" BOOLEAN NOT NULL DEFAULT true, + "showStandings" BOOLEAN NOT NULL DEFAULT true, + "showTopScorers" BOOLEAN NOT NULL DEFAULT true, + "showRecentResults" BOOLEAN NOT NULL DEFAULT true, + "showUpcoming" BOOLEAN NOT NULL DEFAULT true, + "refreshInterval" INTEGER NOT NULL DEFAULT 300, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "Settings_pkey" PRIMARY KEY ("id") +); + +-- Create ApiRequestLog table +CREATE TABLE IF NOT EXISTS "ApiRequestLog" ( + "id" TEXT NOT NULL, + "endpoint" TEXT NOT NULL, + "status" INTEGER NOT NULL, + "duration" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "ApiRequestLog_pkey" PRIMARY KEY ("id") +); + +-- Create index for ApiRequestLog +CREATE INDEX IF NOT EXISTS "ApiRequestLog_createdAt_idx" ON "ApiRequestLog"("createdAt"); + +-- Insert default settings +INSERT INTO "Settings" ("id", "apiKey", "selectedLeague", "leagueId", "season", "siteName", "primaryColor", "secondaryColor", "showLiveMatches", "showStandings", "showTopScorers", "showRecentResults", "showUpcoming", "refreshInterval", "createdAt", "updatedAt") +VALUES ('main', '54a71050279ccaa37d29625c16f2ef70', 'primeira_liga', 94, 2024, 'Primeira Liga Stats', '#E42518', '#006600', true, true, true, true, true, 300, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT ("id") DO NOTHING; diff --git a/nextjs_space/.env.example b/nextjs_space/.env.example new file mode 100644 index 0000000..4f261a3 --- /dev/null +++ b/nextjs_space/.env.example @@ -0,0 +1,9 @@ +# Database Configuration +DATABASE_URL="postgresql://primeiraliga:primeiraliga2024@db:5432/primeiraliga?connect_timeout=15" + +# API Football Configuration +API_FOOTBALL_API_KEY="your_api_key_here" + +# NextAuth Configuration +NEXTAUTH_URL="http://localhost:3000" +NEXTAUTH_SECRET="your-secret-key-here" diff --git a/nextjs_space/app/_components/countdown-timer.tsx b/nextjs_space/app/_components/countdown-timer.tsx new file mode 100644 index 0000000..8ca1ac0 --- /dev/null +++ b/nextjs_space/app/_components/countdown-timer.tsx @@ -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(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 ( +
+ + + Next update: {minutes}:{seconds?.toString()?.padStart(2, '0') ?? '00'} + +
+ ) +} diff --git a/nextjs_space/app/_components/live-matches.tsx b/nextjs_space/app/_components/live-matches.tsx new file mode 100644 index 0000000..75bc1dc --- /dev/null +++ b/nextjs_space/app/_components/live-matches.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 ( +
+
+ +
+
+ ) + } + + if (error) { + return ( +
+
+ + {error} +
+
+ ) + } + + if ((fixtures?.length ?? 0) === 0) { + return ( +
+
+ +

Live Matches

+
+

No live matches at the moment

+
+ ) + } + + return ( +
+
+ +

Live Matches

+ + LIVE + +
+ +
+ {fixtures?.map((fixture) => ( +
+
+ {/* Home Team */} +
+
+ {fixture?.teams?.home?.name +
+ {fixture?.teams?.home?.name} +
+ + {/* Score */} +
+
+ {fixture?.goals?.home ?? 0} + - + {fixture?.goals?.away ?? 0} +
+ {fixture?.fixture?.status?.elapsed ?? 0}' +
+ + {/* Away Team */} +
+ {fixture?.teams?.away?.name} +
+ {fixture?.teams?.away?.name +
+
+
+
+ ))} +
+
+ ) +} diff --git a/nextjs_space/app/_components/recent-results.tsx b/nextjs_space/app/_components/recent-results.tsx new file mode 100644 index 0000000..079cc21 --- /dev/null +++ b/nextjs_space/app/_components/recent-results.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 ( +
+
+ +
+
+ ) + } + + if (error) { + return ( +
+
+ + {error} +
+
+ ) + } + + return ( +
+
+ +

Recent Results

+
+ +
+ {fixtures?.slice(0, 8)?.map((fixture) => { + const date = new Date(fixture?.fixture?.date ?? '') + const formattedDate = date?.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) + + return ( +
+
+
+
+ {fixture?.teams?.home?.name +
+ + {fixture?.teams?.home?.name} + +
+ +
+ (fixture?.goals?.away ?? 0) ? 'text-[#006600]' : 'text-gray-600' + }`}> + {fixture?.goals?.home ?? 0} + + - + (fixture?.goals?.home ?? 0) ? 'text-[#006600]' : 'text-gray-600' + }`}> + {fixture?.goals?.away ?? 0} + +
+ +
+ + {fixture?.teams?.away?.name} + +
+ {fixture?.teams?.away?.name +
+
+
+

{formattedDate}

+
+ ) + })} +
+
+ ) +} diff --git a/nextjs_space/app/_components/standings.tsx b/nextjs_space/app/_components/standings.tsx new file mode 100644 index 0000000..f863c6c --- /dev/null +++ b/nextjs_space/app/_components/standings.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 ( +
+
+ +
+
+ ) + } + + if (error) { + return ( +
+
+ + {error} +
+
+ ) + } + + return ( +
+
+ +

League Standings

+
+ +
+ + + + + + + + + + + + + + + + {standings?.map((team, idx) => ( + = (standings?.length ?? 0) - 2 ? 'bg-red-50/30' : '' + }`} + > + + + + + + + + + + + ))} + +
#TeamPWDLGDPtsForm
{team?.rank} +
+
+ {team?.team?.name +
+ {team?.team?.name} +
+
{team?.all?.played ?? 0}{team?.all?.win ?? 0}{team?.all?.draw ?? 0}{team?.all?.lose ?? 0}{team?.goalsDiff ?? 0}{team?.points ?? 0} +
+ {team?.form?.split('')?.slice(-5)?.map((result, i) => ( +
+ {result} +
+ ))} +
+
+
+
+ ) +} diff --git a/nextjs_space/app/_components/top-scorers.tsx b/nextjs_space/app/_components/top-scorers.tsx new file mode 100644 index 0000000..7af690f --- /dev/null +++ b/nextjs_space/app/_components/top-scorers.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 ( +
+
+ +
+
+ ) + } + + if (error) { + return ( +
+
+ + {error} +
+
+ ) + } + + return ( +
+
+ +

Top Scorers

+
+ +
+ {scorers?.slice(0, 12)?.map((scorer, idx) => { + const stats = scorer?.statistics?.[0] + const goals = stats?.goals?.total ?? 0 + + return ( +
+
+
+ {scorer?.player?.name +
+
+

{scorer?.player?.name}

+
+
+ {stats?.team?.name +
+ {stats?.team?.name} +
+
+
+
+ Goals + {goals} +
+ {idx === 0 && ( +
+ + 👑 Top Scorer + +
+ )} +
+ ) + })} +
+
+ ) +} diff --git a/nextjs_space/app/_components/upcoming-fixtures.tsx b/nextjs_space/app/_components/upcoming-fixtures.tsx new file mode 100644 index 0000000..a920499 --- /dev/null +++ b/nextjs_space/app/_components/upcoming-fixtures.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 ( +
+
+ +
+
+ ) + } + + if (error) { + return ( +
+
+ + {error} +
+
+ ) + } + + return ( +
+
+ +

Upcoming Fixtures

+
+ +
+ {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 ( +
+
+
+
+ {fixture?.teams?.home?.name +
+ + {fixture?.teams?.home?.name} + +
+ +
+ vs +
+ +
+ + {fixture?.teams?.away?.name} + +
+ {fixture?.teams?.away?.name +
+
+
+

+ {formattedDate} • {formattedTime} +

+
+ ) + })} +
+
+ ) +} diff --git a/nextjs_space/app/admin/_components/api-stats-chart.tsx b/nextjs_space/app/admin/_components/api-stats-chart.tsx new file mode 100644 index 0000000..ba3fe05 --- /dev/null +++ b/nextjs_space/app/admin/_components/api-stats-chart.tsx @@ -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(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 ( +
+ No data available yet +
+ ); + } + + return ( +
+

+ Requests Over Time (Last 24 Hours) +

+ +
+ ); +} diff --git a/nextjs_space/app/admin/layout.tsx b/nextjs_space/app/admin/layout.tsx new file mode 100644 index 0000000..96d458c --- /dev/null +++ b/nextjs_space/app/admin/layout.tsx @@ -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}; +} diff --git a/nextjs_space/app/admin/page.tsx b/nextjs_space/app/admin/page.tsx new file mode 100644 index 0000000..00e9630 --- /dev/null +++ b/nextjs_space/app/admin/page.tsx @@ -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(null); + const [stats, setStats] = useState(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(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 = ( + key: K, + value: SettingsData[K] + ) => { + if (settings) { + setSettings({ ...settings, [key]: value }); + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+ + + Back to Site + +
+

+ + Backoffice +

+
+
+ + +
+
+
+ + {error && ( +
+
+ + {error} +
+
+ )} + +
+
+ {/* Layout Settings */} +
+

+ + Layout Settings +

+ +
+
+ + 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" + /> +
+ +
+
+ +
+ + updateSetting('primaryColor', e.target.value) + } + className="w-12 h-10 rounded cursor-pointer" + /> + + 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" + /> +
+
+
+ +
+ + updateSetting('secondaryColor', e.target.value) + } + className="w-12 h-10 rounded cursor-pointer" + /> + + 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" + /> +
+
+
+ +
+ + + 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" + /> +
+ +
+ + {[ + { 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) => ( + + ))} +
+
+
+ + {/* API Key Management */} +
+

+ + API Key Management +

+ +
+
+ +
+ 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" + /> + +
+

+ Get your API key from{' '} + + api-football.com + +

+
+ +
+

+ API Status +

+
+ {settings?.apiKey ? ( + <> +
+ API Key Configured + + ) : ( + <> +
+ No API Key Set + + )} +
+
+
+
+ + {/* League Switcher */} +
+

+ + League Selection +

+ +
+
+ + + +
+ +
+

+ Current Configuration +

+
+
League ID:
+
{settings?.leagueId}
+
Season:
+
{settings?.season}
+
+
+
+
+ + {/* API Statistics */} +
+

+ + API Statistics +

+ +
+
+
+ {stats?.totalRequests || 0} +
+
Total Requests
+
+
+
+ {stats?.requestsToday || 0} +
+
Requests Today
+
+
+
+ {stats?.avgResponseTime || 0}ms +
+
Avg Response Time
+
+
+
+ {stats?.successRate || 100}% +
+
Success Rate
+
+
+ + + + {stats?.requestsByEndpoint && stats.requestsByEndpoint.length > 0 && ( +
+

+ Requests by Endpoint +

+
+ {stats.requestsByEndpoint.map((item) => ( +
+ + {item.endpoint} + + + {item.count} + +
+ ))} +
+
+ )} +
+
+
+
+ ); +} diff --git a/nextjs_space/app/api/admin/settings/route.ts b/nextjs_space/app/api/admin/settings/route.ts new file mode 100644 index 0000000..63fde91 --- /dev/null +++ b/nextjs_space/app/api/admin/settings/route.ts @@ -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 } + ); + } +} diff --git a/nextjs_space/app/api/admin/stats/route.ts b/nextjs_space/app/api/admin/stats/route.ts new file mode 100644 index 0000000..96961d9 --- /dev/null +++ b/nextjs_space/app/api/admin/stats/route.ts @@ -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 = {}; + 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 } + ); + } +} diff --git a/nextjs_space/app/api/fixtures/route.ts b/nextjs_space/app/api/fixtures/route.ts new file mode 100644 index 0000000..c5454ac --- /dev/null +++ b/nextjs_space/app/api/fixtures/route.ts @@ -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 = { + 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 } + ); + } +} diff --git a/nextjs_space/app/api/settings/route.ts b/nextjs_space/app/api/settings/route.ts new file mode 100644 index 0000000..8539369 --- /dev/null +++ b/nextjs_space/app/api/settings/route.ts @@ -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 } + ); + } +} diff --git a/nextjs_space/app/api/standings/route.ts b/nextjs_space/app/api/standings/route.ts new file mode 100644 index 0000000..b950db1 --- /dev/null +++ b/nextjs_space/app/api/standings/route.ts @@ -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 } + ); + } +} diff --git a/nextjs_space/app/api/topscorers/route.ts b/nextjs_space/app/api/topscorers/route.ts new file mode 100644 index 0000000..2bc1abc --- /dev/null +++ b/nextjs_space/app/api/topscorers/route.ts @@ -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 } + ); + } +} diff --git a/nextjs_space/app/globals.css b/nextjs_space/app/globals.css new file mode 100644 index 0000000..3171f5b --- /dev/null +++ b/nextjs_space/app/globals.css @@ -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; +} diff --git a/nextjs_space/app/layout.tsx b/nextjs_space/app/layout.tsx new file mode 100644 index 0000000..41ae30d --- /dev/null +++ b/nextjs_space/app/layout.tsx @@ -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 ( + + + + + {children} + + ) +} diff --git a/nextjs_space/app/page.tsx b/nextjs_space/app/page.tsx new file mode 100644 index 0000000..c48c8e3 --- /dev/null +++ b/nextjs_space/app/page.tsx @@ -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(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 ( +
+
+
+ ); + } + + return ( + +
+ {/* Header */} +
+
+
+
+
+ + + + +
+
+

+ {leagueName} +

+

{leagueSubtitle}

+
+
+
+ + + + +
+
+
+
+ + {/* Hero Section */} +
+
+

{heroText}

+

+ Real-time scores, standings, and statistics +

+
+
+ + {/* Main Content */} +
+ {settings.showLiveMatches && } + {settings.showStandings && } + {settings.showTopScorers && } +
+ {settings.showRecentResults && } + {settings.showUpcoming && } +
+
+ + {/* Footer */} +
+
+

+ Data updates every {Math.round(settings.refreshInterval / 60)}{' '} + minutes • Powered by API-Football +

+
+
+
+
+ ); +} diff --git a/nextjs_space/components.json b/nextjs_space/components.json new file mode 100644 index 0000000..c597462 --- /dev/null +++ b/nextjs_space/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/nextjs_space/components/theme-provider.tsx b/nextjs_space/components/theme-provider.tsx new file mode 100644 index 0000000..8c90fbc --- /dev/null +++ b/nextjs_space/components/theme-provider.tsx @@ -0,0 +1,9 @@ +"use client" + +import * as React from "react" +import { ThemeProvider as NextThemesProvider } from "next-themes" +import { type ThemeProviderProps } from "next-themes/dist/types" + +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + return {children} +} diff --git a/nextjs_space/components/ui/accordion.tsx b/nextjs_space/components/ui/accordion.tsx new file mode 100644 index 0000000..84bf2eb --- /dev/null +++ b/nextjs_space/components/ui/accordion.tsx @@ -0,0 +1,58 @@ +'use client'; + +import * as React from 'react'; +import * as AccordionPrimitive from '@radix-ui/react-accordion'; +import { ChevronDown } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +const Accordion = AccordionPrimitive.Root; + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = 'AccordionItem'; + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180', + className + )} + {...props} + > + {children} + + + +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)); + +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/nextjs_space/components/ui/alert-dialog.tsx b/nextjs_space/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..5cba559 --- /dev/null +++ b/nextjs_space/components/ui/alert-dialog.tsx @@ -0,0 +1,141 @@ +'use client'; + +import * as React from 'react'; +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; + +import { cn } from '@/lib/utils'; +import { buttonVariants } from '@/components/ui/button'; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogHeader.displayName = 'AlertDialogHeader'; + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogFooter.displayName = 'AlertDialogFooter'; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/nextjs_space/components/ui/alert.tsx b/nextjs_space/components/ui/alert.tsx new file mode 100644 index 0000000..d2b59cc --- /dev/null +++ b/nextjs_space/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@/lib/utils'; + +const alertVariants = cva( + 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground', + { + variants: { + variant: { + default: 'bg-background text-foreground', + destructive: + 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = 'Alert'; + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = 'AlertTitle'; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = 'AlertDescription'; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/nextjs_space/components/ui/aspect-ratio.tsx b/nextjs_space/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..aaabffb --- /dev/null +++ b/nextjs_space/components/ui/aspect-ratio.tsx @@ -0,0 +1,7 @@ +'use client'; + +import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio'; + +const AspectRatio = AspectRatioPrimitive.Root; + +export { AspectRatio }; diff --git a/nextjs_space/components/ui/avatar.tsx b/nextjs_space/components/ui/avatar.tsx new file mode 100644 index 0000000..1346957 --- /dev/null +++ b/nextjs_space/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +'use client'; + +import * as React from 'react'; +import * as AvatarPrimitive from '@radix-ui/react-avatar'; + +import { cn } from '@/lib/utils'; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/nextjs_space/components/ui/badge.tsx b/nextjs_space/components/ui/badge.tsx new file mode 100644 index 0000000..2eb790a --- /dev/null +++ b/nextjs_space/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@/lib/utils'; + +const badgeVariants = cva( + 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + { + variants: { + variant: { + default: + 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80', + secondary: + 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', + destructive: + 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', + outline: 'text-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge, badgeVariants }; diff --git a/nextjs_space/components/ui/breadcrumb.tsx b/nextjs_space/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..8b62197 --- /dev/null +++ b/nextjs_space/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from 'react'; +import { Slot } from '@radix-ui/react-slot'; +import { ChevronRight, MoreHorizontal } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<'nav'> & { + separator?: React.ReactNode; + } +>(({ ...props }, ref) =>