Neuere Version

This commit is contained in:
Marc Wieland 2026-01-23 10:32:02 +01:00
parent f67d869005
commit da018a070d
101 changed files with 12784 additions and 2398 deletions

View File

@ -1,16 +0,0 @@
# Git
.git
.gitignore
.github
# Docker
Dockerfile
docker-compose.yml
.dockerignore
# IDE
.vscode
.idea
# Dokumentation (optional - auskommentieren falls benötigt)
# README.md

View File

@ -1,112 +0,0 @@
# Copilot Instructions for Turnierplaner
## Project Overview
**Turnierplaner** (Tournament Planner) is a German-language, client-side volleyball tournament management app for the "NVJ" organization. It handles team registration, round-robin rotation logic, live scoring, and leaderboards for two leagues: "Bundesliga" and "Champions League".
### Tech Stack
- **Frontend**: Vanilla HTML, CSS, JavaScript (ES6+)
- **Persistence**: LocalStorage for all data (no backend)
- **UI Pattern**: Multi-page app with shared state via localStorage
- **Build**: None - runs directly in browser via file:// or static server
## Architecture & Key Components
### Two-Page Structure
1. **[index.html](index.html)** + [script.js](script.js): Team entry interface
- Add/remove teams with name, club, and motto fields
- Set field count (used to calculate fields per league)
- Export/import tournament configuration as JSON
- Navigate to planning view with "Weiter →"
2. **[planning.html](planning.html)** + [planning.js](planning.js): Live tournament management
- Display current round matchups on fields (grid layout: field number | team 1 | team 2)
- Show waiting teams below the playing fields
- Live score input per match with automatic point calculation
- Round rotation system with "Nächste Runde" button
- Timer with custom input (MM:SS or seconds)
- Modals for: Points History, Scoreboard (rankings), Reset confirmation
### Data Flow & State Management
**Three localStorage Keys** ([planning.js](planning.js#L1-L3)):
- `turnierplaner_data`: Teams + field count (shared between pages)
- `turnierplaner_rotation`: Current round number + team order per league
- `turnierplaner_scores`: Match results keyed by `"league:fieldNum"`
**Critical Logic**: Field allocation in [planning.js](planning.js#L63-L76)
```javascript
// fieldCount = number of physical fields (from user input)
// Each field has 2 halves, so max playing teams = fieldCount * 2
const maxPlayingTeams = fieldCount * 2;
const playingTeamsCount = Math.min(teams.length, maxPlayingTeams);
const fieldsPerLeague = Math.ceil(playingTeamsCount / 2);
```
**Round Rotation** ([planning.js](planning.js#L574-L611)): Three rotation strategies based on waiting team count:
- **Fall 1** (0 waiting, teams === fieldCount * 2): Team at index 0 stays, team at index 1 moves to end
- **Fall 2** (1 waiting, teams === fieldCount * 2 + 1): All rotate by 1, first team goes to end
- **Fall 3** (2+ waiting, teams > fieldCount * 2 + 1): Swap playing/waiting blocks entirely
**Point Calculation** ([planning.js](planning.js#L544-L565)): Winner gets `|score1 - score2| + 2`, loser gets `-|score1 - score2|`, tie = 0
## Development Workflows
### Running Locally
```bash
# No build step - open directly in browser
start index.html # Windows
# Or use a local server for better CORS handling
npx serve .
```
### Testing Changes
1. Open browser DevTools → Application → Local Storage
2. Clear `turnierplaner_*` keys to reset state
3. Use Export/Import JSON to preserve test data
### Debugging Rotation Logic
Add breakpoints in [planning.js](planning.js#L574) `rotateLeague()`. Check `rotationState.teamOrder` vs actual team indices.
## Code Conventions & Patterns
### Naming & Structure
- German UI text (labels, buttons, alerts), English code (variables, functions)
- Global functions (no modules): `onclick="functionName()"` in HTML
- Modal pattern: `open{Name}Modal()` / `close{Name}Modal()` with `display: block/none`
- Auto-save: Input event listeners trigger `saveData()` ([script.js](script.js#L94-L99))
### Score Input Pattern ([planning.js](planning.js#L116-L120))
```javascript
// Inline onchange in field card HTML
onchange="updateMatchScore('bundesliga', 1, this.value, otherInput.value)"
```
### XSS Prevention
Use `escapeHtml()` ([script.js](script.js#L203-L210)) for user input in team names/clubs/mottos
### Mobile-First CSS ([styles.css](styles.css))
- `viewport-fit=cover` for iOS safe areas
- Touch-optimized: `min-height: 44px`, `touch-action: manipulation`
- Responsive grid: `grid-template-columns: 1fr 1fr``1fr` on mobile
## Integration Points & Dependencies
### External Dependencies
None - pure vanilla JavaScript
### Browser APIs
- **LocalStorage**: All persistence ([script.js](script.js#L1), [planning.js](planning.js#L1-L3))
- **File API**: JSON import via `<input type="file">` ([script.js](script.js#L180-L200))
- **Blob API**: JSON export download ([script.js](script.js#L166-L177))
### Cross-Page Communication
Exclusively via localStorage keys. [index.html](index.html) writes `turnierplaner_data`, [planning.html](planning.html) reads it on load ([planning.js](planning.js#L12-L14)).
## Important Gotchas
1. **Field Count Logic**: `fieldCount` from user input = physical fields. Each field has 2 halves, so max `playingTeamsCount = fieldCount * 2`
2. **Field Numbering Offset**: Champions League fields start after Bundesliga fields ([planning.js](planning.js#L65-L69))
3. **Round State Mismatch**: If `rotationState` deleted but `matchScores` exists, scores reference wrong teams
4. **Timer Doesn't Persist**: Timer state (`timerSeconds`, `isRunning`) not saved to localStorage
5. **Deletion Side Effects**: Deleting teams in [index.html](index.html) doesn't invalidate [planning.html](planning.html) rotation state

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

101
DOCKER.md
View File

@ -1,101 +0,0 @@
# Turnierplaner - Docker Deployment
## Docker Setup
Die Anwendung ist als statischer Container mit nginx konfiguriert.
### Voraussetzungen
- Docker Desktop für Windows installiert und gestartet
- Docker Compose (ist in Docker Desktop enthalten)
### Anwendung starten
**Option 1: Mit Docker Compose (empfohlen)**
```powershell
# Container bauen und starten
docker-compose up -d
# Logs anzeigen
docker-compose logs -f
# Container stoppen
docker-compose down
```
**Option 2: Mit Docker direkt**
```powershell
# Image bauen
docker build -t turnierplaner:latest .
# Container starten
docker run -d -p 8080:80 --name turnierplaner turnierplaner:latest
# Container stoppen
docker stop turnierplaner
docker rm turnierplaner
```
### Anwendung aufrufen
Nach dem Start ist die Anwendung erreichbar unter:
- **http://localhost:8080**
### Nützliche Befehle
```powershell
# Container Status prüfen
docker ps
# Container Logs anzeigen
docker logs turnierplaner
# In Container einsteigen (für Debugging)
docker exec -it turnierplaner sh
# Image neu bauen (nach Änderungen)
docker-compose build
docker-compose up -d
# Alle Container/Images aufräumen
docker-compose down
docker system prune -a
```
### Port ändern
Falls Port 8080 bereits belegt ist, ändere in [docker-compose.yml](docker-compose.yml):
```yaml
ports:
- "3000:80" # Statt 8080
```
### Daten-Persistenz
Die Anwendung speichert alle Daten im Browser LocalStorage. Die Daten bleiben auch nach Container-Neustart erhalten, solange du denselben Browser verwendest.
### Troubleshooting
**Port bereits belegt:**
```powershell
# Prüfe welcher Prozess Port 8080 nutzt
netstat -ano | findstr :8080
# Stoppe den Prozess oder ändere den Port in docker-compose.yml
```
**Container startet nicht:**
```powershell
# Prüfe Logs
docker logs turnierplaner
# Prüfe ob Docker Desktop läuft
docker ps
```
**Nach Code-Änderungen:**
```powershell
# Container stoppen und neu bauen
docker-compose down
docker-compose build --no-cache
docker-compose up -d
```

View File

@ -1,18 +0,0 @@
# Multi-stage build für minimales Image
FROM nginx:alpine
# Kopiere alle statischen Dateien
COPY index.html /usr/share/nginx/html/
COPY planning.html /usr/share/nginx/html/
COPY script.js /usr/share/nginx/html/
COPY planning.js /usr/share/nginx/html/
COPY styles.css /usr/share/nginx/html/
# Kopiere nginx Konfiguration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Exponiere Port 80
EXPOSE 80
# nginx läuft automatisch im Vordergrund
CMD ["nginx", "-g", "daemon off;"]

73
README.md Normal file
View File

@ -0,0 +1,73 @@
# Welcome to your Lovable project
## Project info
**URL**: https://lovable.dev/projects/REPLACE_WITH_PROJECT_ID
## How can I edit this code?
There are several ways of editing your application.
**Use Lovable**
Simply visit the [Lovable Project](https://lovable.dev/projects/REPLACE_WITH_PROJECT_ID) and start prompting.
Changes made via Lovable will be committed automatically to this repo.
**Use your preferred IDE**
If you want to work locally using your own IDE, you can clone this repo and push changes. Pushed changes will also be reflected in Lovable.
The only requirement is having Node.js & npm installed - [install with nvm](https://github.com/nvm-sh/nvm#installing-and-updating)
Follow these steps:
```sh
# Step 1: Clone the repository using the project's Git URL.
git clone <YOUR_GIT_URL>
# Step 2: Navigate to the project directory.
cd <YOUR_PROJECT_NAME>
# Step 3: Install the necessary dependencies.
npm i
# Step 4: Start the development server with auto-reloading and an instant preview.
npm run dev
```
**Edit a file directly in GitHub**
- Navigate to the desired file(s).
- Click the "Edit" button (pencil icon) at the top right of the file view.
- Make your changes and commit the changes.
**Use GitHub Codespaces**
- Navigate to the main page of your repository.
- Click on the "Code" button (green button) near the top right.
- Select the "Codespaces" tab.
- Click on "New codespace" to launch a new Codespace environment.
- Edit files directly within the Codespace and commit and push your changes once you're done.
## What technologies are used for this project?
This project is built with:
- Vite
- TypeScript
- React
- shadcn-ui
- Tailwind CSS
## How can I deploy this project?
Simply open [Lovable](https://lovable.dev/projects/REPLACE_WITH_PROJECT_ID) and click on Share -> Publish.
## Can I connect a custom domain to my Lovable project?
Yes, you can!
To connect a domain, navigate to Project > Settings > Domains and click Connect Domain.
Read more here: [Setting up a custom domain](https://docs.lovable.dev/features/custom-domain#custom-domain)

BIN
bun.lockb Normal file

Binary file not shown.

20
components.json Normal file
View File

@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

View File

@ -1,17 +0,0 @@
version: '3.8'
services:
turnierplaner:
build:
context: .
dockerfile: Dockerfile
container_name: turnierplaner
ports:
- "18080:80"
restart: unless-stopped
networks:
- turnierplaner-network
networks:
turnierplaner-network:
driver: bridge

26
eslint.config.js Normal file
View File

@ -0,0 +1,26 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"@typescript-eslint/no-unused-vars": "off",
},
},
);

View File

@ -1,64 +1,24 @@
<!DOCTYPE html>
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1.0, user-scalable=no">
<title>Turnierplaner - Team-Eingabe</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="container">
<h1>🏐 NVJ Turnierplaner</h1>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NVJ Turnierplaner Volleyball-Turniere organisieren</title>
<meta name="description" content="Organisiere Volleyball-Turniere einfach und übersichtlich mit dem NVJ Turnierplaner. Bundesliga und Champions League." />
<meta name="author" content="NVJ" />
<div class="settings">
<div class="settings-group">
<label for="fieldCount">Anzahl Felder:</label>
<input type="number" id="fieldCount" min="1" value="1" placeholder="z.B. 4">
</div>
</div>
<meta property="og:title" content="NVJ Turnierplaner" />
<meta property="og:description" content="Volleyball-Turniere einfach organisieren" />
<meta property="og:type" content="website" />
<meta property="og:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
<div class="leagues-container">
<!-- Bundesliga -->
<div class="league">
<div class="league-header">
<div class="league-title">
<h2>Bundesliga</h2>
<div class="team-count" id="bundesliga-count">0 Teams</div>
</div>
<button class="add-team-btn" onclick="addTeam('bundesliga')">+</button>
</div>
<div class="teams-list" id="bundesliga-list">
<div class="empty-message">Keine Teams hinzugefügt. Klicke auf + um zu beginnen!</div>
</div>
</div>
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@Lovable" />
<meta name="twitter:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
</head>
<!-- Champions League -->
<div class="league">
<div class="league-header">
<div class="league-title">
<h2>Champions League</h2>
<div class="team-count" id="champions-count">0 Teams</div>
</div>
<button class="add-team-btn" onclick="addTeam('champions')">+</button>
</div>
<div class="teams-list" id="champions-list">
<div class="empty-message">Keine Teams hinzugefügt. Klicke auf + um zu beginnen!</div>
</div>
</div>
</div>
<div class="export-section">
<div class="export-buttons">
<button class="export-btn" onclick="exportData()">📥 Daten exportieren (JSON)</button>
<button class="export-btn" onclick="importData()">📤 Daten importieren</button>
<input type="file" id="import-file" accept=".json" style="display: none;" onchange="handleFileImport(event)">
</div>
<button class="next-btn" onclick="navigateToPlanning()">Weiter →</button>
</div>
<div id="export-output"></div>
</div>
<script src="script.js"></script>
</body>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -1,28 +0,0 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip Kompression für bessere Performance
gzip on;
gzip_types text/css application/javascript application/json;
gzip_min_length 1000;
location / {
try_files $uri $uri/ /index.html;
}
# Cache-Control Header für statische Assets
location ~* \.(js|css)$ {
expires 1d;
add_header Cache-Control "public, immutable";
}
# Keine Cache für HTML-Dateien
location ~* \.(html)$ {
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
}
}

6692
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

89
package.json Normal file
View File

@ -0,0 +1,89 @@
{
"name": "vite_react_shadcn_ts",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build:dev": "vite build --mode development",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@hookform/resolvers": "^3.10.0",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-aspect-ratio": "^1.1.7",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-context-menu": "^2.2.15",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-hover-card": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-menubar": "^1.1.15",
"@radix-ui/react-navigation-menu": "^1.2.13",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toast": "^1.2.14",
"@radix-ui/react-toggle": "^1.1.9",
"@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7",
"@tanstack/react-query": "^5.83.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.462.0",
"next-themes": "^0.3.0",
"react": "^18.3.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.61.1",
"react-resizable-panels": "^2.1.9",
"react-router-dom": "^6.30.1",
"recharts": "^2.15.4",
"sonner": "^1.7.4",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.9",
"zod": "^3.25.76"
},
"devDependencies": {
"@eslint/js": "^9.32.0",
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.0.0",
"@tailwindcss/typography": "^0.5.16",
"@types/node": "^22.16.5",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react-swc": "^3.11.0",
"autoprefixer": "^10.4.21",
"eslint": "^9.32.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^15.15.0",
"jsdom": "^20.0.3",
"lovable-tagger": "^1.1.13",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.3",
"typescript-eslint": "^8.38.0",
"vite": "^5.4.19",
"vitest": "^3.2.4"
}
}

View File

@ -1,721 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1.0, user-scalable=no">
<title>Turnierplaner - Planung</title>
<link rel="stylesheet" href="styles.css">
<style>
.planning-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 15px;
background: white;
border-radius: 8px;
margin-bottom: 15px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
flex-shrink: 0;
gap: 10px;
}
.back-btn {
background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%);
color: white;
border: none;
padding: 10px 16px;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
min-height: 44px;
touch-action: manipulation;
transition: transform 0.3s;
white-space: nowrap;
}
.back-btn:hover {
transform: translateY(-2px);
}
.menu-bar {
display: flex;
gap: 10px;
background: white;
padding: 12px 15px;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
flex-shrink: 0;
flex-wrap: wrap;
}
.menu-btn {
background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%);
color: white;
border: none;
padding: 12px 20px;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
font-size: 0.95em;
min-height: 44px;
touch-action: manipulation;
transition: transform 0.3s;
white-space: nowrap;
}
.menu-btn:hover {
transform: translateY(-2px);
}
.timer-section {
display: flex;
align-items: center;
gap: 10px;
background: #f8f9fa;
padding: 10px 15px;
border-radius: 4px;
}
.timer-display {
font-size: 1.5em;
font-weight: bold;
color: #2ecc71;
font-family: 'Courier New', monospace;
min-width: 80px;
}
.timer-control-btn {
background: #2ecc71;
color: white;
border: none;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
min-height: 40px;
touch-action: manipulation;
font-size: 0.85em;
}
.timer-control-btn:hover {
background: #27ae60;
}
.timer-input {
width: 100px;
padding: 8px 10px;
border: 2px solid #2ecc71;
border-radius: 4px;
font-size: 0.95em;
text-align: center;
min-height: 40px;
}
.timer-input:focus {
outline: none;
border-color: #27ae60;
box-shadow: 0 0 0 3px rgba(46, 204, 113, 0.1);
}
.fields-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
margin-bottom: 15px;
flex: 1;
overflow: hidden;
}
@media (max-width: 768px) {
.fields-container {
grid-template-columns: 1fr;
}
}
.fields-section {
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
height: 100%;
}
.fields-header {
padding: 15px;
background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%);
color: white;
flex-shrink: 0;
}
.fields-header h3 {
margin: 0;
font-size: 1.2em;
}
.fields-grid {
padding: 15px;
overflow-y: auto;
flex: 1;
display: grid;
grid-template-columns: 120px 1fr 1fr;
gap: 12px;
-webkit-overflow-scrolling: touch;
}
.field-info-box {
background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%);
color: white;
padding: 12px;
border-radius: 6px;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
font-weight: 600;
min-height: 60px;
border: 2px solid #1e8449;
font-size: 1.2em;
}
.field-info-number {
font-size: 1.2em;
}
.field-info-team {
display: none;
-webkit-box-orient: vertical;
}
.waiting-teams-section {
background: #f8f9fa;
padding: 15px;
border-top: 2px solid #e0e0e0;
flex-shrink: 0;
}
.waiting-teams-label {
font-weight: 600;
color: #333;
margin-bottom: 10px;
font-size: 0.95em;
}
.waiting-teams-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.waiting-team-badge {
background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%);
color: white;
padding: 8px 12px;
border-radius: 20px;
font-size: 0.85em;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 6px;
}
.waiting-team-badge::before {
content: "⏳";
}
.no-waiting-teams {
color: #999;
font-style: italic;
font-size: 0.9em;
}
.field-card {
background: #f8f9fa;
padding: 15px;
border-radius: 6px;
border: 2px solid #e0e0e0;
cursor: pointer;
transition: all 0.3s;
text-align: center;
min-height: 80px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.field-card:hover {
border-color: #2ecc71;
background: #f0fdf4;
transform: translateY(-2px);
}
.field-card-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
}
.team-name {
font-size: 0.9em;
color: #333;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.score-input {
width: 50px;
padding: 6px;
font-size: 0.95em;
border: 1px solid #ddd;
border-radius: 4px;
text-align: center;
}
/* Modal Styles */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
animation: fadeIn 0.3s;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal-content {
background: white;
margin: 5% auto;
padding: 20px;
border-radius: 8px;
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
animation: slideIn 0.3s;
}
@keyframes slideIn {
from {
transform: translateY(-50px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #2ecc71;
}
.modal-header h2 {
margin: 0;
font-size: 1.5em;
color: #333;
}
.close-btn {
background: none;
border: none;
font-size: 1.8em;
cursor: pointer;
color: #999;
transition: color 0.3s;
}
.close-btn:hover {
color: #333;
}
.modal-form {
display: flex;
flex-direction: column;
gap: 15px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-weight: 600;
color: #333;
}
.form-group input,
.form-group select {
padding: 12px;
border: 2px solid #ddd;
border-radius: 4px;
font-size: 1em;
min-height: 44px;
font-family: inherit;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #2ecc71;
box-shadow: 0 0 0 3px rgba(46, 204, 113, 0.1);
}
.modal-footer {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 20px;
padding-top: 15px;
border-top: 2px solid #f0f0f0;
}
.modal-footer button {
padding: 12px 24px;
border: none;
border-radius: 4px;
font-weight: 600;
cursor: pointer;
min-height: 44px;
touch-action: manipulation;
transition: transform 0.3s;
}
.cancel-btn {
background: #f0f0f0;
color: #333;
}
.cancel-btn:hover {
transform: translateY(-2px);
}
.submit-btn {
background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%);
color: white;
}
.submit-btn:hover {
transform: translateY(-2px);
}
.points-display-content {
display: flex;
flex-direction: column;
gap: 30px;
max-height: 70vh;
overflow-y: auto;
}
.league-points-section {
border: 2px solid #2ecc71;
border-radius: 8px;
overflow: hidden;
}
.league-points-header {
background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%);
color: white;
padding: 12px 15px;
font-weight: 600;
font-size: 1.05em;
}
.points-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9em;
}
.points-table th,
.points-table td {
padding: 10px;
border: 1px solid #e0e0e0;
text-align: center;
}
.points-table th {
background: #f0fdf4;
font-weight: 600;
color: #2ecc71;
}
.points-table td:first-child,
.points-table th:first-child {
text-align: left;
padding-left: 12px;
min-width: 120px;
}
.points-table tr:nth-child(even) {
background: #fafbfc;
}
.points-table tr:hover {
background: #f0fdf4;
}
.points-value {
font-weight: 600;
padding: 6px 8px;
border-radius: 4px;
min-width: 40px;
}
.points-value.positive {
background: #d4edda;
color: #155724;
}
.points-value.negative {
background: #f8d7da;
color: #721c24;
}
.points-value.neutral {
background: #e2e3e5;
color: #383d41;
}
.points-total {
font-weight: 700;
background: #2ecc71;
color: white;
border-radius: 4px;
}
.scoreboard-content {
display: flex;
flex-direction: column;
gap: 20px;
max-height: 70vh;
overflow-y: auto;
}
.scoreboard-league {
border: 2px solid #3498db;
border-radius: 8px;
overflow: hidden;
}
.scoreboard-league-header {
background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
color: white;
padding: 12px 15px;
font-weight: 600;
font-size: 1.05em;
}
.scoreboard-list {
list-style: none;
padding: 0;
margin: 0;
}
.scoreboard-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 15px;
border-bottom: 1px solid #e0e0e0;
transition: background 0.2s;
}
.scoreboard-item:last-child {
border-bottom: none;
}
.scoreboard-item:hover {
background: #f0f8ff;
}
.scoreboard-rank {
font-size: 1.3em;
font-weight: 700;
color: #3498db;
min-width: 35px;
text-align: center;
}
.scoreboard-team-name {
flex: 1;
margin: 0 15px;
font-weight: 500;
color: #333;
}
.scoreboard-points {
background: #3498db;
color: white;
padding: 6px 12px;
border-radius: 20px;
font-weight: 700;
min-width: 70px;
text-align: center;
}
.scoreboard-points.positive {
background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%);
}
.scoreboard-points.negative {
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
}
</style>
</head>
<body>
<div class="container">
<div class="planning-header">
<button class="back-btn" onclick="goBack()">← Zurück</button>
<h1 style="flex: 1; text-align: center; margin: 0; font-size: 1.3em; color: #333;">🏐 Turnierplanung</h1>
</div>
<div class="menu-bar">
<button class="menu-btn" onclick="openPointsModal()">📊 Punkte anzeigen</button>
<button class="menu-btn" onclick="openScoreboard()" style="background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);">🏆 Scoreboard</button>
<button class="menu-btn" onclick="nextRound()" style="background: linear-gradient(135deg, #ff9800 0%, #f57c00 100%); margin-left: auto; margin-right: auto;">⚡ Nächste Runde</button>
<button class="menu-btn" onclick="confirmReset()" style="background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%); margin-left: auto;">🔄 Zurücksetzen</button>
<div style="margin-left: auto; display: flex; align-items: center; gap: 10px;">
<span style="font-weight: 600; color: #333;">Spielzeit:</span>
<div class="timer-section">
<div class="timer-display" id="timerDisplay">00:00</div>
<button class="timer-control-btn" id="timerBtn" onclick="toggleTimer()">Start</button>
<input type="text" class="timer-input" id="timerInput" placeholder="Sek. oder MM:SS" title="Sekunden oder MM:SS eingeben und Enter drücken">
</div>
</div>
</div>
<div class="fields-container">
<!-- Bundesliga Felder -->
<div class="fields-section">
<div class="fields-header">
<h3>Bundesliga</h3>
</div>
<div class="fields-grid" id="bundesliga-fields">
</div>
<div class="waiting-teams-section">
<div class="waiting-teams-label">Wartende Teams:</div>
<div class="waiting-teams-list" id="bundesliga-waiting">
</div>
</div>
</div>
<!-- Champions League Felder -->
<div class="fields-section">
<div class="fields-header">
<h3>Champions League</h3>
</div>
<div class="fields-grid" id="champions-fields">
</div>
<div class="waiting-teams-section">
<div class="waiting-teams-label">Wartende Teams:</div>
<div class="waiting-teams-list" id="champions-waiting">
</div>
</div>
</div>
</div>
</div>
<!-- Punkte Modal -->
<div id="pointsModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Punkte anzeigen</h2>
<button class="close-btn" onclick="closePointsModal()">×</button>
</div>
<div class="points-display-content" id="pointsDisplayContent">
<!-- Wird dynamisch gefüllt -->
</div>
</div>
</div>
<!-- Scoreboard Modal -->
<div id="scoreboardModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>🏆 Scoreboard</h2>
<button class="close-btn" onclick="closeScoreboard()">×</button>
</div>
<div class="scoreboard-content" id="scoreboardContent">
<!-- Wird dynamisch gefüllt -->
</div>
</div>
</div>
<!-- Reset Confirmation Modal -->
<div id="resetConfirmModal" class="modal">
<div class="modal-content" style="max-width: 400px;">
<div class="modal-header">
<h2>⚠️ Turnier zurücksetzen</h2>
<button class="close-btn" onclick="closeResetConfirm()">×</button>
</div>
<div style="padding: 20px; text-align: center;">
<p style="color: #333; margin-bottom: 20px; font-size: 1.05em;">
Alle Runden, Scores und Punkte werden gelöscht. Dieser Vorgang kann nicht rückgängig gemacht werden!
</p>
<div style="display: flex; gap: 10px; justify-content: center;">
<button class="cancel-btn" onclick="closeResetConfirm()">Abbrechen</button>
<button class="submit-btn" style="background: #e74c3c;" onclick="performReset()">Ja, Zurücksetzen</button>
</div>
</div>
</div>
</div>
<!-- Ergebnisse Modal (nicht mehr verwendet) -->
<div id="resultsModal" class="modal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h2>Ergebnisse eintragen</h2>
<button class="close-btn" onclick="closeResultsModal()">×</button>
</div>
<form class="modal-form" onsubmit="saveResults(event)">
<div class="form-group">
<label for="resultsField">Feld:</label>
<select id="resultsField" required>
<option value="">-- Feld wählen --</option>
</select>
</div>
<div class="form-group">
<label for="resultsTeam">Team:</label>
<input type="text" id="resultsTeam" placeholder="Teamname" readonly>
</div>
<div class="form-group">
<label for="resultsSets">Sätze gewonnen:</label>
<input type="number" id="resultsSets" min="0" placeholder="z.B. 2" required>
</div>
<div class="form-group">
<label for="resultsOpponent">Gegnerischer Team:</label>
<input type="text" id="resultsOpponent" placeholder="Name des Gegners">
</div>
<div class="modal-footer">
<button type="button" class="cancel-btn" onclick="closeResultsModal()">Abbrechen</button>
<button type="submit" class="submit-btn">Speichern</button>
</div>
</form>
</div>
</div>
<script src="planning.js"></script>
</body>
</html>

View File

@ -1,799 +0,0 @@
const STORAGE_KEY = 'turnierplaner_data';
const ROTATION_STATE_KEY = 'turnierplaner_rotation';
const SCORES_KEY = 'turnierplaner_scores';
let timerInterval = null;
let timerSeconds = 0;
let isRunning = false;
let allTeams = [];
let fieldCount = 0; // Anzahl der physischen Felder (jedes Feld hat 2 Feldhälften)
let rotationState = {};
let matchScores = {}; // { "league:fieldNum": {team1Score, team2Score}, ... }
document.addEventListener('DOMContentLoaded', () => {
loadTournamentData();
setupTimerInput();
loadMatchScores();
});
function loadTournamentData() {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) {
console.warn('Keine Turnierdaten gefunden');
return;
}
const data = JSON.parse(stored);
allTeams = data;
fieldCount = parseInt(data.fieldCount) || 0;
// Lade oder initialisiere Rotations-State
loadRotationState(data);
// Populate fields mit aktuellem State
displayCurrentRound();
}
function loadRotationState(data) {
const stored = localStorage.getItem(ROTATION_STATE_KEY);
if (stored) {
rotationState = JSON.parse(stored);
} else {
// Initialisiere mit Startzustand
rotationState = {
bundesliga: {
round: 0,
teamOrder: data.bundesliga.map((t, i) => i)
},
champions: {
round: 0,
teamOrder: data.champions.map((t, i) => i)
}
};
saveRotationState();
}
}
function saveRotationState() {
localStorage.setItem(ROTATION_STATE_KEY, JSON.stringify(rotationState));
}
function displayCurrentRound() {
displayLeagueRound('bundesliga', allTeams.bundesliga);
displayLeagueRound('champions', allTeams.champions);
}
function displayLeagueRound(league, teams) {
// Jedes Feld hat 2 Feldhälften, also maximal fieldCount * 2 Teams können gleichzeitig spielen
const maxPlayingTeams = fieldCount * 2;
const playingTeamsCount = Math.min(teams.length, maxPlayingTeams);
const fieldsPerLeague = Math.ceil(playingTeamsCount / 2); // Anzahl Felder für diese Liga
const state = rotationState[league];
// Bestimme Offset für Feldnummern - Champions League beginnt nach Bundesliga
let fieldNumberOffset = 0;
if (league === 'champions') {
const bundesligaPlayingTeams = Math.min(allTeams.bundesliga.length, fieldCount * 2);
fieldNumberOffset = Math.ceil(bundesligaPlayingTeams / 2);
}
// Aktualisiere Felder basierend auf teamOrder
const container = document.getElementById(`${league}-fields`);
container.innerHTML = '';
for (let i = 0; i < fieldsPerLeague; i++) {
// Info-Box für Feldnummer (nur Label)
const fieldInfoBox = document.createElement('div');
fieldInfoBox.className = 'field-info-box';
const fieldNum = fieldNumberOffset + i + 1;
fieldInfoBox.innerHTML = `<div class="field-info-number">Feld ${fieldNum}:</div>`;
container.appendChild(fieldInfoBox);
// Team 1 (mittlere Spalte) mit Score-Input
const team1Index = state.teamOrder[i * 2];
const team1 = teams[team1Index] || {};
const fieldCard1 = document.createElement('div');
fieldCard1.className = 'field-card';
const scoreKey = getScoreKey(league, fieldNum);
const existingScore = matchScores[scoreKey] || { team1Score: '', team2Score: '' };
fieldCard1.innerHTML = `
<div class="field-card-content">
<div class="team-name">${team1.name || '-'}</div>
<input type="number" class="score-input" value="${existingScore.team1Score}" placeholder="0"
onchange="updateMatchScore('${league}', ${fieldNum}, this.value, document.getElementById('score-${league}-${fieldNum}-t2').value)">
</div>
`;
fieldCard1.id = `score-${league}-${fieldNum}-t1-card`;
container.appendChild(fieldCard1);
// Team 2 (rechte Spalte) mit Score-Input
const team2Index = state.teamOrder[i * 2 + 1];
const team2 = teams[team2Index] || {};
const fieldCard2 = document.createElement('div');
fieldCard2.className = 'field-card';
fieldCard2.innerHTML = `
<div class="field-card-content">
<input type="number" class="score-input" value="${existingScore.team2Score}" placeholder="0"
id="score-${league}-${fieldNum}-t2"
onchange="updateMatchScore('${league}', ${fieldNum}, document.getElementById('score-${league}-${fieldNum}-t1-card').querySelector('.score-input').value, this.value)">
<div class="team-name">${team2.name || '-'}</div>
</div>
`;
container.appendChild(fieldCard2);
}
// Aktualisiere wartende Teams
updateWaitingTeams(league, teams, fieldsPerLeague, state.teamOrder);
}
function updateWaitingTeams(league, teams, fieldsPerLeague, teamOrder) {
const container = document.getElementById(`${league}-waiting`);
container.innerHTML = '';
// Anzahl der spielenden Teams = fieldsPerLeague * 2 (2 Teams pro Feld)
const playingTeamsCount = fieldsPerLeague * 2;
const waitingIndices = teamOrder.slice(playingTeamsCount);
if (waitingIndices.length === 0) {
container.innerHTML = '<div class="no-waiting-teams">Alle Teams spielen</div>';
} else {
waitingIndices.forEach(teamIndex => {
const team = teams[teamIndex];
const badge = document.createElement('div');
badge.className = 'waiting-team-badge';
badge.textContent = team.name;
badge.title = `${team.club || ''} - ${team.motto || ''}`;
container.appendChild(badge);
});
}
}
function populateFields(league, teams, fieldCount) {
const container = document.getElementById(`${league}-fields`);
const totalFields = parseInt(fieldCount);
const fieldsPerLeague = Math.ceil(totalFields / 2);
container.innerHTML = '';
for (let i = 1; i <= fieldsPerLeague; i++) {
const fieldCard = document.createElement('div');
fieldCard.className = 'field-card';
// Zuweisung von Teams zu Feldern (Round-Robin)
const teamIndex = (i - 1) % teams.length;
const team = teams[teamIndex] || {};
fieldCard.innerHTML = `
<div class="field-number">Feld ${i}</div>
<div class="field-team">${team.name || '-'}</div>
`;
fieldCard.setAttribute('data-field', i);
fieldCard.setAttribute('data-league', league);
fieldCard.setAttribute('data-team', team.name || '');
container.appendChild(fieldCard);
}
}
function populateWaitingTeams(league, teams, fieldCount) {
const container = document.getElementById(`${league}-waiting`);
const totalFields = parseInt(fieldCount);
const fieldsPerLeague = Math.ceil(totalFields / 2);
container.innerHTML = '';
// Teams die auf Feldern spielen
const playingTeamIndices = new Set();
for (let i = 0; i < fieldsPerLeague; i++) {
playingTeamIndices.add(i % teams.length);
}
// Wartende Teams sind alle Teams, die nicht gerade spielen
const waitingTeams = teams.filter((team, index) => !playingTeamIndices.has(index));
if (waitingTeams.length === 0) {
container.innerHTML = '<div class="no-waiting-teams">Alle Teams spielen</div>';
} else {
waitingTeams.forEach(team => {
const badge = document.createElement('div');
badge.className = 'waiting-team-badge';
badge.textContent = team.name;
badge.title = `${team.club || ''} - ${team.motto || ''}`;
container.appendChild(badge);
});
}
}
// Timer Functions
function setupTimerInput() {
const timerInput = document.getElementById('timerInput');
timerInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
const value = timerInput.value.trim();
if (value) {
parseAndSetTimer(value);
timerInput.value = '';
timerInput.blur(); // Remove focus after input
}
}
});
}
function parseAndSetTimer(timeStr) {
let seconds = 0;
if (timeStr.includes(':')) {
// Parse MM:SS format
const parts = timeStr.split(':');
if (parts.length === 2) {
const minutes = parseInt(parts[0]) || 0;
seconds = parseInt(parts[1]) || 0;
seconds = minutes * 60 + seconds;
}
} else {
// Parse as just seconds
seconds = parseInt(timeStr) || 0;
}
if (seconds > 0) {
// Stop current timer if running
if (isRunning) {
pauseTimer();
}
timerSeconds = seconds;
updateTimerDisplay();
// Start timer automatically
startTimer();
}
}
function toggleTimer() {
if (isRunning) {
pauseTimer();
} else {
startTimer();
}
}
function startTimer() {
if (isRunning) return;
isRunning = true;
const btn = document.getElementById('timerBtn');
btn.textContent = 'Pause';
timerInterval = setInterval(() => {
if (timerSeconds > 0) {
timerSeconds--;
updateTimerDisplay();
} else {
pauseTimer();
}
}, 1000);
}
function pauseTimer() {
if (!isRunning) return;
isRunning = false;
const btn = document.getElementById('timerBtn');
btn.textContent = 'Start';
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
}
function updateTimerDisplay() {
const minutes = Math.floor(timerSeconds / 60);
const seconds = timerSeconds % 60;
const display = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
document.getElementById('timerDisplay').textContent = display;
}
// Modal Functions
function openPointsModal() {
document.getElementById('pointsModal').style.display = 'block';
}
function closePointsModal() {
document.getElementById('pointsModal').style.display = 'none';
}
function openResultsModal() {
document.getElementById('resultsModal').style.display = 'block';
}
function closeResultsModal() {
document.getElementById('resultsModal').style.display = 'none';
}
function savePoints(event) {
event.preventDefault();
const field = document.getElementById('pointsField').value;
const team = document.getElementById('pointsTeam').value;
const points = document.getElementById('pointsValue').value;
if (!field || !points) {
alert('Bitte alle Felder ausfüllen');
return;
}
// TODO: Punkte speichern (z.B. in localStorage oder an Server)
console.log('Punkte gespeichert:', { field, team, points });
alert(`✅ Punkte für ${team} gespeichert!`);
// Reset form
document.getElementById('pointsField').value = '';
document.getElementById('pointsTeam').value = '';
document.getElementById('pointsValue').value = '';
closePointsModal();
}
function saveResults(event) {
event.preventDefault();
const field = document.getElementById('resultsField').value;
const team = document.getElementById('resultsTeam').value;
const sets = document.getElementById('resultsSets').value;
const opponent = document.getElementById('resultsOpponent').value;
if (!field || !sets) {
alert('Bitte alle Pflichtfelder ausfüllen');
return;
}
// TODO: Ergebnisse speichern (z.B. in localStorage oder an Server)
console.log('Ergebnis gespeichert:', { field, team, sets, opponent });
alert(`✅ Ergebnis für ${team} gespeichert!`);
// Reset form
document.getElementById('resultsField').value = '';
document.getElementById('resultsTeam').value = '';
document.getElementById('resultsSets').value = '';
document.getElementById('resultsOpponent').value = '';
closeResultsModal();
}
// Close modal when clicking outside
window.addEventListener('click', (event) => {
const pointsModal = document.getElementById('pointsModal');
const resultsModal = document.getElementById('resultsModal');
if (event.target === pointsModal) {
closePointsModal();
}
if (event.target === resultsModal) {
closeResultsModal();
}
});
function goBack() {
// Stop timer before navigating
if (isRunning) {
pauseTimer();
}
window.location.href = 'index.html';
}
// Rotation Logic
function nextRound() {
// Inkrementiere Runden-Nummer
rotationState.bundesliga.round++;
rotationState.champions.round++;
// Leere die Scores für die neue Runde
matchScores = {};
saveMatchScores();
rotateLeague('bundesliga', allTeams.bundesliga);
rotateLeague('champions', allTeams.champions);
saveRotationState();
displayCurrentRound();
console.log('Nächste Runde!', rotationState);
}
function rotateLeague(league, teams) {
// Jedes Feld hat 2 Feldhälften - maximale spielende Teams = fieldCount * 2
const maxPlayingTeams = fieldCount * 2;
const playingTeamsCount = Math.min(teams.length, maxPlayingTeams);
const state = rotationState[league];
const totalTeams = teams.length;
const waitingCount = totalTeams - playingTeamsCount;
// Fall 1: Keine wartenden Teams (Anzahl Teams = fieldCount * 2)
// Team 1 bleibt stehen, alle anderen rotieren um 1 Position weiter
if (waitingCount === 0) {
// [0, 1, 2, 3, 4, 5] -> [0, 2, 3, 4, 5, 1]
// Team an Index 0 bleibt, Team an Index 1 geht ans Ende, Rest rückt auf
if (state.teamOrder.length > 1) {
const secondTeam = state.teamOrder.splice(1, 1)[0];
state.teamOrder.push(secondTeam);
}
console.log(`${league} Fall 1: Team ${teams[state.teamOrder[0]].name} bleibt stehen, andere rotieren`);
}
// Fall 2: Genau 1 wartendes Team (Anzahl Teams = fieldCount * 2 + 1)
// Alle rotieren um 1 Position, wartendes Team kommt aufs Randfeld
else if (waitingCount === 1) {
// [0, 1, 2, 3, 4, 5, 6(wartend)] -> [1, 2, 3, 4, 5, 6, 0]
// Einfach erstes Team ans Ende verschieben
const first = state.teamOrder.shift();
state.teamOrder.push(first);
console.log(`${league} Fall 2: Team ${teams[first].name} geht warten, Team ${teams[state.teamOrder[playingTeamsCount - 1]].name} kommt ins Spiel`);
}
// Fall 3: Mehrere wartende Teams (Anzahl Teams > fieldCount * 2 + 1)
// Alle wartenden sollen spielen, max 1 Runde Pause
else {
// [0, 1, 2, 3(spielend), 4, 5, 6, 7(wartend)] -> [4, 5, 6, 7(jetzt spielend), 0, 1, 2, 3(jetzt wartend)]
// Wartende Teams kommen nach vorne, spielende Teams gehen warten
const playingTeams = state.teamOrder.slice(0, playingTeamsCount);
const waitingTeams = state.teamOrder.slice(playingTeamsCount);
state.teamOrder = [...waitingTeams, ...playingTeams];
console.log(`${league} Fall 3: Wartende Teams spielen jetzt, spielende Teams warten`);
}
}
// ===== SCORE & PUNKTE-SYSTEM =====
function loadMatchScores() {
const stored = localStorage.getItem(SCORES_KEY);
if (stored) {
matchScores = JSON.parse(stored);
} else {
matchScores = {};
}
}
function saveMatchScores() {
localStorage.setItem(SCORES_KEY, JSON.stringify(matchScores));
}
function getScoreKey(league, fieldNum) {
return `${league}:${fieldNum}`;
}
function updateMatchScore(league, fieldNum, team1Score, team2Score) {
const key = getScoreKey(league, fieldNum);
matchScores[key] = {
round: rotationState[league].round,
team1Score: parseInt(team1Score) || 0,
team2Score: parseInt(team2Score) || 0
};
saveMatchScores();
}
function calculatePoints(team1Score, team2Score) {
// Berechnet Punkte für Team 1 und Team 2 basierend auf Spielstand
const diff = Math.abs(team1Score - team2Score);
if (team1Score > team2Score) {
// Team 1 gewinnt
return {
team1Points: diff + 2,
team2Points: -diff
};
} else if (team2Score > team1Score) {
// Team 2 gewinnt
return {
team1Points: -diff,
team2Points: diff + 2
};
} else {
// Unentschieden
return {
team1Points: 0,
team2Points: 0
};
}
}
function getTeamPointsHistory() {
// Erstellt eine Punkte-Historie für alle Teams in beiden Ligen
const history = {
bundesliga: {},
champions: {}
};
// Initialisiere leere Arrays für jedes Team
allTeams.bundesliga.forEach((team, idx) => {
history.bundesliga[idx] = [];
});
allTeams.champions.forEach((team, idx) => {
history.champions[idx] = [];
});
// Gehe durch alle gespeicherten Scores
Object.keys(matchScores).forEach(key => {
const [league, fieldNum] = key.split(':');
const score = matchScores[key];
const fieldNumInt = parseInt(fieldNum);
const state = rotationState[league];
const teamsInLeague = league === 'bundesliga' ? allTeams.bundesliga : allTeams.champions;
// Berechne fieldsPerLeague basierend auf fieldCount
const maxPlayingTeams = fieldCount * 2;
const playingTeamsCount = Math.min(teamsInLeague.length, maxPlayingTeams);
const fieldsPerLeague = Math.ceil(playingTeamsCount / 2);
// Bestimme Offset für Feldnummern
const bundesligaPlayingTeams = Math.min(allTeams.bundesliga.length, fieldCount * 2);
const bundesligaFields = Math.ceil(bundesligaPlayingTeams / 2);
const fieldOffset = league === 'bundesliga' ? 0 : bundesligaFields;
const i = fieldNumInt - fieldOffset - 1;
if (i >= 0 && i < fieldsPerLeague && state && state.teamOrder) {
const team1Index = state.teamOrder[i * 2];
const team2Index = state.teamOrder[i * 2 + 1];
if (team1Index !== undefined && team2Index !== undefined) {
const points = calculatePoints(score.team1Score, score.team2Score);
if (!history[league][team1Index][score.round]) {
history[league][team1Index][score.round] = 0;
}
if (!history[league][team2Index][score.round]) {
history[league][team2Index][score.round] = 0;
}
history[league][team1Index][score.round] = points.team1Points;
history[league][team2Index][score.round] = points.team2Points;
}
}
});
return history;
}
// ===== MODAL FUNCTIONS =====
function openPointsModal() {
const history = getTeamPointsHistory();
const content = document.getElementById('pointsDisplayContent');
content.innerHTML = '';
// Bundesliga
if (allTeams.bundesliga.length > 0) {
content.appendChild(createLeaguePointsSection('bundesliga', 'Bundesliga', allTeams.bundesliga, history.bundesliga));
}
// Champions League
if (allTeams.champions.length > 0) {
content.appendChild(createLeaguePointsSection('champions', 'Champions League', allTeams.champions, history.champions));
}
document.getElementById('pointsModal').style.display = 'block';
}
function createLeaguePointsSection(leagueId, leagueName, teams, history) {
const section = document.createElement('div');
section.className = 'league-points-section';
// Header
const header = document.createElement('div');
header.className = 'league-points-header';
header.innerHTML = leagueName;
section.appendChild(header);
// Table
const table = document.createElement('table');
table.className = 'points-table';
// Berechne maximale Runde
let maxRound = 0;
Object.keys(history).forEach(teamId => {
if (history[teamId] && history[teamId].length) {
maxRound = Math.max(maxRound, history[teamId].length - 1);
}
});
// Header Row
const headerRow = document.createElement('tr');
headerRow.innerHTML = '<th>Team</th>';
for (let r = 0; r <= maxRound; r++) {
const th = document.createElement('th');
th.innerHTML = `Runde ${r + 1}`;
headerRow.appendChild(th);
}
const totalTh = document.createElement('th');
totalTh.innerHTML = 'Gesamt';
headerRow.appendChild(totalTh);
table.appendChild(headerRow);
// Team Rows
teams.forEach((team, teamIdx) => {
const row = document.createElement('tr');
// Team Name
const nameCell = document.createElement('td');
nameCell.style.textAlign = 'left';
nameCell.style.fontWeight = '500';
nameCell.innerHTML = team.name;
row.appendChild(nameCell);
// Points per round
let totalPoints = 0;
const teamHistory = history[teamIdx] || [];
for (let r = 0; r <= maxRound; r++) {
const cell = document.createElement('td');
const points = teamHistory[r] !== undefined ? teamHistory[r] : '-';
if (points !== '-') {
totalPoints += points;
const pointsDiv = document.createElement('div');
pointsDiv.className = 'points-value';
if (points > 0) {
pointsDiv.classList.add('positive');
pointsDiv.innerHTML = `+${points}`;
} else if (points < 0) {
pointsDiv.classList.add('negative');
pointsDiv.innerHTML = `${points}`;
} else {
pointsDiv.classList.add('neutral');
pointsDiv.innerHTML = '0';
}
cell.appendChild(pointsDiv);
} else {
cell.innerHTML = '-';
}
row.appendChild(cell);
}
// Total Points
const totalCell = document.createElement('td');
const totalDiv = document.createElement('div');
totalDiv.className = 'points-value points-total';
totalDiv.innerHTML = totalPoints > 0 ? `+${totalPoints}` : `${totalPoints}`;
totalCell.appendChild(totalDiv);
row.appendChild(totalCell);
table.appendChild(row);
});
section.appendChild(table);
return section;
}
function closePointsModal() {
document.getElementById('pointsModal').style.display = 'none';
}
function openScoreboard() {
const history = getTeamPointsHistory();
const content = document.getElementById('scoreboardContent');
content.innerHTML = '';
// Erstelle Daten für beide Ligen mit Gesamtpunkten
const leaguesData = [
{ id: 'bundesliga', name: 'Bundesliga', teams: allTeams.bundesliga, history: history.bundesliga },
{ id: 'champions', name: 'Champions League', teams: allTeams.champions, history: history.champions }
];
leaguesData.forEach(league => {
if (league.teams.length > 0) {
// Berechne Gesamtpunkte für jedes Team
const teamScores = league.teams.map((team, idx) => {
const teamHistory = league.history[idx] || [];
const totalPoints = teamHistory.reduce((sum, pts) => sum + (pts || 0), 0);
return { idx, name: team.name, points: totalPoints };
});
// Sortiere absteigend nach Punkten
teamScores.sort((a, b) => b.points - a.points);
// Erstelle die Liga-Section
const section = document.createElement('div');
section.className = 'scoreboard-league';
const header = document.createElement('div');
header.className = 'scoreboard-league-header';
header.innerHTML = league.name;
section.appendChild(header);
const list = document.createElement('ul');
list.className = 'scoreboard-list';
teamScores.forEach((score, rank) => {
const item = document.createElement('li');
item.className = 'scoreboard-item';
const rankDiv = document.createElement('div');
rankDiv.className = 'scoreboard-rank';
rankDiv.innerHTML = `${rank + 1}.`;
item.appendChild(rankDiv);
const nameDiv = document.createElement('div');
nameDiv.className = 'scoreboard-team-name';
nameDiv.innerHTML = score.name;
item.appendChild(nameDiv);
const pointsDiv = document.createElement('div');
pointsDiv.className = 'scoreboard-points';
if (score.points > 0) {
pointsDiv.classList.add('positive');
pointsDiv.innerHTML = `+${score.points}`;
} else if (score.points < 0) {
pointsDiv.classList.add('negative');
pointsDiv.innerHTML = `${score.points}`;
} else {
pointsDiv.innerHTML = '0';
}
item.appendChild(pointsDiv);
list.appendChild(item);
});
section.appendChild(list);
content.appendChild(section);
}
});
document.getElementById('scoreboardModal').style.display = 'block';
}
function closeScoreboard() {
document.getElementById('scoreboardModal').style.display = 'none';
}
function confirmReset() {
document.getElementById('resetConfirmModal').style.display = 'block';
}
function closeResetConfirm() {
document.getElementById('resetConfirmModal').style.display = 'none';
}
function performReset() {
// Lade die Rohdaten (Teams, Feldanzahl)
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return;
const data = JSON.parse(stored);
// Setze Rotationsstates zurück auf Anfangszustand
rotationState = {
bundesliga: {
round: 0,
teamOrder: data.bundesliga.map((t, i) => i)
},
champions: {
round: 0,
teamOrder: data.champions.map((t, i) => i)
}
};
// Leere alle Scores
matchScores = {};
// Speichere zurückgesetzte States
saveRotationState();
saveMatchScores();
// Aktualisiere die UI
displayCurrentRound();
// Schließe das Modal
closeResetConfirm();
alert('✅ Turnier wurde zurückgesetzt! Alle Runden und Scores wurden gelöscht.');
}
function openResultsModal() {
document.getElementById('resultsModal').style.display = 'block';
}
function closeResultsModal() {
document.getElementById('resultsModal').style.display = 'none';
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

1
public/placeholder.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

14
public/robots.txt Normal file
View File

@ -0,0 +1,14 @@
User-agent: Googlebot
Allow: /
User-agent: Bingbot
Allow: /
User-agent: Twitterbot
Allow: /
User-agent: facebookexternalhit
Allow: /
User-agent: *
Allow: /

227
script.js
View File

@ -1,227 +0,0 @@
const STORAGE_KEY = 'turnierplaner_data';
// Daten laden beim Start
document.addEventListener('DOMContentLoaded', () => {
loadData();
});
function addTeam(league) {
const list = document.getElementById(`${league}-list`);
// Leere-Nachricht entfernen wenn vorhanden
const emptyMsg = list.querySelector('.empty-message');
if (emptyMsg) {
emptyMsg.remove();
}
const teamEntry = document.createElement('div');
teamEntry.className = 'team-entry';
teamEntry.innerHTML = `
<div class="team-info">
<input type="text" placeholder="Teamname" class="team-name">
<input type="text" placeholder="Vereinsname" class="team-club">
<input type="text" placeholder="Schlachtruf" class="team-motto">
</div>
<button class="delete-btn" onclick="deleteTeam(this)">Löschen</button>
`;
list.appendChild(teamEntry);
updateTeamCount(league);
saveData();
// Focus auf das erste Input-Feld
teamEntry.querySelector('.team-name').focus();
}
function deleteTeam(button) {
const teamEntry = button.parentElement;
const list = teamEntry.parentElement;
teamEntry.remove();
// Bestimme welche Liga
const league = list.id.replace('-list', '');
updateTeamCount(league);
saveData();
// Zeige Leere-Nachricht wenn keine Teams mehr
if (list.children.length === 0) {
const emptyMsg = document.createElement('div');
emptyMsg.className = 'empty-message';
emptyMsg.textContent = 'Keine Teams hinzugefügt. Klicke auf + um zu beginnen!';
list.appendChild(emptyMsg);
}
}
function saveData() {
const data = {
fieldCount: document.getElementById('fieldCount').value,
bundesliga: getTeamsFromList('bundesliga'),
champions: getTeamsFromList('champions')
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
}
function getTeamsFromList(league) {
const list = document.getElementById(`${league}-list`);
const teams = [];
list.querySelectorAll('.team-entry').forEach(entry => {
teams.push({
name: entry.querySelector('.team-name').value,
club: entry.querySelector('.team-club').value,
motto: entry.querySelector('.team-motto').value
});
});
return teams;
}
function loadData() {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return;
const data = JSON.parse(stored);
// Lade Feldanzahl
document.getElementById('fieldCount').value = data.fieldCount || 1;
// Lade Teams
loadTeamsToList('bundesliga', data.bundesliga || []);
loadTeamsToList('champions', data.champions || []);
// Event listener für Auto-Save
document.getElementById('fieldCount').addEventListener('change', saveData);
document.addEventListener('input', (e) => {
if (e.target.classList.contains('team-name') ||
e.target.classList.contains('team-club') ||
e.target.classList.contains('team-motto')) {
saveData();
}
});
// Update Team-Anzahl
updateTeamCount('bundesliga');
updateTeamCount('champions');
}
function loadTeamsToList(league, teams) {
const list = document.getElementById(`${league}-list`);
list.innerHTML = '';
if (teams.length === 0) {
const emptyMsg = document.createElement('div');
emptyMsg.className = 'empty-message';
emptyMsg.textContent = 'Keine Teams hinzugefügt. Klicke auf + um zu beginnen!';
list.appendChild(emptyMsg);
return;
}
teams.forEach(team => {
const teamEntry = document.createElement('div');
teamEntry.className = 'team-entry';
teamEntry.innerHTML = `
<div class="team-info">
<input type="text" placeholder="Teamname" class="team-name" value="${escapeHtml(team.name)}">
<input type="text" placeholder="Vereinsname" class="team-club" value="${escapeHtml(team.club)}">
<input type="text" placeholder="Schlachtruf" class="team-motto" value="${escapeHtml(team.motto)}">
</div>
<button class="delete-btn" onclick="deleteTeam(this)">Löschen</button>
`;
list.appendChild(teamEntry);
});
// Event listener für Auto-Save
document.addEventListener('input', (e) => {
if (e.target.classList.contains('team-name') ||
e.target.classList.contains('team-club') ||
e.target.classList.contains('team-motto')) {
saveData();
}
});
// Update Team-Anzahl
updateTeamCount(league);
}
function updateTeamCount(league) {
const list = document.getElementById(`${league}-list`);
const countElement = document.getElementById(`${league}-count`);
const teamCount = list.querySelectorAll('.team-entry').length;
const teamText = teamCount === 1 ? 'Team' : 'Teams';
countElement.textContent = `${teamCount} ${teamText}`;
}
function exportData() {
const data = {
fieldCount: document.getElementById('fieldCount').value,
bundesliga: getTeamsFromList('bundesliga'),
champions: getTeamsFromList('champions')
};
const outputDiv = document.getElementById('export-output');
outputDiv.textContent = JSON.stringify(data, null, 2);
outputDiv.style.display = 'block';
// Download als JSON-Datei
const jsonString = JSON.stringify(data, null, 2);
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `turnierplaner_${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
}
function importData() {
document.getElementById('import-file').click();
}
function handleFileImport(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target.result);
document.getElementById('fieldCount').value = data.fieldCount || 1;
loadTeamsToList('bundesliga', data.bundesliga || []);
loadTeamsToList('champions', data.champions || []);
saveData();
alert('✅ Daten erfolgreich importiert!');
} catch (error) {
alert('❌ Fehler beim Importieren der Datei: ' + error.message);
}
};
reader.readAsText(file);
// Reset file input
event.target.value = '';
}
function escapeHtml(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, m => map[m]);
}
function navigateToPlanning() {
const data = {
fieldCount: document.getElementById('fieldCount').value,
bundesliga: getTeamsFromList('bundesliga'),
champions: getTeamsFromList('champions')
};
// Speichere in localStorage falls noch nicht gespeichert
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
// Navigiere zur Planungsseite
window.location.href = 'planning.html';
}

42
src/App.css Normal file
View File

@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

32
src/App.tsx Normal file
View File

@ -0,0 +1,32 @@
import { Toaster } from "@/components/ui/toaster";
import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { TournamentProvider } from "@/context/TournamentContext";
import Index from "./pages/Index";
import Tournament from "./pages/Tournament";
import NotFound from "./pages/NotFound";
const queryClient = new QueryClient();
const App = () => (
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<TournamentProvider>
<Toaster />
<Sonner />
<BrowserRouter>
<Routes>
<Route path="/" element={<Index />} />
<Route path="/tournament" element={<Tournament />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
</TournamentProvider>
</TooltipProvider>
</QueryClientProvider>
);
export default App;

View File

@ -0,0 +1,49 @@
import { useTournament } from '@/context/TournamentContext';
import { Button } from '@/components/ui/button';
import { Minus, Plus, LayoutGrid } from 'lucide-react';
export const FieldSettings = () => {
const { fieldCount, setFieldCount } = useTournament();
return (
<div className="card-apple p-6 space-y-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-xl bg-field/10 text-field">
<LayoutGrid className="w-5 h-5" />
</div>
<div>
<h2 className="text-lg font-semibold text-foreground">Spielfelder</h2>
<p className="text-sm text-muted-foreground">Anzahl verfügbarer Felder</p>
</div>
</div>
<div className="flex items-center justify-center gap-4 py-4">
<Button
variant="outline"
size="icon"
onClick={() => setFieldCount(fieldCount - 1)}
disabled={fieldCount <= 1}
className="h-12 w-12 rounded-xl"
>
<Minus className="w-5 h-5" />
</Button>
<div className="w-20 text-center">
<span className="text-4xl font-bold text-foreground">{fieldCount}</span>
<p className="text-xs text-muted-foreground mt-1">
{fieldCount === 1 ? 'Feld' : 'Felder'}
</p>
</div>
<Button
variant="outline"
size="icon"
onClick={() => setFieldCount(fieldCount + 1)}
className="h-12 w-12 rounded-xl"
>
<Plus className="w-5 h-5" />
</Button>
</div>
</div>
);
};

61
src/components/Header.tsx Normal file
View File

@ -0,0 +1,61 @@
import { Link, useLocation } from 'react-router-dom';
import { useTournament } from '@/context/TournamentContext';
import { Button } from '@/components/ui/button';
import { ArrowRight, ArrowLeft, RotateCcw } from 'lucide-react';
export const Header = () => {
const location = useLocation();
const { teams, resetTournament } = useTournament();
const isHome = location.pathname === '/';
return (
<header className="sticky top-0 z-50 backdrop-blur-xl bg-background/80 border-b border-border/50">
<div className="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
<Link to="/" className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-primary flex items-center justify-center">
<span className="text-primary-foreground font-bold text-sm">NVJ</span>
</div>
<span className="font-semibold text-lg text-foreground">Turnierplaner</span>
</Link>
<div className="flex items-center gap-2">
{!isHome && (
<Button
variant="ghost"
size="sm"
onClick={resetTournament}
className="rounded-xl text-muted-foreground hover:text-foreground"
>
<RotateCcw className="w-4 h-4 mr-2" />
Zurücksetzen
</Button>
)}
{isHome ? (
<Button
asChild
disabled={teams.length < 2}
className="rounded-xl"
>
<Link to="/tournament">
Turnierplan
<ArrowRight className="w-4 h-4 ml-2" />
</Link>
</Button>
) : (
<Button
asChild
variant="outline"
className="rounded-xl"
>
<Link to="/">
<ArrowLeft className="w-4 h-4 mr-2" />
Zurück
</Link>
</Button>
)}
</div>
</div>
</header>
);
};

View File

@ -0,0 +1,108 @@
import { useState } from 'react';
import { Match } from '@/types/tournament';
import { useTournament } from '@/context/TournamentContext';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Check } from 'lucide-react';
interface MatchScoreInputProps {
match: Match;
}
export const MatchScoreInput = ({ match }: MatchScoreInputProps) => {
const { updateMatchResult } = useTournament();
const [scoreA, setScoreA] = useState(match.result?.scoreA?.toString() ?? '');
const [scoreB, setScoreB] = useState(match.result?.scoreB?.toString() ?? '');
const handleSaveResult = () => {
const numA = parseInt(scoreA) || 0;
const numB = parseInt(scoreB) || 0;
updateMatchResult(match.id, numA, numB);
};
const isChampions = match.league === 'champions';
const hasResult = match.result !== undefined;
const canSave = scoreA !== '' && scoreB !== '';
return (
<div className="card-apple p-5 space-y-4">
<div className="flex items-center justify-between">
<span className={`text-xs font-semibold px-2.5 py-1 rounded-full ${
isChampions
? 'bg-champions/20 text-champions-foreground'
: 'bg-bundesliga/10 text-bundesliga'
}`}>
Feld {match.fieldNumber}
</span>
{hasResult && (
<span className="flex items-center gap-1 text-xs text-field font-medium">
<Check className="w-3 h-3" />
Gespeichert
</span>
)}
</div>
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="flex-1 p-3 bg-secondary/50 rounded-xl">
<p className="font-semibold text-foreground text-sm">{match.teamA.name}</p>
<p className="text-xs text-muted-foreground">{match.teamA.club}</p>
</div>
<Input
type="number"
min="0"
value={scoreA}
onChange={(e) => setScoreA(e.target.value)}
className="w-16 h-12 text-center text-lg font-bold rounded-xl"
placeholder="0"
/>
</div>
<div className="flex items-center gap-3">
<div className="flex-1 h-px bg-border" />
<span className="text-xs font-medium text-muted-foreground">VS</span>
<div className="flex-1 h-px bg-border" />
</div>
<div className="flex items-center gap-3">
<div className="flex-1 p-3 bg-secondary/50 rounded-xl">
<p className="font-semibold text-foreground text-sm">{match.teamB.name}</p>
<p className="text-xs text-muted-foreground">{match.teamB.club}</p>
</div>
<Input
type="number"
min="0"
value={scoreB}
onChange={(e) => setScoreB(e.target.value)}
className="w-16 h-12 text-center text-lg font-bold rounded-xl"
placeholder="0"
/>
</div>
</div>
<Button
onClick={handleSaveResult}
disabled={!canSave}
variant={hasResult ? "outline" : "default"}
className="w-full rounded-xl"
size="sm"
>
<Check className="w-4 h-4 mr-2" />
{hasResult ? 'Aktualisieren' : 'Ergebnis speichern'}
</Button>
{hasResult && match.result && (
<div className="pt-2 border-t border-border/50">
<div className="flex justify-between text-sm">
<span className={match.result.pointsA >= 0 ? 'text-field font-medium' : 'text-destructive'}>
{match.result.pointsA >= 0 ? '+' : ''}{match.result.pointsA} Pkt
</span>
<span className={match.result.pointsB >= 0 ? 'text-field font-medium' : 'text-destructive'}>
{match.result.pointsB >= 0 ? '+' : ''}{match.result.pointsB} Pkt
</span>
</div>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,28 @@
import { NavLink as RouterNavLink, NavLinkProps } from "react-router-dom";
import { forwardRef } from "react";
import { cn } from "@/lib/utils";
interface NavLinkCompatProps extends Omit<NavLinkProps, "className"> {
className?: string;
activeClassName?: string;
pendingClassName?: string;
}
const NavLink = forwardRef<HTMLAnchorElement, NavLinkCompatProps>(
({ className, activeClassName, pendingClassName, to, ...props }, ref) => {
return (
<RouterNavLink
ref={ref}
to={to}
className={({ isActive, isPending }) =>
cn(className, isActive && activeClassName, isPending && pendingClassName)
}
{...props}
/>
);
},
);
NavLink.displayName = "NavLink";
export { NavLink };

View File

@ -0,0 +1,208 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useTournament } from '@/context/TournamentContext';
import { Trophy, Star, BarChart3, TrendingUp, TrendingDown, Minus } from 'lucide-react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
const CHART_COLORS = [
'hsl(211, 100%, 50%)',
'hsl(0, 72%, 51%)',
'hsl(142, 71%, 45%)',
'hsl(45, 93%, 47%)',
'hsl(280, 65%, 60%)',
'hsl(180, 65%, 45%)',
'hsl(320, 65%, 52%)',
'hsl(30, 80%, 55%)',
];
export const ScoreboardModal = () => {
const { getTeamScores, rounds } = useTournament();
const [open, setOpen] = useState(false);
const bundesligaScores = getTeamScores('bundesliga');
const championsScores = getTeamScores('champions');
const createChartData = (league: 'bundesliga' | 'champions') => {
const scores = league === 'bundesliga' ? bundesligaScores : championsScores;
if (rounds.length === 0) return [];
const data = [];
// Add starting point
const startPoint: Record<string, number | string> = { round: 'Start' };
scores.forEach((s) => {
startPoint[s.team.name] = 0;
});
data.push(startPoint);
// Add each round
for (let i = 0; i < rounds.length; i++) {
const point: Record<string, number | string> = { round: `Runde ${i + 1}` };
scores.forEach((s) => {
const cumulativePoints = s.pointsHistory.slice(0, i + 1).reduce((a, b) => a + b, 0);
point[s.team.name] = cumulativePoints;
});
data.push(point);
}
return data;
};
const renderScoreTable = (league: 'bundesliga' | 'champions') => {
const scores = league === 'bundesliga' ? bundesligaScores : championsScores;
const Icon = league === 'bundesliga' ? Trophy : Star;
const badgeClass = league === 'bundesliga' ? 'bg-bundesliga/10 text-bundesliga' : 'bg-champions/20 text-champions-foreground';
if (scores.length === 0) {
return (
<div className="text-center py-12 text-muted-foreground">
Keine Teams in dieser Liga
</div>
);
}
return (
<div className="space-y-4">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border">
<th className="text-left py-3 px-2 text-sm font-medium text-muted-foreground">#</th>
<th className="text-left py-3 px-2 text-sm font-medium text-muted-foreground">Team</th>
<th className="text-center py-3 px-2 text-sm font-medium text-muted-foreground">Spiele</th>
<th className="text-right py-3 px-2 text-sm font-medium text-muted-foreground">Punkte</th>
<th className="text-center py-3 px-2 text-sm font-medium text-muted-foreground">Trend</th>
</tr>
</thead>
<tbody>
{scores.map((score, index) => {
const lastRoundPoints = score.pointsHistory[score.pointsHistory.length - 1] ?? 0;
return (
<tr key={score.team.id} className="border-b border-border/50 hover:bg-secondary/30 transition-colors">
<td className="py-3 px-2">
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold ${
index === 0 ? 'bg-champions text-champions-foreground' :
index === 1 ? 'bg-muted text-muted-foreground' :
index === 2 ? 'bg-orange-100 text-orange-700' :
'bg-secondary text-muted-foreground'
}`}>
{index + 1}
</span>
</td>
<td className="py-3 px-2">
<p className="font-semibold text-foreground">{score.team.name}</p>
<p className="text-xs text-muted-foreground">{score.team.club}</p>
</td>
<td className="py-3 px-2 text-center text-sm text-muted-foreground">
{score.matchesPlayed}
</td>
<td className="py-3 px-2 text-right">
<span className={`text-lg font-bold ${
score.totalPoints > 0 ? 'text-field' :
score.totalPoints < 0 ? 'text-destructive' :
'text-foreground'
}`}>
{score.totalPoints > 0 ? '+' : ''}{score.totalPoints}
</span>
</td>
<td className="py-3 px-2">
<div className="flex justify-center">
{lastRoundPoints > 0 ? (
<TrendingUp className="w-4 h-4 text-field" />
) : lastRoundPoints < 0 ? (
<TrendingDown className="w-4 h-4 text-destructive" />
) : (
<Minus className="w-4 h-4 text-muted-foreground" />
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{rounds.length > 0 && (
<div className="pt-4">
<h4 className="text-sm font-medium text-muted-foreground mb-4">Punkteverlauf</h4>
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={createChartData(league)}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis
dataKey="round"
tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
stroke="hsl(var(--border))"
/>
<YAxis
tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
stroke="hsl(var(--border))"
/>
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '0.75rem',
}}
labelStyle={{ color: 'hsl(var(--foreground))' }}
/>
<Legend />
{scores.map((score, index) => (
<Line
key={score.team.id}
type="monotone"
dataKey={score.team.name}
stroke={CHART_COLORS[index % CHART_COLORS.length]}
strokeWidth={2}
dot={{ r: 4 }}
activeDot={{ r: 6 }}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
</div>
)}
</div>
);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="rounded-xl">
<BarChart3 className="w-4 h-4 mr-2" />
Scoreboard
</Button>
</DialogTrigger>
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto bg-card">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">Scoreboard</DialogTitle>
</DialogHeader>
<Tabs defaultValue="bundesliga" className="mt-4">
<TabsList className="grid w-full grid-cols-2 rounded-xl">
<TabsTrigger value="bundesliga" className="rounded-lg gap-2">
<Trophy className="w-4 h-4" />
Bundesliga
</TabsTrigger>
<TabsTrigger value="champions" className="rounded-lg gap-2">
<Star className="w-4 h-4" />
Champions League
</TabsTrigger>
</TabsList>
<TabsContent value="bundesliga" className="mt-4">
{renderScoreTable('bundesliga')}
</TabsContent>
<TabsContent value="champions" className="mt-4">
{renderScoreTable('champions')}
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
};

115
src/components/TeamForm.tsx Normal file
View File

@ -0,0 +1,115 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useTournament } from '@/context/TournamentContext';
import { League } from '@/types/tournament';
import { Plus } from 'lucide-react';
export const TeamForm = () => {
const { addTeam } = useTournament();
const [name, setName] = useState('');
const [club, setClub] = useState('');
const [battleCry, setBattleCry] = useState('');
const [league, setLeague] = useState<League>('bundesliga');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !club.trim()) return;
addTeam({
name: name.trim(),
club: club.trim(),
battleCry: battleCry.trim(),
league,
});
setName('');
setClub('');
setBattleCry('');
};
return (
<form onSubmit={handleSubmit} className="card-apple p-6 space-y-5">
<h2 className="text-lg font-semibold text-foreground">Neues Team</h2>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name" className="text-sm font-medium text-muted-foreground">
Teamname
</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="z.B. Die Netzroller"
className="h-11 rounded-xl border-border/60 bg-secondary/50 focus:bg-card transition-colors"
/>
</div>
<div className="space-y-2">
<Label htmlFor="club" className="text-sm font-medium text-muted-foreground">
Verein
</Label>
<Input
id="club"
value={club}
onChange={(e) => setClub(e.target.value)}
placeholder="z.B. VfB Stuttgart"
className="h-11 rounded-xl border-border/60 bg-secondary/50 focus:bg-card transition-colors"
/>
</div>
<div className="space-y-2">
<Label htmlFor="battleCry" className="text-sm font-medium text-muted-foreground">
Schlachtruf
</Label>
<Input
id="battleCry"
value={battleCry}
onChange={(e) => setBattleCry(e.target.value)}
placeholder="z.B. Volle Power!"
className="h-11 rounded-xl border-border/60 bg-secondary/50 focus:bg-card transition-colors"
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-muted-foreground">Liga</Label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setLeague('bundesliga')}
className={`flex-1 py-3 px-4 rounded-xl text-sm font-medium transition-all duration-200 ${
league === 'bundesliga'
? 'bg-bundesliga text-bundesliga-foreground shadow-md'
: 'bg-secondary text-muted-foreground hover:bg-secondary/80'
}`}
>
Bundesliga
</button>
<button
type="button"
onClick={() => setLeague('champions')}
className={`flex-1 py-3 px-4 rounded-xl text-sm font-medium transition-all duration-200 ${
league === 'champions'
? 'bg-champions text-champions-foreground shadow-md'
: 'bg-secondary text-muted-foreground hover:bg-secondary/80'
}`}
>
Champions League
</button>
</div>
</div>
</div>
<Button
type="submit"
disabled={!name.trim() || !club.trim()}
className="w-full h-11 rounded-xl font-medium transition-all duration-200"
>
<Plus className="w-4 h-4 mr-2" />
Team hinzufügen
</Button>
</form>
);
};

View File

@ -0,0 +1,78 @@
import { useTournament } from '@/context/TournamentContext';
import { League } from '@/types/tournament';
import { X, Trophy, Star } from 'lucide-react';
interface TeamListProps {
league: League;
}
export const TeamList = ({ league }: TeamListProps) => {
const { teams, removeTeam } = useTournament();
const filteredTeams = teams.filter((team) => team.league === league);
const leagueConfig = {
bundesliga: {
title: 'Bundesliga',
icon: Trophy,
emptyText: 'Noch keine Teams in der Bundesliga',
badgeClass: 'bg-bundesliga/10 text-bundesliga',
borderClass: 'border-l-bundesliga',
},
champions: {
title: 'Champions League',
icon: Star,
emptyText: 'Noch keine Teams in der Champions League',
badgeClass: 'bg-champions/20 text-champions-foreground',
borderClass: 'border-l-champions',
},
};
const config = leagueConfig[league];
const Icon = config.icon;
return (
<div className="card-apple p-6 space-y-4">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-xl ${config.badgeClass}`}>
<Icon className="w-5 h-5" />
</div>
<div>
<h2 className="text-lg font-semibold text-foreground">{config.title}</h2>
<p className="text-sm text-muted-foreground">{filteredTeams.length} Teams</p>
</div>
</div>
{filteredTeams.length === 0 ? (
<p className="text-sm text-muted-foreground py-8 text-center">
{config.emptyText}
</p>
) : (
<div className="space-y-2">
{filteredTeams.map((team) => (
<div
key={team.id}
className={`group relative p-4 bg-secondary/50 rounded-xl border-l-4 ${config.borderClass} transition-all duration-200 hover:bg-secondary`}
>
<button
onClick={() => removeTeam(team.id)}
className="absolute right-3 top-3 p-1.5 rounded-lg opacity-0 group-hover:opacity-100 bg-destructive/10 text-destructive hover:bg-destructive/20 transition-all duration-200"
>
<X className="w-4 h-4" />
</button>
<div className="pr-8">
<h3 className="font-semibold text-foreground">{team.name}</h3>
<p className="text-sm text-muted-foreground">{team.club}</p>
{team.battleCry && (
<p className="text-xs text-muted-foreground mt-1 italic">
{team.battleCry}"
</p>
)}
</div>
</div>
))}
</div>
)}
</div>
);
};

View File

@ -0,0 +1,189 @@
import { useTournament } from '@/context/TournamentContext';
import { Match, Team, League } from '@/types/tournament';
import { Trophy, Star, Clock, Play, CheckCircle, AlertCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { MatchScoreInput } from '@/components/MatchScoreInput';
import { ScoreboardModal } from '@/components/ScoreboardModal';
const WaitingTeam = ({ team }: { team: Team }) => (
<div className="flex items-center gap-3 p-3 bg-waiting/10 rounded-xl border border-waiting/20">
<Clock className="w-4 h-4 text-waiting" />
<div>
<p className="font-medium text-foreground">{team.name}</p>
<p className="text-xs text-muted-foreground">{team.club}</p>
</div>
</div>
);
const LeagueSection = ({
title,
icon: Icon,
matches,
waiting,
badgeClass,
}: {
title: string;
icon: typeof Trophy;
matches: Match[];
waiting: Team[];
badgeClass: string;
}) => {
const matchesWithResults = matches.filter((m) => m.result);
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-xl ${badgeClass}`}>
<Icon className="w-5 h-5" />
</div>
<div>
<h2 className="text-xl font-semibold text-foreground">{title}</h2>
<p className="text-sm text-muted-foreground">
{matchesWithResults.length}/{matches.length} Spiele eingetragen
</p>
</div>
</div>
{matches.length === 0 ? (
<div className="card-apple p-8 text-center">
<p className="text-muted-foreground">
Mindestens 2 Teams benötigt für Spiele
</p>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2">
{matches.map((match) => (
<MatchScoreInput key={match.id} match={match} />
))}
</div>
)}
{waiting.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Clock className="w-4 h-4" />
Wartepositionen ({waiting.length})
</h3>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{waiting.map((team) => (
<WaitingTeam key={team.id} team={team} />
))}
</div>
</div>
)}
</div>
);
};
export const TournamentView = () => {
const { teams, fieldCount, currentRound, rounds, startNewRound, completeCurrentRound } = useTournament();
const bundesligaMatches = currentRound?.matches.filter((m) => m.league === 'bundesliga') ?? [];
const championsMatches = currentRound?.matches.filter((m) => m.league === 'champions') ?? [];
const bundesligaWaiting = currentRound?.bundesligaWaiting ?? [];
const championsWaiting = currentRound?.championsWaiting ?? [];
const allMatchesHaveResults = currentRound?.matches.every((m) => m.result) ?? false;
const hasEnoughTeams = teams.length >= 2;
const completedRounds = rounds.filter((r) => r.completed);
return (
<div className="space-y-8">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-foreground">Turnierübersicht</h1>
<p className="text-muted-foreground mt-1">
{completedRounds.length} {completedRounds.length === 1 ? 'Runde' : 'Runden'} abgeschlossen
{currentRound && ` • Runde ${currentRound.roundNumber} aktiv`}
</p>
</div>
<div className="flex gap-2">
<ScoreboardModal />
{!currentRound ? (
<Button
onClick={startNewRound}
disabled={!hasEnoughTeams}
className="rounded-xl"
>
<Play className="w-4 h-4 mr-2" />
{rounds.length === 0 ? 'Turnier starten' : 'Neue Runde'}
</Button>
) : (
<Button
onClick={completeCurrentRound}
disabled={!allMatchesHaveResults}
className="rounded-xl"
>
<CheckCircle className="w-4 h-4 mr-2" />
Runde abschließen
</Button>
)}
</div>
</div>
{!currentRound && !hasEnoughTeams && (
<div className="card-apple p-6 flex items-center gap-4 border-l-4 border-l-champions">
<AlertCircle className="w-6 h-6 text-champions" />
<div>
<p className="font-medium text-foreground">Noch nicht genug Teams</p>
<p className="text-sm text-muted-foreground">
Füge mindestens 2 Teams hinzu, um das Turnier zu starten.
</p>
</div>
</div>
)}
{!currentRound && hasEnoughTeams && rounds.length === 0 && (
<div className="card-apple p-6 flex items-center gap-4 border-l-4 border-l-primary">
<Play className="w-6 h-6 text-primary" />
<div>
<p className="font-medium text-foreground">Bereit zum Start</p>
<p className="text-sm text-muted-foreground">
{teams.length} Teams und {fieldCount} Felder sind konfiguriert. Klicke auf "Turnier starten".
</p>
</div>
</div>
)}
{!currentRound && rounds.length > 0 && (
<div className="card-apple p-6 flex items-center gap-4 border-l-4 border-l-field">
<CheckCircle className="w-6 h-6 text-field" />
<div>
<p className="font-medium text-foreground">Runde {rounds.length} abgeschlossen</p>
<p className="text-sm text-muted-foreground">
Starte eine neue Runde oder schau dir das Scoreboard an.
</p>
</div>
</div>
)}
{currentRound && (
<>
<div className="card-apple p-4 bg-primary/5 border-primary/20">
<p className="text-sm text-center text-muted-foreground">
<span className="font-semibold text-primary">Runde {currentRound.roundNumber}</span>
Trage die Ergebnisse für alle Spiele ein
</p>
</div>
<LeagueSection
title="Bundesliga"
icon={Trophy}
matches={bundesligaMatches}
waiting={bundesligaWaiting}
badgeClass="bg-bundesliga/10 text-bundesliga"
/>
<LeagueSection
title="Champions League"
icon={Star}
matches={championsMatches}
waiting={championsWaiting}
badgeClass="bg-champions/20 text-champions-foreground"
/>
</>
)}
</div>
);
};

View File

@ -0,0 +1,52 @@
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 };

View File

@ -0,0 +1,104 @@
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,
};

View File

@ -0,0 +1,43 @@
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 };

View File

@ -0,0 +1,5 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
const AspectRatio = AspectRatioPrimitive.Root;
export { AspectRatio };

View File

@ -0,0 +1,38 @@
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 };

View File

@ -0,0 +1,29 @@
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 };

