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
+41
View File
@@ -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
+70
View File
@@ -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"]
+203
View File
@@ -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
================================================================================
+697
View File
@@ -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=<live|last|next>`
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 **45 requests per refresh cycle** = ~5872 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 1015 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)
+57
View File
@@ -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
+41
View File
@@ -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;
+9
View File
@@ -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"
@@ -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>
);
}
+20
View File
@@ -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"
}
}
@@ -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 <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
+58
View File
@@ -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<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn('border-b', className)}
{...props}
/>
));
AccordionItem.displayName = 'AccordionItem';
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn('pb-4 pt-0', className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
+141
View File
@@ -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<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-2 text-center sm:text-left',
className
)}
{...props}
/>
);
AlertDialogHeader.displayName = 'AlertDialogHeader';
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
);
AlertDialogFooter.displayName = 'AlertDialogFooter';
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold', className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: 'outline' }),
'mt-2 sm:mt-0',
className
)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};
+59
View File
@@ -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<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
));
Alert.displayName = 'Alert';
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
{...props}
/>
));
AlertTitle.displayName = 'AlertTitle';
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('text-sm [&_p]:leading-relaxed', className)}
{...props}
/>
));
AlertDescription.displayName = 'AlertDescription';
export { Alert, AlertTitle, AlertDescription };
@@ -0,0 +1,7 @@
'use client';
import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio';
const AspectRatio = AspectRatioPrimitive.Root;
export { AspectRatio };
+50
View File
@@ -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<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full',
className
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn('aspect-square h-full w-full', className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
'flex h-full w-full items-center justify-center rounded-full bg-muted',
className
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };
+36
View File
@@ -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<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };
+115
View File
@@ -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) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
Breadcrumb.displayName = 'Breadcrumb';
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<'ol'>
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
'flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5',
className
)}
{...props}
/>
));
BreadcrumbList.displayName = 'BreadcrumbList';
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<'li'>
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn('inline-flex items-center gap-1.5', className)}
{...props}
/>
));
BreadcrumbItem.displayName = 'BreadcrumbItem';
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<'a'> & {
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : 'a';
return (
<Comp
ref={ref}
className={cn('transition-colors hover:text-foreground', className)}
{...props}
/>
);
});
BreadcrumbLink.displayName = 'BreadcrumbLink';
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<'span'>
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn('font-normal text-foreground', className)}
{...props}
/>
));
BreadcrumbPage.displayName = 'BreadcrumbPage';
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<'li'>) => (
<li
role="presentation"
aria-hidden="true"
className={cn('[&>svg]:size-3.5', className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
BreadcrumbSeparator.displayName = 'BreadcrumbSeparator';
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<'span'>) => (
<span
role="presentation"
aria-hidden="true"
className={cn('flex h-9 w-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
);
BreadcrumbEllipsis.displayName = 'BreadcrumbElipssis';
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};
+56
View File
@@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
+66
View File
@@ -0,0 +1,66 @@
'use client';
import * as React from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { DayPicker } from 'react-day-picker';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn('p-3', className)}
classNames={{
months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
month: 'space-y-4',
caption: 'flex justify-center pt-1 relative items-center',
caption_label: 'text-sm font-medium',
nav: 'space-x-1 flex items-center',
nav_button: cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100'
),
nav_button_previous: 'absolute left-1',
nav_button_next: 'absolute right-1',
table: 'w-full border-collapse space-y-1',
head_row: 'flex',
head_cell:
'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]',
row: 'flex w-full mt-2',
cell: 'h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20',
day: cn(
buttonVariants({ variant: 'ghost' }),
'h-9 w-9 p-0 font-normal aria-selected:opacity-100'
),
day_range_end: 'day-range-end',
day_selected:
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
day_today: 'bg-accent text-accent-foreground',
day_outside:
'day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30',
day_disabled: 'text-muted-foreground opacity-50',
day_range_middle:
'aria-selected:bg-accent aria-selected:text-accent-foreground',
day_hidden: 'invisible',
...classNames,
}}
components={{
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
);
}
Calendar.displayName = 'Calendar';
export { Calendar };
+79
View File
@@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+262
View File
@@ -0,0 +1,262 @@
'use client';
import * as React from 'react';
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from 'embla-carousel-react';
import { ArrowLeft, ArrowRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: 'horizontal' | 'vertical';
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error('useCarousel must be used within a <Carousel />');
}
return context;
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = 'horizontal',
opts,
setApi,
plugins,
className,
children,
...props
},
ref
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === 'horizontal' ? 'x' : 'y',
},
plugins
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return;
}
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'ArrowLeft') {
event.preventDefault();
scrollPrev();
} else if (event.key === 'ArrowRight') {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext]
);
React.useEffect(() => {
if (!api || !setApi) {
return;
}
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) {
return;
}
onSelect(api);
api.on('reInit', onSelect);
api.on('select', onSelect);
return () => {
api?.off('select', onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn('relative', className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
}
);
Carousel.displayName = 'Carousel';
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel();
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
'flex',
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
className
)}
{...props}
/>
</div>
);
});
CarouselContent.displayName = 'CarouselContent';
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel();
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
'min-w-0 shrink-0 grow-0 basis-full',
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
className
)}
{...props}
/>
);
});
CarouselItem.displayName = 'CarouselItem';
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
'absolute h-8 w-8 rounded-full',
orientation === 'horizontal'
? '-left-12 top-1/2 -translate-y-1/2'
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
);
});
CarouselPrevious.displayName = 'CarouselPrevious';
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
'absolute h-8 w-8 rounded-full',
orientation === 'horizontal'
? '-right-12 top-1/2 -translate-y-1/2'
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
);
});
CarouselNext.displayName = 'CarouselNext';
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};
+30
View File
@@ -0,0 +1,30 @@
'use client';
import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils';
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn('flex items-center justify-center text-current')}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };
@@ -0,0 +1,11 @@
'use client';
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
+155
View File
@@ -0,0 +1,155 @@
'use client';
import * as React from 'react';
import { type DialogProps } from '@radix-ui/react-dialog';
import { Command as CommandPrimitive } from 'cmdk';
import { Search } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Dialog, DialogContent } from '@/components/ui/dialog';
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
className
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
className
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn('-mx-1 h-px bg-border', className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
className
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground',
className
)}
{...props}
/>
);
};
CommandShortcut.displayName = 'CommandShortcut';
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};
+200
View File
@@ -0,0 +1,200 @@
'use client';
import * as React from 'react';
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
const ContextMenu = ContextMenuPrimitive.Root;
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
const ContextMenuGroup = ContextMenuPrimitive.Group;
const ContextMenuPortal = ContextMenuPrimitive.Portal;
const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
inset && 'pl-8',
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
));
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
));
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
{...props}
/>
));
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
));
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName;
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
));
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-sm font-semibold text-foreground',
inset && 'pl-8',
className
)}
{...props}
/>
));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
));
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground',
className
)}
{...props}
/>
);
};
ContextMenuShortcut.displayName = 'ContextMenuShortcut';
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};
@@ -0,0 +1,65 @@
import * as React from "react";
import { format } from "date-fns";
import { Calendar as CalendarIcon } from "lucide-react";
import { DateRange } from "react-day-picker";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
interface DateRangePickerProps {
value: DateRange;
onChange: (value: DateRange) => void;
className?: string;
}
export function DateRangePicker({
value,
onChange,
className,
}: DateRangePickerProps) {
return (
<div className={cn("grid gap-2", className)}>
<Popover>
<PopoverTrigger asChild>
<Button
id="date"
variant={"outline"}
className={cn(
"w-[300px] justify-start text-left font-normal",
!value && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{value?.from ? (
value.to ? (
<>
{format(value.from, "LLL dd, y")} -{" "}
{format(value.to, "LLL dd, y")}
</>
) : (
format(value.from, "LLL dd, y")
)
) : (
<span>Pick a date range</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
initialFocus
mode="range"
defaultMonth={value?.from}
selected={value}
onSelect={(value) => onChange(value as DateRange)}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
</div>
);
}
+122
View File
@@ -0,0 +1,122 @@
'use client';
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-1.5 text-center sm:text-left',
className
)}
{...props}
/>
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
+118
View File
@@ -0,0 +1,118 @@
'use client';
import * as React from 'react';
import { Drawer as DrawerPrimitive } from 'vaul';
import { cn } from '@/lib/utils';
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
);
Drawer.displayName = 'Drawer';
const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerPortal = DrawerPrimitive.Portal;
const DrawerClose = DrawerPrimitive.Close;
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn('fixed inset-0 z-50 bg-black/80', className)}
{...props}
/>
));
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
'fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background',
className
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
));
DrawerContent.displayName = 'DrawerContent';
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('grid gap-1.5 p-4 text-center sm:text-left', className)}
{...props}
/>
);
DrawerHeader.displayName = 'DrawerHeader';
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
);
DrawerFooter.displayName = 'DrawerFooter';
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
));
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};
@@ -0,0 +1,200 @@
'use client';
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
inset && 'pl-8',
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-sm font-semibold',
inset && 'pl-8',
className
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};
+179
View File
@@ -0,0 +1,179 @@
'use client';
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from '@radix-ui/react-slot';
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from 'react-hook-form';
import { cn } from '@/lib/utils';
import { Label } from '@/components/ui/label';
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>');
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
);
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn('space-y-2', className)} {...props} />
</FormItemContext.Provider>
);
});
FormItem.displayName = 'FormItem';
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return (
<Label
ref={ref}
className={cn(error && 'text-destructive', className)}
htmlFor={formItemId}
{...props}
/>
);
});
FormLabel.displayName = 'FormLabel';
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
});
FormControl.displayName = 'FormControl';
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return (
<p
ref={ref}
id={formDescriptionId}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
);
});
FormDescription.displayName = 'FormDescription';
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;
if (!body) {
return null;
}
return (
<p
ref={ref}
id={formMessageId}
className={cn('text-sm font-medium text-destructive', className)}
{...props}
>
{body}
</p>
);
});
FormMessage.displayName = 'FormMessage';
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};
+29
View File
@@ -0,0 +1,29 @@
'use client';
import * as React from 'react';
import * as HoverCardPrimitive from '@radix-ui/react-hover-card';
import { cn } from '@/lib/utils';
const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger;
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
));
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent };
+71
View File
@@ -0,0 +1,71 @@
'use client';
import * as React from 'react';
import { OTPInput, OTPInputContext } from 'input-otp';
import { Dot } from 'lucide-react';
import { cn } from '@/lib/utils';
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
'flex items-center gap-2 has-[:disabled]:opacity-50',
containerClassName
)}
className={cn('disabled:cursor-not-allowed', className)}
{...props}
/>
));
InputOTP.displayName = 'InputOTP';
const InputOTPGroup = React.forwardRef<
React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<'div'>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center', className)} {...props} />
));
InputOTPGroup.displayName = 'InputOTPGroup';
const InputOTPSlot = React.forwardRef<
React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<'div'> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
return (
<div
ref={ref}
className={cn(
'relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md',
isActive && 'z-10 ring-2 ring-ring ring-offset-background',
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
);
});
InputOTPSlot.displayName = 'InputOTPSlot';
const InputOTPSeparator = React.forwardRef<
React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<'div'>
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Dot />
</div>
));
InputOTPSeparator.displayName = 'InputOTPSeparator';
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
+25
View File
@@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
+26
View File
@@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
+236
View File
@@ -0,0 +1,236 @@
'use client';
import * as React from 'react';
import * as MenubarPrimitive from '@radix-ui/react-menubar';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
const MenubarMenu = MenubarPrimitive.Menu;
const MenubarGroup = MenubarPrimitive.Group;
const MenubarPortal = MenubarPrimitive.Portal;
const MenubarSub = MenubarPrimitive.Sub;
const MenubarRadioGroup = MenubarPrimitive.RadioGroup;
const Menubar = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Root
ref={ref}
className={cn(
'flex h-10 items-center space-x-1 rounded-md border bg-background p-1',
className
)}
{...props}
/>
));
Menubar.displayName = MenubarPrimitive.Root.displayName;
const MenubarTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Trigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
className
)}
{...props}
/>
));
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;
const MenubarSubTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
inset && 'pl-8',
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
));
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
const MenubarSubContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
));
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;
const MenubarContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
>(
(
{ className, align = 'start', alignOffset = -4, sideOffset = 8, ...props },
ref
) => (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
ref={ref}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</MenubarPrimitive.Portal>
)
);
MenubarContent.displayName = MenubarPrimitive.Content.displayName;
const MenubarItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
{...props}
/>
));
MenubarItem.displayName = MenubarPrimitive.Item.displayName;
const MenubarCheckboxItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<MenubarPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
));
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;
const MenubarRadioItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<MenubarPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
));
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;
const MenubarLabel = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-sm font-semibold',
inset && 'pl-8',
className
)}
{...props}
/>
));
MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
const MenubarSeparator = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
const MenubarShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground',
className
)}
{...props}
/>
);
};
MenubarShortcut.displayname = 'MenubarShortcut';
export {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarLabel,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarPortal,
MenubarSubContent,
MenubarSubTrigger,
MenubarGroup,
MenubarSub,
MenubarShortcut,
};
@@ -0,0 +1,128 @@
import * as React from 'react';
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu';
import { cva } from 'class-variance-authority';
import { ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn(
'relative z-10 flex max-w-max flex-1 items-center justify-center',
className
)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
));
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName;
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
'group flex flex-1 list-none items-center justify-center space-x-1',
className
)}
{...props}
/>
));
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
const NavigationMenuItem = NavigationMenuPrimitive.Item;
const navigationMenuTriggerStyle = cva(
'group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50'
);
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), 'group', className)}
{...props}
>
{children}{' '}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
));
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName;
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
'left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ',
className
)}
{...props}
/>
));
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName;
const NavigationMenuLink = NavigationMenuPrimitive.Link;
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn('absolute left-0 top-full flex justify-center')}>
<NavigationMenuPrimitive.Viewport
className={cn(
'origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]',
className
)}
ref={ref}
{...props}
/>
</div>
));
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName;
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
'top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in',
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
));
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName;
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
};
+117
View File
@@ -0,0 +1,117 @@
import * as React from 'react';
import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';
import { cn } from '@/lib/utils';
import { ButtonProps, buttonVariants } from '@/components/ui/button';
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => (
<nav
role="navigation"
aria-label="pagination"
className={cn('mx-auto flex w-full justify-center', className)}
{...props}
/>
);
Pagination.displayName = 'Pagination';
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<'ul'>
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn('flex flex-row items-center gap-1', className)}
{...props}
/>
));
PaginationContent.displayName = 'PaginationContent';
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<'li'>
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn('', className)} {...props} />
));
PaginationItem.displayName = 'PaginationItem';
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<ButtonProps, 'size'> &
React.ComponentProps<'a'>;
const PaginationLink = ({
className,
isActive,
size = 'icon',
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? 'page' : undefined}
className={cn(
buttonVariants({
variant: isActive ? 'outline' : 'ghost',
size,
}),
className
)}
{...props}
/>
);
PaginationLink.displayName = 'PaginationLink';
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn('gap-1 pl-2.5', className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
);
PaginationPrevious.displayName = 'PaginationPrevious';
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn('gap-1 pr-2.5', className)}
{...props}
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
);
PaginationNext.displayName = 'PaginationNext';
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<'span'>) => (
<span
aria-hidden
className={cn('flex h-9 w-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
);
PaginationEllipsis.displayName = 'PaginationEllipsis';
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
};
+31
View File
@@ -0,0 +1,31 @@
'use client';
import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '@/lib/utils';
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent };
+28
View File
@@ -0,0 +1,28 @@
'use client';
import * as React from 'react';
import * as ProgressPrimitive from '@radix-ui/react-progress';
import { cn } from '@/lib/utils';
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
'relative h-4 w-full overflow-hidden rounded-full bg-secondary',
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };
@@ -0,0 +1,44 @@
'use client';
import * as React from 'react';
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
import { Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn('grid gap-2', className)}
{...props}
ref={ref}
/>
);
});
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
'aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
});
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem };
+45
View File
@@ -0,0 +1,45 @@
'use client';
import { GripVertical } from 'lucide-react';
import * as ResizablePrimitive from 'react-resizable-panels';
import { cn } from '@/lib/utils';
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
'flex h-full w-full data-[panel-group-direction=vertical]:flex-col',
className
)}
{...props}
/>
);
const ResizablePanel = ResizablePrimitive.Panel;
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
'relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90',
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
@@ -0,0 +1,48 @@
'use client';
import * as React from 'react';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cn } from '@/lib/utils';
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' &&
'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' &&
'h-2.5 flex-col border-t border-t-transparent p-[1px]',
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };
+160
View File
@@ -0,0 +1,160 @@
'use client';
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils';
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};
+31
View File
@@ -0,0 +1,31 @@
'use client';
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '@/lib/utils';
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = 'horizontal', decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className
)}
{...props}
/>
)
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };
+140
View File
@@ -0,0 +1,140 @@
'use client';
import * as React from 'react';
import * as SheetPrimitive from '@radix-ui/react-dialog';
import { cva, type VariantProps } from 'class-variance-authority';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
{
variants: {
side: {
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
bottom:
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
right:
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
},
},
defaultVariants: {
side: 'right',
},
}
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = 'right', className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
));
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-2 text-center sm:text-left',
className
)}
{...props}
/>
);
SheetHeader.displayName = 'SheetHeader';
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
);
SheetFooter.displayName = 'SheetFooter';
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold text-foreground', className)}
{...props}
/>
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};
+15
View File
@@ -0,0 +1,15 @@
import { cn } from '@/lib/utils';
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('animate-pulse rounded-md bg-muted', className)}
{...props}
/>
);
}
export { Skeleton };
+28
View File
@@ -0,0 +1,28 @@
'use client';
import * as React from 'react';
import * as SliderPrimitive from '@radix-ui/react-slider';
import { cn } from '@/lib/utils';
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
'relative flex w-full touch-none select-none items-center',
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };
+31
View File
@@ -0,0 +1,31 @@
'use client';
import { useTheme } from 'next-themes';
import { Toaster as Sonner } from 'sonner';
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme();
return (
<Sonner
theme={theme as ToasterProps['theme']}
className="toaster group"
toastOptions={{
classNames: {
toast:
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
description: 'group-[.toast]:text-muted-foreground',
actionButton:
'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton:
'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
},
}}
{...props}
/>
);
};
export { Toaster };
+29
View File
@@ -0,0 +1,29 @@
'use client';
import * as React from 'react';
import * as SwitchPrimitives from '@radix-ui/react-switch';
import { cn } from '@/lib/utils';
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };
+117
View File
@@ -0,0 +1,117 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
));
Table.displayName = 'Table';
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
));
TableHeader.displayName = 'TableHeader';
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
));
TableBody.displayName = 'TableBody';
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0',
className
)}
{...props}
/>
));
TableFooter.displayName = 'TableFooter';
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
className
)}
{...props}
/>
));
TableRow.displayName = 'TableRow';
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
className
)}
{...props}
/>
));
TableHead.displayName = 'TableHead';
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
{...props}
/>
));
TableCell.displayName = 'TableCell';
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn('mt-4 text-sm text-muted-foreground', className)}
{...props}
/>
));
TableCaption.displayName = 'TableCaption';
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};
+55
View File
@@ -0,0 +1,55 @@
'use client';
import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from '@/lib/utils';
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };
+66
View File
@@ -0,0 +1,66 @@
import { useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from './card'
import { Badge } from './badge'
import { Checkbox } from './checkbox'
import { Button } from './button'
import { Pencil, Trash2 } from 'lucide-react'
import { motion } from 'framer-motion'
interface TaskCardProps {
id: string
title: string
description?: string
category: string
completed: boolean
onComplete: (id: string, completed: boolean) => void
onDelete: (id: string) => void
onEdit: (id: string) => void
}
export function TaskCard({
id,
title,
description,
category,
completed,
onComplete,
onDelete,
onEdit,
}: TaskCardProps) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2 }}
>
<Card className={`w-full ${completed ? 'opacity-60' : ''}`}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="flex items-center space-x-2">
<Checkbox
checked={completed}
onCheckedChange={(checked) => onComplete(id, checked as boolean)}
/>
<CardTitle className={`text-lg ${completed ? 'line-through' : ''}`}>
{title}
</CardTitle>
</div>
<div className="flex space-x-2">
<Button variant="ghost" size="icon" onClick={() => onEdit(id)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => onDelete(id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
{description && <p className="text-sm text-muted-foreground">{description}</p>}
<Badge variant="secondary" className="mt-2">
{category}
</Badge>
</CardContent>
</Card>
</motion.div>
)
}
+24
View File
@@ -0,0 +1,24 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
);
}
);
Textarea.displayName = 'Textarea';
export { Textarea };
+129
View File
@@ -0,0 +1,129 @@
'use client';
import * as React from 'react';
import * as ToastPrimitives from '@radix-ui/react-toast';
import { cva, type VariantProps } from 'class-variance-authority';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
className
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
{
variants: {
variant: {
default: 'border bg-background text-foreground',
destructive:
'destructive group border-destructive bg-destructive text-destructive-foreground',
},
},
defaultVariants: {
variant: 'default',
},
}
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
className
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn('text-sm font-semibold', className)}
{...props}
/>
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn('text-sm opacity-90', className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};
+35
View File
@@ -0,0 +1,35 @@
'use client';
import { useToast } from '@/hooks/use-toast';
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from '@/components/ui/toast';
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
}
@@ -0,0 +1,61 @@
'use client';
import * as React from 'react';
import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group';
import { type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { toggleVariants } from '@/components/ui/toggle';
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: 'default',
variant: 'default',
});
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn('flex items-center justify-center gap-1', className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
));
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
});
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
export { ToggleGroup, ToggleGroupItem };
+45
View File
@@ -0,0 +1,45 @@
'use client';
import * as React from 'react';
import * as TogglePrimitive from '@radix-ui/react-toggle';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const toggleVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground',
{
variants: {
variant: {
default: 'bg-transparent',
outline:
'border border-input bg-transparent hover:bg-accent hover:text-accent-foreground',
},
size: {
default: 'h-10 px-3',
sm: 'h-9 px-2.5',
lg: 'h-11 px-5',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
));
Toggle.displayName = TogglePrimitive.Root.displayName;
export { Toggle, toggleVariants };
+30
View File
@@ -0,0 +1,30 @@
'use client';
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@/lib/utils';
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+191
View File
@@ -0,0 +1,191 @@
'use client';
// Inspired by react-hot-toast library
import * as React from 'react';
import type { ToastActionElement, ToastProps } from '@/components/ui/toast';
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: 'ADD_TOAST',
UPDATE_TOAST: 'UPDATE_TOAST',
DISMISS_TOAST: 'DISMISS_TOAST',
REMOVE_TOAST: 'REMOVE_TOAST',
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType['ADD_TOAST'];
toast: ToasterToast;
}
| {
type: ActionType['UPDATE_TOAST'];
toast: Partial<ToasterToast>;
}
| {
type: ActionType['DISMISS_TOAST'];
toastId?: ToasterToast['id'];
}
| {
type: ActionType['REMOVE_TOAST'];
toastId?: ToasterToast['id'];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: 'REMOVE_TOAST',
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'ADD_TOAST':
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case 'UPDATE_TOAST':
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
};
case 'DISMISS_TOAST': {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
};
}
case 'REMOVE_TOAST':
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, 'id'>;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: 'UPDATE_TOAST',
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
dispatch({
type: 'ADD_TOAST',
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
};
}
export { useToast, toast };
+31
View File
@@ -0,0 +1,31 @@
import prisma from './db';
/**
* Logs an API request to the database.
* This is intentionally fire-and-forget it never blocks the response path.
*/
export function logApiRequest(
endpoint: string,
status: number,
duration: number
): void {
prisma.apiRequestLog
.create({ data: { endpoint, status, duration } })
.catch((error) => console.error('Failed to log API request:', error));
// Prune old logs in the background (keep last 7 days)
pruneOldLogs();
}
let lastPruneAt = 0;
const PRUNE_INTERVAL = 60 * 60 * 1000; // prune at most once per hour
function pruneOldLogs(): void {
if (Date.now() - lastPruneAt < PRUNE_INTERVAL) return;
lastPruneAt = Date.now();
const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
prisma.apiRequestLog
.deleteMany({ where: { createdAt: { lt: cutoff } } })
.catch((error) => console.error('Failed to prune API logs:', error));
}
+18
View File
@@ -0,0 +1,18 @@
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log:
process.env.NODE_ENV === 'development'
? ['query', 'error', 'warn']
: ['error'],
});
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
export default prisma;
+35
View File
@@ -0,0 +1,35 @@
'use client';
import { createContext, useContext } from 'react';
export interface AppSettings {
siteName: string;
selectedLeague: string;
primaryColor: string;
secondaryColor: string;
showLiveMatches: boolean;
showStandings: boolean;
showTopScorers: boolean;
showRecentResults: boolean;
showUpcoming: boolean;
refreshInterval: number;
}
export const defaultSettings: AppSettings = {
siteName: 'Primeira Liga Stats',
selectedLeague: 'primeira_liga',
primaryColor: '#E42518',
secondaryColor: '#006600',
showLiveMatches: true,
showStandings: true,
showTopScorers: true,
showRecentResults: true,
showUpcoming: true,
refreshInterval: 300,
};
export const SettingsContext = createContext<AppSettings>(defaultSettings);
export function useSettings() {
return useContext(SettingsContext);
}
+94
View File
@@ -0,0 +1,94 @@
import prisma from './db';
export interface AppSettings {
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;
}
const defaultSettings: AppSettings = {
id: 'main',
apiKey: null,
selectedLeague: 'primeira_liga',
leagueId: 94,
season: 2024,
siteName: 'Primeira Liga Stats',
primaryColor: '#E42518',
secondaryColor: '#006600',
showLiveMatches: true,
showStandings: true,
showTopScorers: true,
showRecentResults: true,
showUpcoming: true,
refreshInterval: 300,
};
// In-memory cache to avoid a DB round-trip on every API call
let settingsCache: AppSettings | null = null;
let settingsCacheAt = 0;
const SETTINGS_CACHE_TTL = 30_000; // 30 seconds
export async function getSettings(): Promise<AppSettings> {
// Return from cache if still fresh
if (settingsCache && Date.now() - settingsCacheAt < SETTINGS_CACHE_TTL) {
return settingsCache;
}
try {
let settings = await prisma.settings.findUnique({
where: { id: 'main' },
});
if (!settings) {
// Create default settings with API key from env
settings = await prisma.settings.create({
data: {
...defaultSettings,
apiKey: process.env.API_FOOTBALL_API_KEY || null,
},
});
}
settingsCache = settings as AppSettings;
settingsCacheAt = Date.now();
return settingsCache;
} catch (error) {
console.error('Failed to get settings:', error);
return {
...defaultSettings,
apiKey: process.env.API_FOOTBALL_API_KEY || null,
};
}
}
/** Call this after any settings update to bust the cache. */
export function invalidateSettingsCache() {
settingsCache = null;
settingsCacheAt = 0;
}
export function getLeagueConfig(selectedLeague: string) {
if (selectedLeague === 'euro_2026') {
return {
leagueId: 4, // EURO Championship
season: 2024, // Euro 2024 (most recent)
name: 'UEFA Euro 2024',
};
}
return {
leagueId: 94, // Portuguese Primeira Liga
season: 2024,
name: 'Primeira Liga',
};
}
+28
View File
@@ -0,0 +1,28 @@
export type Expense = {
id: string
amount: number
category: string
description: string
date: Date
}
export type ExpenseFormData = Omit<Expense, 'id' | 'date'> & {
date: string
}
export const EXPENSE_CATEGORIES = [
'Food',
'Transportation',
'Housing',
'Utilities',
'Entertainment',
'Healthcare',
'Shopping',
'Education',
'Other'
] as const
export type DateRange = {
from: Date | undefined
to: Date | undefined
}
+14
View File
@@ -0,0 +1,14 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const remainingSeconds = seconds % 60
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`
}
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
+44
View File
@@ -0,0 +1,44 @@
const path = require('path');
/** @type {import('next').NextConfig} */
const nextConfig = {
distDir: process.env.NEXT_DIST_DIR || '.next',
output: process.env.NEXT_OUTPUT_MODE,
outputFileTracingRoot: path.join(__dirname, '../'),
eslint: {
ignoreDuringBuilds: true,
},
typescript: {
ignoreBuildErrors: false,
},
images: {
unoptimized: true,
remotePatterns: [
{
protocol: 'https',
hostname: 'media.api-sports.io',
},
{
protocol: 'https',
hostname: 'media-1.api-sports.io',
},
{
protocol: 'https',
hostname: 'media-2.api-sports.io',
},
{
protocol: 'https',
hostname: 'media-3.api-sports.io',
},
],
},
// Compression and performance
compress: true,
poweredByHeader: false,
// Faster page loads via HTTP keep-alive
httpAgentOptions: {
keepAlive: true,
},
};
module.exports = nextConfig;
+49
View File
@@ -0,0 +1,49 @@
{
"name": "primeira-liga-stats",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "prisma generate && next build",
"start": "next start",
"lint": "next lint",
"db:push": "prisma db push",
"db:migrate": "prisma migrate deploy"
},
"dependencies": {
"@prisma/client": "^5.22.0",
"@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-tooltip": "^1.1.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.460.0",
"next": "15.1.0",
"next-auth": "^4.24.10",
"next-themes": "^0.4.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@types/node": "^22.9.0",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"prisma": "^5.22.0",
"tailwindcss": "^3.4.15",
"typescript": "^5.7.2"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+38
View File
@@ -0,0 +1,38 @@
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "linux-musl-openssl-3.0.x", "linux-musl-arm64-openssl-3.0.x"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Settings {
id String @id @default("main")
apiKey String?
selectedLeague String @default("primeira_liga") // primeira_liga or euro_2026
leagueId Int @default(94)
season Int @default(2024)
siteName String @default("Primeira Liga Stats")
primaryColor String @default("#E42518")
secondaryColor String @default("#006600")
showLiveMatches Boolean @default(true)
showStandings Boolean @default(true)
showTopScorers Boolean @default(true)
showRecentResults Boolean @default(true)
showUpcoming Boolean @default(true)
refreshInterval Int @default(300) // seconds
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ApiRequestLog {
id String @id @default(cuid())
endpoint String
status Int
duration Int // milliseconds
createdAt DateTime @default(now())
@@index([createdAt])
}
+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<circle cx="128" cy="128" r="120" fill="#006600"/>
<path d="M128 8 A120 120 0 0 1 248 128 L128 128 Z" fill="#FF0000"/>
<path d="M128 68 L158 98 L148 138 L108 138 L98 98 Z" fill="#FFD700" stroke="#000" stroke-width="3"/>
<circle cx="128" cy="103" r="8" fill="#000"/>
<polygon points="128,138 108,168 88,158 98,138" fill="#FFF" stroke="#000" stroke-width="2"/>
<polygon points="128,138 148,138 168,158 148,168" fill="#FFF" stroke="#000" stroke-width="2"/>
<polygon points="128,68 148,48 168,68 158,98" fill="#FFF" stroke="#000" stroke-width="2"/>
<polygon points="128,68 98,98 88,68 108,48" fill="#FFF" stroke="#000" stroke-width="2"/>
<polygon points="158,98 178,108 178,138 148,138" fill="#FFF" stroke="#000" stroke-width="2"/>
<polygon points="98,98 108,138 78,138 78,108" fill="#FFF" stroke="#000" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 908 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

+3
View File
@@ -0,0 +1,3 @@
# Allow all web crawlers to access all content
User-agent: *
Allow: /
+90
View File
@@ -0,0 +1,90 @@
import type { Config } from 'tailwindcss';
const config: Config = {
darkMode: ['class'],
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
'gradient-conic':
'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))',
},
},
keyframes: {
'accordion-down': {
from: {
height: '0',
},
to: {
height: 'var(--radix-accordion-content-height)',
},
},
'accordion-up': {
from: {
height: 'var(--radix-accordion-content-height)',
},
to: {
height: '0',
},
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
},
},
plugins: [require('tailwindcss-animate')],
};
export default config;
+42
View File
@@ -0,0 +1,42 @@
{
"compilerOptions": {
"target": "es2020",
"lib": [
"dom",
"dom.iterable",
"es5",
"es2020"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".build/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
echo "Waiting for database to be ready..."
sleep 5
echo "Running database migrations..."
npx prisma db push --skip-generate
echo "Starting application..."
node server.js