View File

@ -0,0 +1,90 @@
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,
};

View File

@ -0,0 +1,47 @@
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 gap-2 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 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
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 };

View File

@ -0,0 +1,54 @@
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 };

View File

@ -0,0 +1,43 @@
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 };

View File

@ -0,0 +1,224 @@
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 };

303
src/components/ui/chart.tsx Normal file
View File

@ -0,0 +1,303 @@
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> });
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
});
ChartContainer.displayName = "Chart";
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref,
) => {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item.dataKey || item.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return <div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>;
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn("shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", {
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
})}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">{itemConfig?.label || item.name}</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
},
);
ChartTooltipContent.displayName = "ChartTooltip";
const ChartLegend = RechartsPrimitive.Legend;
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}
>(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn("flex items-center justify-center gap-4", verticalAlign === "top" ? "pb-3" : "pt-3", className)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn("flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground")}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
});
ChartLegendContent.displayName = "ChartLegend";
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
}
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle };

View File

@ -0,0 +1,26 @@
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 data-[state=checked]:bg-primary data-[state=checked]:text-primary-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,
)}
{...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 };

View File

@ -0,0 +1,9 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@ -0,0 +1,132 @@
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,
};

View File

@ -0,0 +1,178 @@
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 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground focus:bg-accent focus: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 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
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 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
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 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
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,
};

View File

@ -0,0 +1,95 @@
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 data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<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,
};

View File

@ -0,0 +1,87 @@
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,
};

View File

@ -0,0 +1,179 @@
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 data-[state=open]:bg-accent focus: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 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
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 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
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 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
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,
};

129
src/components/ui/form.tsx Normal file
View File

@ -0,0 +1,129 @@
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 };

View File

@ -0,0 +1,27 @@
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 };

View File

@ -0,0 +1,61 @@
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="animate-caret-blink h-4 w-px 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 };

View File

@ -0,0 +1,22 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ 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-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground 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 md:text-sm",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };

View File

@ -0,0 +1,17 @@
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 };

View File

@ -0,0 +1,207 @@
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 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground focus:bg-accent focus: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 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground focus:bg-accent focus: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 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
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 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
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 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
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,
};

View File

@ -0,0 +1,120 @@
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,
};

View File

@ -0,0 +1,81 @@
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,
};

View File

@ -0,0 +1,29 @@
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 };

View File

@ -0,0 +1,23 @@
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 };

View File

@ -0,0 +1,36 @@
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 };

View File

@ -0,0 +1,37 @@
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 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 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 [&[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 };

View File

@ -0,0 +1,38 @@
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 };

View File

@ -0,0 +1,143 @@
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 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
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,
};

View File

@ -0,0 +1,20 @@
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 };

107
src/components/ui/sheet.tsx Normal file
View File

@ -0,0 +1,107 @@
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import * as React from "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 data-[state=open]:bg-secondary hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<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,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetOverlay,
SheetPortal,
SheetTitle,
SheetTrigger,
};

View File

@ -0,0 +1,637 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { VariantProps, cva } from "class-variance-authority";
import { PanelLeft } from "lucide-react";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar:state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContext = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContext | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
>(({ defaultOpen = true, open: openProp, onOpenChange: setOpenProp, className, style, children, ...props }, ref) => {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContext>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn("group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar", className)}
ref={ref}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
});
SidebarProvider.displayName = "SidebarProvider";
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}
>(({ side = "left", variant = "sidebar", collapsible = "offcanvas", className, children, ...props }, ref) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
className={cn("flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground", className)}
ref={ref}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
ref={ref}
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"relative h-svh w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]",
)}
/>
<div
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
</div>
</div>
</div>
);
});
Sidebar.displayName = "Sidebar";
const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.ComponentProps<typeof Button>>(
({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
},
);
SidebarTrigger.displayName = "SidebarTrigger";
const SidebarRail = React.forwardRef<HTMLButtonElement, React.ComponentProps<"button">>(
({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] group-data-[side=left]:-right-4 group-data-[side=right]:left-0 hover:after:bg-sidebar-border sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
);
},
);
SidebarRail.displayName = "SidebarRail";
const SidebarInset = React.forwardRef<HTMLDivElement, React.ComponentProps<"main">>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
"relative flex min-h-svh flex-1 flex-col bg-background",
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className,
)}
{...props}
/>
);
});
SidebarInset.displayName = "SidebarInset";
const SidebarInput = React.forwardRef<React.ElementRef<typeof Input>, React.ComponentProps<typeof Input>>(
({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className,
)}
{...props}
/>
);
},
);
SidebarInput.displayName = "SidebarInput";
const SidebarHeader = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
return <div ref={ref} data-sidebar="header" className={cn("flex flex-col gap-2 p-2", className)} {...props} />;
});
SidebarHeader.displayName = "SidebarHeader";
const SidebarFooter = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
return <div ref={ref} data-sidebar="footer" className={cn("flex flex-col gap-2 p-2", className)} {...props} />;
});
SidebarFooter.displayName = "SidebarFooter";
const SidebarSeparator = React.forwardRef<React.ElementRef<typeof Separator>, React.ComponentProps<typeof Separator>>(
({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
);
},
);
SidebarSeparator.displayName = "SidebarSeparator";
const SidebarContent = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
);
});
SidebarContent.displayName = "SidebarContent";
const SidebarGroup = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
});
SidebarGroup.displayName = "SidebarGroup";
const SidebarGroupLabel = React.forwardRef<HTMLDivElement, React.ComponentProps<"div"> & { asChild?: boolean }>(
({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div";
return (
<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
);
},
);
SidebarGroupLabel.displayName = "SidebarGroupLabel";
const SidebarGroupAction = React.forwardRef<HTMLButtonElement, React.ComponentProps<"button"> & { asChild?: boolean }>(
({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
},
);
SidebarGroupAction.displayName = "SidebarGroupAction";
const SidebarGroupContent = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => (
<div ref={ref} data-sidebar="group-content" className={cn("w-full text-sm", className)} {...props} />
),
);
SidebarGroupContent.displayName = "SidebarGroupContent";
const SidebarMenu = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(({ className, ...props }, ref) => (
<ul ref={ref} data-sidebar="menu" className={cn("flex w-full min-w-0 flex-col gap-1", className)} {...props} />
));
SidebarMenu.displayName = "SidebarMenu";
const SidebarMenuItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(({ className, ...props }, ref) => (
<li ref={ref} data-sidebar="menu-item" className={cn("group/menu-item relative", className)} {...props} />
));
SidebarMenuItem.displayName = "SidebarMenuItem";
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>
>(({ asChild = false, isActive = false, variant = "default", size = "default", tooltip, className, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="right" align="center" hidden={state !== "collapsed" || isMobile} {...tooltip} />
</Tooltip>
);
});
SidebarMenuButton.displayName = "SidebarMenuButton";
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform peer-hover/menu-button:text-sidebar-accent-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className,
)}
{...props}
/>
);
});
SidebarMenuAction.displayName = "SidebarMenuAction";
const SidebarMenuBadge = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
),
);
SidebarMenuBadge.displayName = "SidebarMenuBadge";
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
showIcon?: boolean;
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
<Skeleton
className="h-4 max-w-[--skeleton-width] flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
});
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton";
const SidebarMenuSub = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(
({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
),
);
SidebarMenuSub.displayName = "SidebarMenuSub";
const SidebarMenuSubItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(({ ...props }, ref) => (
<li ref={ref} {...props} />
));
SidebarMenuSubItem.displayName = "SidebarMenuSubItem";
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
});
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};

View File

@ -0,0 +1,7 @@
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 };

View File

@ -0,0 +1,23 @@
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 };

View File

@ -0,0 +1,27 @@
import { useTheme } from "next-themes";
import { Toaster as Sonner, toast } 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, toast };

View File

@ -0,0 +1,27 @@
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 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input 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",
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 };

View File

@ -0,0 +1,72 @@
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 data-[state=selected]:bg-muted hover:bg-muted/50", 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 };

View File

@ -0,0 +1,53 @@
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 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
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 };

View File

@ -0,0 +1,21 @@
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 };

111
src/components/ui/toast.tsx Normal file
View File

@ -0,0 +1,111 @@
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 group-[.destructive]:border-muted/40 hover:bg-secondary group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 group-[.destructive]:focus:ring-destructive disabled:pointer-events-none disabled:opacity-50",
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 group-hover:opacity-100 group-[.destructive]:text-red-300 hover:text-foreground group-[.destructive]:hover:text-red-50 focus:opacity-100 focus:outline-none focus:ring-2 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,
};

View File

@ -0,0 +1,24 @@
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>
);
}

View File

@ -0,0 +1,49 @@
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 };

View File

@ -0,0 +1,37 @@
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 };

View File

@ -0,0 +1,28 @@
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 };

View File

@ -0,0 +1,3 @@
import { useToast, toast } from "@/hooks/use-toast";
export { useToast, toast };

View File

@ -0,0 +1,166 @@
import React, { createContext, useContext, useState, ReactNode, useMemo } from 'react';
import { Team, Round, TeamScore, Match, MatchResult } from '@/types/tournament';
import { generateMatches } from '@/utils/tournamentUtils';
interface TournamentContextType {
teams: Team[];
fieldCount: number;
rounds: Round[];
currentRound: Round | null;
addTeam: (team: Omit<Team, 'id'>) => void;
removeTeam: (id: string) => void;
setFieldCount: (count: number) => void;
resetTournament: () => void;
startNewRound: () => void;
updateMatchResult: (matchId: string, scoreA: number, scoreB: number) => void;
completeCurrentRound: () => void;
getTeamScores: (league: 'bundesliga' | 'champions') => TeamScore[];
}
const TournamentContext = createContext<TournamentContextType | undefined>(undefined);
export const TournamentProvider = ({ children }: { children: ReactNode }) => {
const [teams, setTeams] = useState<Team[]>([]);
const [fieldCount, setFieldCountState] = useState<number>(4);
const [rounds, setRounds] = useState<Round[]>([]);
const currentRound = useMemo(() => {
return rounds.find((r) => !r.completed) || null;
}, [rounds]);
const addTeam = (team: Omit<Team, 'id'>) => {
const newTeam: Team = {
...team,
id: crypto.randomUUID(),
};
setTeams((prev) => [...prev, newTeam]);
};
const removeTeam = (id: string) => {
setTeams((prev) => prev.filter((team) => team.id !== id));
};
const setFieldCount = (count: number) => {
setFieldCountState(Math.max(1, count));
};
const resetTournament = () => {
setTeams([]);
setFieldCountState(4);
setRounds([]);
};
const startNewRound = () => {
if (currentRound && !currentRound.completed) return;
const state = generateMatches(teams, fieldCount);
const allMatches = [...state.bundesligaMatches, ...state.championsMatches];
const newRound: Round = {
id: crypto.randomUUID(),
roundNumber: rounds.length + 1,
matches: allMatches,
bundesligaWaiting: state.bundesligaWaiting,
championsWaiting: state.championsWaiting,
completed: false,
};
setRounds((prev) => [...prev, newRound]);
};
const updateMatchResult = (matchId: string, scoreA: number, scoreB: number) => {
const diff = scoreB - scoreA;
const result: MatchResult = {
scoreA,
scoreB,
pointsA: -diff,
pointsB: diff,
};
setRounds((prev) =>
prev.map((round) => ({
...round,
matches: round.matches.map((match) =>
match.id === matchId ? { ...match, result } : match
),
}))
);
};
const completeCurrentRound = () => {
if (!currentRound) return;
// Check if all matches have results
const allMatchesHaveResults = currentRound.matches.every((m) => m.result);
if (!allMatchesHaveResults) return;
setRounds((prev) =>
prev.map((round) =>
round.id === currentRound.id ? { ...round, completed: true } : round
)
);
};
const getTeamScores = (league: 'bundesliga' | 'champions'): TeamScore[] => {
const leagueTeams = teams.filter((t) => t.league === league);
return leagueTeams.map((team) => {
const pointsHistory: number[] = [];
let totalPoints = 0;
let matchesPlayed = 0;
rounds.forEach((round) => {
let roundPoints = 0;
round.matches.forEach((match) => {
if (match.result) {
if (match.teamA.id === team.id) {
roundPoints += match.result.pointsA;
matchesPlayed++;
} else if (match.teamB.id === team.id) {
roundPoints += match.result.pointsB;
matchesPlayed++;
}
}
});
pointsHistory.push(roundPoints);
totalPoints += roundPoints;
});
return {
team,
totalPoints,
pointsHistory,
matchesPlayed,
};
}).sort((a, b) => b.totalPoints - a.totalPoints);
};
return (
<TournamentContext.Provider
value={{
teams,
fieldCount,
rounds,
currentRound,
addTeam,
removeTeam,
setFieldCount,
resetTournament,
startNewRound,
updateMatchResult,
completeCurrentRound,
getTeamScores,
}}
>
{children}
</TournamentContext.Provider>
);
};
export const useTournament = () => {
const context = useContext(TournamentContext);
if (!context) {
throw new Error('useTournament must be used within a TournamentProvider');
}
return context;
};

19
src/hooks/use-mobile.tsx Normal file
View File

@ -0,0 +1,19 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}

186
src/hooks/use-toast.ts Normal file
View File

@ -0,0 +1,186 @@
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 };

125
src/index.css Normal file
View File

@ -0,0 +1,125 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* NVJ Turnierplaner - Apple-inspired Design System */
@layer base {
:root {
--background: 0 0% 98%;
--foreground: 0 0% 9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 9%;
--primary: 211 100% 50%;
--primary-foreground: 0 0% 100%;
--secondary: 0 0% 96%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96%;
--muted-foreground: 0 0% 45%;
--accent: 0 0% 96%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
--border: 0 0% 90%;
--input: 0 0% 90%;
--ring: 211 100% 50%;
--radius: 0.75rem;
/* Custom tokens */
--bundesliga: 0 72% 51%;
--bundesliga-foreground: 0 0% 100%;
--champions: 45 93% 47%;
--champions-foreground: 0 0% 9%;
--field: 142 71% 45%;
--field-foreground: 0 0% 100%;
--waiting: 0 0% 75%;
--waiting-foreground: 0 0% 20%;
--shadow-sm: 0 1px 2px 0 hsl(0 0% 0% / 0.03);
--shadow-md: 0 4px 6px -1px hsl(0 0% 0% / 0.05), 0 2px 4px -2px hsl(0 0% 0% / 0.05);
--shadow-lg: 0 10px 15px -3px hsl(0 0% 0% / 0.08), 0 4px 6px -4px hsl(0 0% 0% / 0.05);
--shadow-xl: 0 20px 25px -5px hsl(0 0% 0% / 0.1), 0 8px 10px -6px hsl(0 0% 0% / 0.1);
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 0 0% 26%;
--sidebar-primary: 0 0% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 0 0% 96%;
--sidebar-accent-foreground: 0 0% 10%;
--sidebar-border: 0 0% 91%;
--sidebar-ring: 211 100% 50%;
}
.dark {
--background: 0 0% 9%;
--foreground: 0 0% 98%;
--card: 0 0% 12%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 12%;
--popover-foreground: 0 0% 98%;
--primary: 211 100% 50%;
--primary-foreground: 0 0% 100%;
--secondary: 0 0% 17%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 17%;
--muted-foreground: 0 0% 65%;
--accent: 0 0% 17%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62% 30%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 17%;
--input: 0 0% 17%;
--ring: 211 100% 60%;
--sidebar-background: 0 0% 10%;
--sidebar-foreground: 0 0% 96%;
--sidebar-primary: 211 100% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 0 0% 16%;
--sidebar-accent-foreground: 0 0% 96%;
--sidebar-border: 0 0% 16%;
--sidebar-ring: 211 100% 60%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground antialiased;
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text', 'Helvetica Neue', sans-serif;
}
}
@layer utilities {
.card-apple {
@apply bg-card rounded-2xl shadow-[var(--shadow-md)] border border-border/50;
}
.card-apple-hover {
@apply card-apple transition-all duration-300 hover:shadow-[var(--shadow-lg)] hover:-translate-y-0.5;
}
}

6
src/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

5
src/main.tsx Normal file
View File

@ -0,0 +1,5 @@
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
createRoot(document.getElementById("root")!).render(<App />);

49
src/pages/Index.tsx Normal file
View File

@ -0,0 +1,49 @@
import { Header } from '@/components/Header';
import { TeamForm } from '@/components/TeamForm';
import { TeamList } from '@/components/TeamList';
import { FieldSettings } from '@/components/FieldSettings';
import { useTournament } from '@/context/TournamentContext';
const Index = () => {
const { teams } = useTournament();
return (
<div className="min-h-screen bg-background">
<Header />
<main className="max-w-6xl mx-auto px-6 py-8">
<div className="text-center mb-10">
<h1 className="text-4xl font-bold text-foreground tracking-tight">
NVJ Turnierplaner
</h1>
<p className="text-lg text-muted-foreground mt-2">
Volleyball-Turniere einfach organisieren
</p>
</div>
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-1 space-y-6">
<TeamForm />
<FieldSettings />
</div>
<div className="lg:col-span-2 space-y-6">
<TeamList league="bundesliga" />
<TeamList league="champions" />
</div>
</div>
{teams.length >= 2 && (
<div className="mt-8 p-4 bg-primary/5 rounded-2xl border border-primary/10 text-center">
<p className="text-sm text-muted-foreground">
<span className="font-medium text-primary">{teams.length} Teams</span> bereit
klicke auf Turnierplan" um die Spiele zu starten
</p>
</div>
)}
</main>
</div>
);
};
export default Index;

24
src/pages/NotFound.tsx Normal file
View File

@ -0,0 +1,24 @@
import { useLocation } from "react-router-dom";
import { useEffect } from "react";
const NotFound = () => {
const location = useLocation();
useEffect(() => {
console.error("404 Error: User attempted to access non-existent route:", location.pathname);
}, [location.pathname]);
return (
<div className="flex min-h-screen items-center justify-center bg-muted">
<div className="text-center">
<h1 className="mb-4 text-4xl font-bold">404</h1>
<p className="mb-4 text-xl text-muted-foreground">Oops! Page not found</p>
<a href="/" className="text-primary underline hover:text-primary/90">
Return to Home
</a>
</div>
</div>
);
};
export default NotFound;

15
src/pages/Tournament.tsx Normal file
View File

@ -0,0 +1,15 @@
import { Header } from '@/components/Header';
import { TournamentView } from '@/components/TournamentView';
const Tournament = () => {
return (
<div className="min-h-screen bg-background">
<Header />
<main className="max-w-6xl mx-auto px-6 py-8">
<TournamentView />
</main>
</div>
);
};
export default Tournament;

7
src/test/example.test.ts Normal file
View File

@ -0,0 +1,7 @@
import { describe, it, expect } from "vitest";
describe("example", () => {
it("should pass", () => {
expect(true).toBe(true);
});
});

15
src/test/setup.ts Normal file
View File

@ -0,0 +1,15 @@
import "@testing-library/jest-dom";
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => {},
}),
});

50
src/types/tournament.ts Normal file
View File

@ -0,0 +1,50 @@
export type League = 'bundesliga' | 'champions';
export interface Team {
id: string;
name: string;
club: string;
battleCry: string;
league: League;
}
export interface MatchResult {
scoreA: number;
scoreB: number;
pointsA: number;
pointsB: number;
}
export interface Match {
id: string;
teamA: Team;
teamB: Team;
fieldNumber: number;
league: League;
result?: MatchResult;
}
export interface Round {
id: string;
roundNumber: number;
matches: Match[];
bundesligaWaiting: Team[];
championsWaiting: Team[];
completed: boolean;
}
export interface TeamScore {
team: Team;
totalPoints: number;
pointsHistory: number[]; // Points per round
matchesPlayed: number;
}
export interface TournamentState {
bundesligaMatches: Match[];
championsMatches: Match[];
bundesligaWaiting: Team[];
championsWaiting: Team[];
bundesligaFields: number;
championsFields: number;
}

View File

@ -0,0 +1,73 @@
import { Team, Match, TournamentState, League } from '@/types/tournament';
export const generateMatches = (teams: Team[], fieldCount: number): TournamentState => {
const bundesligaTeams = teams.filter((t) => t.league === 'bundesliga');
const championsTeams = teams.filter((t) => t.league === 'champions');
// Distribute fields evenly between leagues (at least 1 each if teams exist)
const bundesligaHasTeams = bundesligaTeams.length >= 2;
const championsHasTeams = championsTeams.length >= 2;
let bundesligaFields = 0;
let championsFields = 0;
if (bundesligaHasTeams && championsHasTeams) {
bundesligaFields = Math.floor(fieldCount / 2);
championsFields = fieldCount - bundesligaFields;
} else if (bundesligaHasTeams) {
bundesligaFields = fieldCount;
} else if (championsHasTeams) {
championsFields = fieldCount;
}
const createMatchesForLeague = (
leagueTeams: Team[],
availableFields: number,
league: League,
fieldOffset: number
): { matches: Match[]; waiting: Team[] } => {
if (leagueTeams.length < 2) {
return { matches: [], waiting: leagueTeams };
}
const shuffled = [...leagueTeams].sort(() => Math.random() - 0.5);
const matches: Match[] = [];
const waiting: Team[] = [];
// Pair teams for matches
let teamIndex = 0;
let fieldNumber = fieldOffset + 1;
while (teamIndex < shuffled.length - 1 && matches.length < availableFields) {
matches.push({
id: crypto.randomUUID(),
teamA: shuffled[teamIndex],
teamB: shuffled[teamIndex + 1],
fieldNumber,
league,
});
teamIndex += 2;
fieldNumber++;
}
// Remaining teams go to waiting
while (teamIndex < shuffled.length) {
waiting.push(shuffled[teamIndex]);
teamIndex++;
}
return { matches, waiting };
};
const bundesliga = createMatchesForLeague(bundesligaTeams, bundesligaFields, 'bundesliga', 0);
const champions = createMatchesForLeague(championsTeams, championsFields, 'champions', bundesligaFields);
return {
bundesligaMatches: bundesliga.matches,
championsMatches: champions.matches,
bundesligaWaiting: bundesliga.waiting,
championsWaiting: champions.waiting,
bundesligaFields,
championsFields,
};
};

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -1,300 +0,0 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%);
height: 100vh;
padding: 15px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
h1 {
text-align: center;
color: white;
margin-bottom: 15px;
font-size: 2em;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
flex-shrink: 0;
}
.settings {
background: white;
padding: 15px 20px;
border-radius: 8px;
margin-bottom: 15px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
flex-shrink: 0;
}
.settings-group {
display: flex;
align-items: center;
gap: 15px;
flex-wrap: wrap;
}
.settings-group label {
font-weight: 600;
color: #333;
font-size: 1.1em;
}
.settings-group input {
padding: 12px 15px;
border: 2px solid #2ecc71;
border-radius: 4px;
font-size: 1.1em;
width: 200px;
min-height: 44px;
}
.settings-group input:focus {
outline: none;
border-color: #27ae60;
box-shadow: 0 0 0 3px rgba(46, 204, 113, 0.1);
}
.leagues-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
margin-bottom: 15px;
flex: 1;
overflow: hidden;
}
@media (max-width: 768px) {
.leagues-container {
grid-template-columns: 1fr;
}
}
@media (max-height: 600px) {
.leagues-container {
grid-template-columns: 1fr 1fr;
}
}
.league {
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
height: 100%;
}
.league-header {
padding: 15px;
background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%);
color: white;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
flex-shrink: 0;
}
.league-title {
display: flex;
flex-direction: column;
gap: 5px;
}
.league-title h2 {
margin: 0;
font-size: 1.3em;
}
.team-count {
font-size: 0.85em;
opacity: 0.9;
}
.add-team-btn {
background: white;
color: #27ae60;
border: none;
width: 50px;
height: 50px;
border-radius: 50%;
font-size: 1.8em;
cursor: pointer;
font-weight: bold;
transition: all 0.3s;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
touch-action: manipulation;
}
.add-team-btn:hover {
background: #f0f0f0;
transform: scale(1.1);
}
.teams-list {
padding: 15px;
overflow-y: auto;
flex: 1;
-webkit-overflow-scrolling: touch;
}
.team-entry {
background: #f8f9fa;
padding: 15px;
margin-bottom: 15px;
border-radius: 6px;
border-left: 4px solid #2ecc71;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 10px;
}
.team-entry:last-child {
margin-bottom: 0;
}
.team-info {
flex: 1;
}
.team-entry input {
width: 100%;
padding: 10px 12px;
margin-bottom: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1em;
font-family: inherit;
min-height: 44px;
}
.team-entry input:last-child {
margin-bottom: 0;
}
.team-entry input:focus {
outline: none;
border-color: #2ecc71;
box-shadow: 0 0 0 3px rgba(46, 204, 113, 0.1);
}
.team-entry input::placeholder {
color: #999;
}
.delete-btn {
background: #ff6b6b;
color: white;
border: none;
padding: 10px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 0.95em;
transition: background 0.3s;
white-space: nowrap;
min-height: 44px;
touch-action: manipulation;
}
.delete-btn:hover {
background: #ff5252;
}
.empty-message {
text-align: center;
color: #999;
padding: 40px 20px;
font-style: italic;
}
.export-section {
background: white;
padding: 15px 20px;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
flex-shrink: 0;
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.export-buttons {
display: flex;
gap: 10px;
flex-wrap: wrap;
flex: 1;
}
.export-btn {
background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%);
color: white;
border: none;
padding: 12px 24px;
border-radius: 4px;
font-size: 1em;
cursor: pointer;
font-weight: 600;
transition: transform 0.3s;
min-height: 44px;
touch-action: manipulation;
}
.export-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(46, 204, 113, 0.4);
}
.next-btn {
background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%);
color: white;
border: none;
padding: 12px 32px;
border-radius: 4px;
font-size: 1.1em;
cursor: pointer;
font-weight: 600;
transition: transform 0.3s;
min-height: 44px;
touch-action: manipulation;
white-space: nowrap;
}
.next-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(46, 204, 113, 0.4);
}
.export-output {
margin-top: 20px;
padding: 15px;
background: #f8f9fa;
border-radius: 4px;
border: 1px solid #ddd;
font-family: 'Courier New', monospace;
font-size: 0.9em;
max-height: 300px;
overflow-y: auto;
white-space: pre-wrap;
word-wrap: break-word;
}

107
tailwind.config.ts Normal file
View File

@ -0,0 +1,107 @@
import type { Config } from "tailwindcss";
export default {
darkMode: ["class"],
content: ["./pages/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}", "./app/**/*.{ts,tsx}", "./src/**/*.{ts,tsx}"],
prefix: "",
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
sidebar: {
DEFAULT: "hsl(var(--sidebar-background))",
foreground: "hsl(var(--sidebar-foreground))",
primary: "hsl(var(--sidebar-primary))",
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
accent: "hsl(var(--sidebar-accent))",
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
border: "hsl(var(--sidebar-border))",
ring: "hsl(var(--sidebar-ring))",
},
bundesliga: {
DEFAULT: "hsl(var(--bundesliga))",
foreground: "hsl(var(--bundesliga-foreground))",
},
champions: {
DEFAULT: "hsl(var(--champions))",
foreground: "hsl(var(--champions-foreground))",
},
field: {
DEFAULT: "hsl(var(--field))",
foreground: "hsl(var(--field-foreground))",
},
waiting: {
DEFAULT: "hsl(var(--waiting))",
foreground: "hsl(var(--waiting-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
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")],
} satisfies Config;

31
tsconfig.app.json Normal file
View File

@ -0,0 +1,31 @@
{
"compilerOptions": {
"types": ["vitest/globals"],
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitAny": false,
"noFallthroughCasesInSwitch": false,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

16
tsconfig.json Normal file
View File

@ -0,0 +1,16 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"noImplicitAny": false,
"noUnusedParameters": false,
"skipLibCheck": true,
"allowJs": true,
"noUnusedLocals": false,
"strictNullChecks": false
}
}

22
tsconfig.node.json Normal file
View File

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

23
vite.config.ts Normal file
View File

@ -0,0 +1,23 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import path from "path";
import { componentTagger } from "lovable-tagger";
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => ({
server: {
host: "::",
port: 8080,
hmr: {
overlay: false,
},
},
plugins: [react(), mode === "development" && componentTagger()].filter(Boolean),
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
// Prevent multiple React copies (common cause of: Cannot read properties of null (reading 'useEffect'))
dedupe: ["react", "react-dom", "react/jsx-runtime", "react/jsx-dev-runtime"],
},
}));

Some files were not shown because too many files have changed in this diff Show More