Neuste Version
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.github
|
||||
.vscode
|
||||
*.md
|
||||
.env
|
||||
.env.local
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.eslintcache
|
||||
coverage
|
||||
.vitest
|
||||
@@ -0,0 +1,104 @@
|
||||
# NVJ Turnierplaner - AI Coding Agent Instructions
|
||||
|
||||
## Project Overview
|
||||
Volleyball tournament organizer (NVJ = Netzroller Volleyball Jugend) for managing teams, rounds, and scoring across two league types: Bundesliga and Champions League. Built with React/TypeScript.
|
||||
|
||||
## Architecture
|
||||
|
||||
### State Management Pattern
|
||||
Single global context provider in [src/context/TournamentContext.tsx](src/context/TournamentContext.tsx):
|
||||
- **TournamentProvider** wraps entire app in [App.tsx](src/App.tsx)
|
||||
- Access via `useTournament()` hook - throws error if used outside provider
|
||||
- State includes: teams, rounds, field count, match results
|
||||
- **Critical**: Use `crypto.randomUUID()` for all new IDs (teams, matches, rounds)
|
||||
|
||||
### Data Flow
|
||||
1. Teams created on Index page → stored in context
|
||||
2. `generateMatches()` utility distributes teams across fields/leagues
|
||||
3. Rounds track match results and waiting teams
|
||||
4. Scores calculated via point differential: `pointsA = -(scoreB - scoreA)`, `pointsB = (scoreB - scoreA)`
|
||||
|
||||
### Type System ([src/types/tournament.ts](src/types/tournament.ts))
|
||||
- **League**: `'bundesliga' | 'champions'` - string literal types only
|
||||
- **Match** includes `result?: MatchResult` - undefined until scores entered
|
||||
- **Round** has `completed: boolean` - only one active round allowed at a time
|
||||
- **TeamScore** maintains `pointsHistory: number[]` for per-round tracking
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Running the App
|
||||
```bash
|
||||
npm run dev # Start development server on port 8080
|
||||
npm run build # Production build
|
||||
npm run build:dev # Development mode build
|
||||
npm run test # Run Vitest once
|
||||
npm run test:watch # Watch mode for tests
|
||||
```
|
||||
|
||||
### Vite Configuration
|
||||
- **Path alias**: `@/` maps to `src/` (configured in [vite.config.ts](vite.config.ts))
|
||||
- **React deduplication**: Critical for preventing hook errors - never remove from config
|
||||
|
||||
## UI Component Conventions
|
||||
|
||||
### shadcn/ui Pattern
|
||||
- All UI primitives in [src/components/ui/](src/components/ui/) - **do not edit** these directly
|
||||
- Import from `@/components/ui/<component>`
|
||||
- Customization via Tailwind classes and variants, not component modification
|
||||
|
||||
### Design System ([src/index.css](src/index.css))
|
||||
**Custom CSS tokens** (use these instead of arbitrary values):
|
||||
- `card-apple` - Standard card styling (rounded-2xl, shadow, border)
|
||||
- `card-apple-hover` - Interactive card with hover effects
|
||||
- `bg-bundesliga` / `bg-champions` - League-specific colors with matching foregrounds
|
||||
- `bg-waiting` / `bg-field` - Status colors for team/field states
|
||||
|
||||
**Color semantics**:
|
||||
- Bundesliga: Red theme (`--bundesliga: 0 72% 51%`)
|
||||
- Champions: Gold/yellow theme (`--champions: 45 93% 47%`)
|
||||
- Field: Green (`--field: 142 71% 45%`)
|
||||
- All have HSL definitions in CSS custom properties
|
||||
|
||||
### Component Structure Pattern
|
||||
See [src/components/TournamentView.tsx](src/components/TournamentView.tsx) for reference:
|
||||
- Extract sub-components within file for local use (`WaitingTeam`, `LeagueSection`)
|
||||
- Use typed icon props: `icon: typeof Trophy` instead of React.ComponentType
|
||||
- Filter round data at component level, not in context
|
||||
|
||||
## Key Files & Responsibilities
|
||||
|
||||
- [src/utils/tournamentUtils.ts](src/utils/tournamentUtils.ts) - Match generation with field distribution logic
|
||||
- [src/context/TournamentContext.tsx](src/context/TournamentContext.tsx) - All tournament state mutations
|
||||
- [src/pages/Index.tsx](src/pages/Index.tsx) - Team setup interface
|
||||
- [src/pages/Tournament.tsx](src/pages/Tournament.tsx) - Active tournament management
|
||||
- [src/components/MatchScoreInput.tsx](src/components/MatchScoreInput.tsx) - Score entry with point calculation
|
||||
|
||||
## Project-Specific Rules
|
||||
|
||||
1. **Never modify completed rounds** - `completed: true` rounds are immutable
|
||||
2. **Field allocation**: Bundesliga and Champions always share fields evenly (±1) when both have teams
|
||||
3. **Waiting teams**: Odd-numbered teams in a league → last team waits (see `createMatchesForLeague`)
|
||||
4. **Score point mapping**: Differential-based, not win/loss binary
|
||||
5. **Component naming**: PascalCase files match export name exactly
|
||||
6. **Routing**: Custom routes MUST be above `path="*"` catch-all in [App.tsx](App.tsx)
|
||||
|
||||
## Dependencies Notes
|
||||
|
||||
- **Bun runtime**: Package manager is Bun (uses bun.lockb), but npm scripts work
|
||||
- **React Router**: v6 pattern with BrowserRouter
|
||||
- **React Query**: QueryClient setup in App.tsx but minimal usage currently
|
||||
- **Lucide React**: Icon library of choice (see imports in components)
|
||||
- **Sonner + shadcn Toast**: Dual toast systems - prefer Sonner for new toasts
|
||||
|
||||
## Testing
|
||||
|
||||
- Vitest + Testing Library configured ([vitest.config.ts](vitest.config.ts))
|
||||
- Test setup in [src/test/setup.ts](src/test/setup.ts)
|
||||
- Run with `npm run test:watch` for development
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Don't destructure `useTournament()` before checking context existence
|
||||
- TypeScript paths require build tool awareness - always use `@/` imports
|
||||
- Custom CSS utilities won't work without `@layer utilities` wrapper
|
||||
- Dark mode is class-based - test both light/dark themes
|
||||
+24
@@ -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?
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
# Multi-stage build für minimales Image
|
||||
|
||||
# Build Stage
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Kopiere package files
|
||||
COPY package*.json ./
|
||||
COPY bun.lockb* ./
|
||||
|
||||
# Installiere Dependencies
|
||||
RUN npm ci --only=production=false
|
||||
|
||||
# Kopiere Source Code
|
||||
COPY . .
|
||||
|
||||
# Build der Anwendung
|
||||
RUN npm run build
|
||||
|
||||
# Production Stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Kopiere Build-Artefakte vom Builder
|
||||
COPY --from=builder /app/dist /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;"]
|
||||
@@ -0,0 +1,65 @@
|
||||
# NVJ Turnierplaner
|
||||
|
||||
Volleyball tournament organizer for Netzroller Volleyball Jugend (NVJ). Manage teams, rounds, and scoring across Bundesliga and Champions League formats.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js (v18 or higher) - [install with nvm](https://github.com/nvm-sh/nvm#installing-and-updating)
|
||||
- Bun (optional, for package management)
|
||||
|
||||
### Installation
|
||||
|
||||
```sh
|
||||
# Clone the repository
|
||||
git clone <YOUR_GIT_URL>
|
||||
|
||||
# Navigate to the project directory
|
||||
cd nvj-turnierplaner2
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
# or with bun
|
||||
bun install
|
||||
|
||||
# Start the development server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The app will be available at `http://localhost:8080`
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Vite** - Build tool and dev server
|
||||
- **React 18** - UI framework
|
||||
- **TypeScript** - Type safety
|
||||
- **shadcn/ui** - Component library
|
||||
- **Tailwind CSS** - Styling
|
||||
- **React Router** - Navigation
|
||||
- **Vitest** - Testing framework
|
||||
|
||||
## Available Scripts
|
||||
|
||||
```sh
|
||||
npm run dev # Start development server (port 8080)
|
||||
npm run build # Production build
|
||||
npm run build:dev # Development mode build
|
||||
npm run test # Run tests once
|
||||
npm run test:watch # Run tests in watch mode
|
||||
npm run lint # Lint code
|
||||
npm run preview # Preview production build
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `src/context/` - Global state management (TournamentContext)
|
||||
- `src/pages/` - Page components (Index, Tournament, NotFound)
|
||||
- `src/components/` - Reusable components
|
||||
- `src/components/ui/` - shadcn/ui primitives (do not edit directly)
|
||||
- `src/utils/` - Utility functions (match generation logic)
|
||||
- `src/types/` - TypeScript type definitions
|
||||
|
||||
## Development Guide
|
||||
|
||||
See [.github/copilot-instructions.md](.github/copilot-instructions.md) for detailed architecture documentation and coding conventions.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
NVJ Turnierplaner — Projektbeschreibung und Befunde
|
||||
|
||||
Kurzbeschreibung
|
||||
- Zweck: Webapp zur Verwaltung von Volleyball-Turnieren (NVJ). Teams anlegen, Runden generieren, Ergebnisse eintragen und Scoreboard anschauen.
|
||||
- Tech-Stack: Vite, React 18, TypeScript, Tailwind CSS, shadcn/ui, Vitest.
|
||||
|
||||
Architekturüberblick
|
||||
- Globaler State: `TournamentContext` ([src/context/TournamentContext.tsx]) verwaltet `teams`, `rounds`, `fieldCount` und stellt Mutationsfunktionen bereit (`startNewRound`, `updateMatchResult`, `completeCurrentRound`, u.a.).
|
||||
- Match-Generierung: `generateMatches` und `createNextRoundMatches` in [src/utils/tournamentUtils.ts] implementieren Start- und Rotationslogik (Circle-Rotation, Wartelisten, Felderaufteilung zwischen Ligen).
|
||||
- Typen: Zentrale Typen in [src/types/tournament.ts] (`Team`, `Match`, `Round`, `TeamScore`).
|
||||
- UI: Hauptansicht `TournamentView` ([src/components/TournamentView.tsx]) zeigt aktuelles Spielgeschehen; `MatchScoreInput` ([src/components/MatchScoreInput.tsx]) ermöglicht Ergebnis-Eingabe.
|
||||
|
||||
Wichtige Erkenntnisse (kurzer Testlauf)
|
||||
- Dev-Server: Vite Dev-Server läuft lokal unter http://localhost:5173/ (getestet).
|
||||
- Tests: `npm run test` (Vitest) ausgeführt — alle Tests bestanden (1 Test, 1 File).
|
||||
- IDs werden via `crypto.randomUUID()` erzeugt — keine DB-Anbindung, alles im Memory State.
|
||||
- Punkteberechnung: Differenzbasiert mit Bonus für Gewinner (siehe `updateMatchResult` in `TournamentContext`).
|
||||
|
||||
Empfohlene nächste Schritte
|
||||
- UI-Test: Kurz manuell Runde generieren, Ergebnisse eintragen, und Scoreboard prüfen.
|
||||
- Feature-Ideen: Persistenz (LocalStorage/IndexedDB), Export/Import von Turnierständen, Druckansicht des Spielplans.
|
||||
|
||||
Rotationslogik beim Rundenwechsel:
|
||||
- Case 1: Keine wartenden Teams -> die erste Mannschaft bleibt als Anker fix, alle anderen rotieren um 1 Position.
|
||||
- Case 2: 1 wartendes Team -> das wartende Team rückt nach vorne, das letzte Spielteam geht auf Warteposition.
|
||||
- Case 3: Mehrere wartende Teams (2+) -> alle wartenden Teams rücken nach vorne, entsprechend viele bisher spielende Teams gehen auf Warteposition.
|
||||
|
||||
Hinweis: Die erste Runde wird separat in `generateMatches()` zufällig aufgebaut; diese 3 Cases beschreiben nur die Rotation für Folgerunden.
|
||||
|
||||
|
||||
Wie man lokal startet
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
# In neuem Terminal
|
||||
npm run test
|
||||
```
|
||||
|
||||
Datum des Tests: 2026-04-29
|
||||
|
||||
Testergebnis (Vitest): 1 Test bestanden
|
||||
|
||||
— Copilot (kurze Analyse)
|
||||
@@ -0,0 +1,17 @@
|
||||
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
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
);
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<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" />
|
||||
<meta name="theme-color" content="#2563eb" />
|
||||
|
||||
<!-- Favicons -->
|
||||
<link rel="icon" type="image/svg+xml" href="/volleyball.svg" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/volleyball.svg" />
|
||||
|
||||
<!-- Open Graph / Social Media -->
|
||||
<meta property="og:title" content="NVJ Turnierplaner" />
|
||||
<meta property="og:description" content="Volleyball-Turniere einfach organisieren" />
|
||||
<meta property="og:type" content="website" />
|
||||
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip Kompression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
|
||||
|
||||
# Security Headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
# Cache statische Assets
|
||||
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# React Router - alle Anfragen zu index.html weiterleiten
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Keine Cache für index.html
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
}
|
||||
|
||||
# Fehlerseiten
|
||||
error_page 404 /index.html;
|
||||
}
|
||||
Generated
+7734
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"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",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@testing-library/jest-dom": "^6.6.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@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",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -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 |
@@ -0,0 +1,14 @@
|
||||
User-agent: Googlebot
|
||||
Allow: /
|
||||
|
||||
User-agent: Bingbot
|
||||
Allow: /
|
||||
|
||||
User-agent: Twitterbot
|
||||
Allow: /
|
||||
|
||||
User-agent: facebookexternalhit
|
||||
Allow: /
|
||||
|
||||
User-agent: *
|
||||
Allow: /
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="512" height="512">
|
||||
<path d="M0 0 C0.97055328 0.00215515 1.94110657 0.0043103 2.94107056 0.00653076 C17.26361716 0.05754633 31.30505979 0.38406747 45.375 3.3125 C46.3846582 3.52181152 47.39431641 3.73112305 48.43457031 3.94677734 C91.6023017 13.16903384 131.3319861 31.80325286 164.375 61.3125 C165.24125 62.06917969 166.1075 62.82585937 167 63.60546875 C177.01111786 72.44340874 186.14027739 81.79673053 194.375 92.3125 C195.17679687 93.30894531 195.97859375 94.30539063 196.8046875 95.33203125 C203.37154108 103.57271312 208.98379123 112.26805231 214.375 121.3125 C214.94516846 122.25319336 214.94516846 122.25319336 215.52685547 123.21289062 C230.45898307 147.95941013 239.53394346 175.13693397 245.375 203.3125 C245.64836182 204.62589355 245.64836182 204.62589355 245.92724609 205.96582031 C251.27640501 233.3343528 251.0370261 266.02678244 245.375 293.3125 C245.06900879 294.80305908 245.06900879 294.80305908 244.75683594 296.32373047 C232.67317485 353.46168251 202.13438688 407.37704527 155.375 443.3125 C154.83037109 443.73354004 154.28574219 444.15458008 153.72460938 444.58837891 C109.52805142 478.55425215 56.50041971 496.84136031 0.75 496.625 C-0.71099625 496.62226578 -0.71099625 496.62226578 -2.20150757 496.61947632 C-16.2346594 496.57522498 -29.83647352 496.10795926 -43.625 493.3125 C-45.45693452 492.97993547 -47.28897011 492.64792732 -49.12109375 492.31640625 C-96.33763137 483.31875063 -139.1942155 459.46570437 -173.625 426.3125 C-174.14868164 425.81073242 -174.67236328 425.30896484 -175.21191406 424.79199219 C-182.22742763 418.03711689 -188.68059464 411.0473889 -194.625 403.3125 C-195.04604004 402.76787109 -195.46708008 402.22324219 -195.90087891 401.66210938 C-203.5788188 391.67153698 -210.31687634 381.21445298 -216.625 370.3125 C-217.01929199 369.63525879 -217.41358398 368.95801758 -217.81982422 368.26025391 C-232.10737749 343.42001736 -241.11862171 315.5526365 -245.625 287.3125 C-245.89316528 285.63450317 -245.89316528 285.63450317 -246.16674805 283.92260742 C-247.81793055 272.36432988 -247.9828062 260.9118654 -247.9375 249.25 C-247.93534485 248.25327271 -247.9331897 247.25654541 -247.93096924 246.22961426 C-247.88067813 231.7451942 -247.59555759 217.54046478 -244.625 203.3125 C-244.42100586 202.32024414 -244.21701172 201.32798828 -244.00683594 200.30566406 C-233.84688498 152.25663837 -211.66902101 109.66842933 -177.625 74.3125 C-177.12323242 73.78881836 -176.62146484 73.26513672 -176.10449219 72.72558594 C-160.20108792 56.20854549 -141.60540677 42.52051743 -121.625 31.3125 C-120.98304688 30.94705078 -120.34109375 30.58160156 -119.6796875 30.20507812 C-97.36552881 17.58226483 -73.27680118 9.14011428 -48.1875 4 C-47.10927979 3.77896606 -47.10927979 3.77896606 -46.00927734 3.5534668 C-30.69956163 0.64390493 -15.54790345 -0.06221224 0 0 Z " fill="#010101" transform="translate(255.625,7.6875)"/>
|
||||
<path d="M0 0 C0.87836482 25.87183652 -10.32372825 51.5302097 -21.6796875 74.1484375 C-23.92989091 78.67079122 -25.92887479 83.20591926 -27.7578125 87.9140625 C-33.89732009 103.49808578 -33.89732009 103.49808578 -39.03515625 107.16796875 C-42.21729145 108.06305844 -45.197043 108.50000652 -48.48876953 108.73632812 C-53.85936937 109.30022544 -59.11225017 110.69637262 -64.375 111.875 C-66.81977402 112.39835511 -69.26509293 112.91917103 -71.7109375 113.4375 C-74.1407855 113.95758402 -76.57047696 114.47840004 -79 115 C-80.11922852 115.23976563 -81.23845703 115.47953125 -82.39160156 115.7265625 C-122.60708553 124.46331939 -158.74193604 144.41476709 -190 171 C-190.98097656 171.79664063 -191.96195312 172.59328125 -192.97265625 173.4140625 C-201.07572532 180.0671606 -208.53064805 187.34226733 -215.9375 194.75 C-216.68211487 195.49325531 -216.68211487 195.49325531 -217.44177246 196.25152588 C-227.4166462 206.03361327 -227.4166462 206.03361327 -236 217 C-239 216 -239 216 -240.03979492 214.33959961 C-240.35021729 213.61780518 -240.66063965 212.89601074 -240.98046875 212.15234375 C-241.3360083 211.33169434 -241.69154785 210.51104492 -242.05786133 209.66552734 C-242.43064209 208.76527832 -242.80342285 207.8650293 -243.1875 206.9375 C-243.57897217 206.00373535 -243.97044434 205.0699707 -244.3737793 204.10791016 C-255.65186808 176.68159716 -265.16532336 145.03513053 -263 115 C-261.9609375 112.99609375 -261.9609375 112.99609375 -260.375 111.4375 C-259.41400391 110.45136719 -259.41400391 110.45136719 -258.43359375 109.4453125 C-257.63050781 108.63835937 -256.82742188 107.83140625 -256 107 C-254.96523535 105.87681436 -253.93569908 104.74879952 -252.91015625 103.6171875 C-241.81821665 91.42258704 -230.01400694 80.1392197 -217 70 C-216.13084961 69.32203369 -216.13084961 69.32203369 -215.24414062 68.63037109 C-200.90035306 57.50092971 -185.87118969 47.71978722 -169.92333984 39.04980469 C-168.05182491 38.02828727 -166.19147749 36.98859911 -164.33203125 35.9453125 C-154.53650431 30.50890686 -144.40449189 26.1322086 -134 22 C-132.80552246 21.51297607 -132.80552246 21.51297607 -131.58691406 21.01611328 C-107.86065315 11.40989438 -81.50727034 4.50858284 -56 2 C-55.05705078 1.90138672 -54.11410156 1.80277344 -53.14257812 1.70117188 C-35.41282787 -0.09553322 -17.79598254 -0.24123855 0 0 Z " fill="#173D66" transform="translate(287,144)"/>
|
||||
<path d="M0 0 C31.50420998 2.46285755 61.22140166 12.08766202 89 27 C89.61601074 27.33 90.23202148 27.66 90.86669922 28 C97.1267128 31.38718832 103.25359059 35.00492224 109.1875 38.9375 C110.04730469 39.50339844 110.90710938 40.06929687 111.79296875 40.65234375 C118.05981274 47.31849107 118.04833417 58.05264002 119.125 66.6875 C119.29386719 67.96044922 119.46273437 69.23339844 119.63671875 70.54492188 C126.41278008 123.01938107 121.85626341 177.51031914 106 228 C105.67515625 229.04414063 105.3503125 230.08828125 105.015625 231.1640625 C99.96807249 247.24842758 94.04391882 262.68978404 87 278 C86.27845939 279.59496775 85.55709671 281.19001601 84.8359375 282.78515625 C81.00977032 291.15870539 76.83679739 299.15531924 72 307 C66.8274614 306.52976922 63.41962184 303.8590359 59.3125 300.9375 C58.59916504 300.43726318 57.88583008 299.93702637 57.15087891 299.42163086 C51.58790091 295.48922437 46.21096079 291.39027431 41 287 C40.46665039 286.55543457 39.93330078 286.11086914 39.38378906 285.65283203 C28.75326314 276.78022632 18.94933314 267.63552088 10.28125 256.78515625 C7.94293318 253.93032767 5.50085282 251.21311657 3 248.5 C-0.42151 244.76257594 -3.78994178 240.99398325 -7.0625 237.125 C-7.79597656 236.26132812 -8.52945312 235.39765625 -9.28515625 234.5078125 C-11.16702617 231.7557385 -11.72152881 230.30284604 -12 227 C-10.98706055 224.11938477 -10.98706055 224.11938477 -9.38671875 220.94140625 C-8.80277344 219.76167236 -8.21882813 218.58193848 -7.6171875 217.36645508 C-7.30233398 216.74606354 -6.98748047 216.125672 -6.66308594 215.48648071 C-5.34454689 212.88615244 -4.05869248 210.27012597 -2.77041626 207.65470886 C-1.7958658 205.6777519 -0.81669439 203.70312718 0.16259766 201.72851562 C7.19677311 187.49914964 13.5211178 173.24340669 18 158 C18.37769531 156.75992188 18.75539063 155.51984375 19.14453125 154.2421875 C31.80355739 108.82141205 25.17919731 58.87496485 7 16 C6.65839844 15.1941748 6.31679688 14.38834961 5.96484375 13.55810547 C4.02530175 9.01622171 2.0364247 4.49922582 0 0 Z " fill="#1E5288" transform="translate(278,25)"/>
|
||||
<path d="M0 0 C3.73567719 3.58839242 6.97626107 7.38481074 10.125 11.5 C27.49008646 33.84501815 46.71821925 54.64834483 69.35595703 71.74560547 C70.94098074 72.95496867 72.50041362 74.19764816 74.05859375 75.44140625 C88.95173727 87.26106028 105.19812738 97.14807222 122 106 C122.6482373 106.34337402 123.29647461 106.68674805 123.96435547 107.04052734 C144.49897232 117.83631951 165.84509384 126.17288432 188 133 C188.80195801 133.25088379 189.60391602 133.50176758 190.43017578 133.76025391 C208.15414352 139.20241836 226.77413689 141.77874311 245 145 C245 145.66 245 146.32 245 147 C227.42175854 156.57624858 210.10566231 164.81821064 191 171 C189.76636719 171.42539062 188.53273438 171.85078125 187.26171875 172.2890625 C177.73512813 175.46055773 167.86679505 177.24328855 158 179 C156.7625 179.22945312 155.525 179.45890625 154.25 179.6953125 C96.29484948 188.71903111 33.7812354 171.49206432 -13.24267578 137.30517578 C-20.42919806 131.96770036 -27.12107528 126.09285898 -33.7109375 120.04296875 C-35.29294705 118.63103873 -36.92380524 117.27266013 -38.58984375 115.9609375 C-40.7259275 114.2229893 -42.4232538 112.53736116 -44.1875 110.4375 C-46.1512211 108.12748829 -48.12644722 105.86686245 -50.21484375 103.66796875 C-59.65365306 93.70903056 -67.46799749 82.4435497 -75 71 C-67.53442197 54.47719785 -49.41398999 39.83587352 -36 28 C-35.45021484 27.51080078 -34.90042969 27.02160156 -34.33398438 26.51757812 C-23.50087344 16.90897108 -12.12350957 7.93266675 0 0 Z " fill="#FEC138" transform="translate(133,307)"/>
|
||||
<path d="M0 0 C15.33921241 30.17273651 25.9844524 60.3815185 27.55078125 94.4765625 C27.61682556 95.85581909 27.61682556 95.85581909 27.6842041 97.26293945 C27.79145014 99.50854027 27.89667276 101.75421604 28 104 C27.175783 104.02426056 27.175783 104.02426056 26.33491516 104.04901123 C-29.69500862 105.72809654 -81.7937735 112.91417896 -133 137 C-133.84143555 137.39123047 -134.68287109 137.78246094 -135.54980469 138.18554688 C-160.60656129 149.92754353 -183.9260104 165.08098268 -205 183 C-206.08281757 183.89775786 -207.16618204 184.79485637 -208.25 185.69140625 C-215.64579505 191.86377402 -222.56027375 198.38160822 -229.29003906 205.27587891 C-229.89170898 205.89124512 -230.49337891 206.50661133 -231.11328125 207.140625 C-231.64735596 207.69266602 -232.18143066 208.24470703 -232.73168945 208.81347656 C-234 210 -234 210 -235 210 C-230.99492082 150.14119331 -200.23087161 97.23147603 -157 57 C-153.04589411 53.54235522 -148.90890808 50.34840675 -144.67822266 47.23828125 C-143.04426048 46.03265772 -141.433795 44.80040594 -139.82421875 43.5625 C-100.42546777 13.7880537 -49.18767305 -1.79045119 0 0 Z " fill="#F1F1F1" transform="translate(260,24)"/>
|
||||
<path d="M0 0 C1.9453125 2.0546875 1.9453125 2.0546875 4.125 4.875 C7.14615851 8.6978899 10.20786911 12.40753436 13.5 16 C16.51005421 19.29147083 19.35711386 22.65789429 22.1328125 26.1484375 C26.89070655 31.98669252 32.02935077 37.32651826 37.375 42.625 C38.67824219 43.93984375 38.67824219 43.93984375 40.0078125 45.28125 C55.30910895 60.53727635 73.11822805 73.51202617 92 84 C92.81500977 84.45729492 93.63001953 84.91458984 94.46972656 85.38574219 C115.7937886 97.24808253 137.86221235 105.87397565 161.62060547 111.43896484 C163.66067814 111.91999162 165.69265017 112.43515141 167.72265625 112.95703125 C174.58642298 114.68360454 181.40420006 115.62963155 188.44287109 116.26660156 C193.79137966 116.79137966 193.79137966 116.79137966 196 119 C184.05731751 135.23621232 171.54582201 150.06119262 157.00585938 164.05957031 C155.33905578 165.67200398 153.70648252 167.31012803 152.078125 168.9609375 C148.97951212 171.84690047 146.64075902 173.90576672 142.30493164 174.15478516 C133.80462479 173.30124961 125.41111291 172.05526314 116.99951172 170.58081055 C114.7679572 170.19063968 112.53461282 169.81243536 110.30078125 169.43554688 C62.7638311 161.29549714 16.67761872 143.58589059 -23 116 C-23.5578418 115.61344238 -24.11568359 115.22688477 -24.69042969 114.82861328 C-32.75703558 109.22025326 -40.48861121 103.34132544 -48 97 C-49.01964844 96.16597656 -50.03929687 95.33195312 -51.08984375 94.47265625 C-65.67074546 82.42248515 -79.42210845 68.97221317 -91 54 C-92.2945549 52.43510858 -93.60326786 50.88288531 -94.91430664 49.33178711 C-103.24222338 39.46950004 -103.24222338 39.46950004 -106 35 C-104.57450062 30.72350187 -101.49095707 29.76811683 -97.6875 27.8125 C-96.933479 27.42102783 -96.17945801 27.02955566 -95.40258789 26.6262207 C-89.68593133 23.72316252 -83.87195688 21.11198874 -77.9375 18.6875 C-77.1849292 18.37466064 -76.4323584 18.06182129 -75.65698242 17.73950195 C-61.61279818 12.19196779 -46.25754319 9.65357537 -31.5625 6.375 C-29.78446522 5.976741 -28.00647024 5.57830424 -26.22851562 5.1796875 C-17.99925356 3.33580551 -17.99925356 3.33580551 -14.23562622 2.50067139 C-12.66917119 2.14986176 -11.10423725 1.79222279 -9.54049683 1.42950439 C-8.74201324 1.24603455 -7.94352966 1.0625647 -7.12084961 0.87353516 C-6.40215195 0.70616852 -5.68345428 0.53880188 -4.94297791 0.36636353 C-3 0 -3 0 0 0 Z " fill="#F1F1F1" transform="translate(254,265)"/>
|
||||
<path d="M0 0 C39.91465482 32.48867253 63.2456626 81.35317588 73 131 C73.2371875 132.17691406 73.474375 133.35382813 73.71875 134.56640625 C76.7841109 151.13287878 76.24083466 168.22940777 76 185 C75.98912354 186.09610596 75.98912354 186.09610596 75.97802734 187.21435547 C75.47216389 220.14692377 63.13206494 253.18468486 48 282 C18.26959159 283.44115086 -25.33802991 273.59650435 -49 255 C-48.68361572 254.40276123 -48.36723145 253.80552246 -48.04125977 253.19018555 C-36.97709016 232.26051474 -27.14279228 211.64773278 -19.18945312 189.32128906 C-18.38441572 187.07339726 -17.56496171 184.83062513 -16.73242188 182.59277344 C-13.61655872 174.13712952 -11.14272773 165.62425987 -9 156.875 C-8.8301358 156.18613708 -8.66027161 155.49727417 -8.48526001 154.78753662 C2.81787898 108.7243273 5.71666806 62.08650193 1.27001953 14.89306641 C0.8069978 9.93211931 0.40764073 4.96575536 0 0 Z " fill="#FDD45C" transform="translate(412,85)"/>
|
||||
<path d="M0 0 C0.17015625 0.70793701 0.3403125 1.41587402 0.515625 2.14526367 C7.64313216 31.48225377 17.46290471 63.42427903 36.8828125 87.31640625 C37.25148438 87.87199219 37.62015625 88.42757812 38 89 C36.85819428 92.42541716 36.14801848 92.87768713 33.3125 94.9375 C22.11818157 103.44238779 11.99254395 112.81877376 2.0625 122.75 C1.56627136 123.24532227 1.07004272 123.74064453 0.55877686 124.25097656 C-9.41639005 134.03332162 -9.41639005 134.03332162 -18 145 C-21 144 -21 144 -22.03979492 142.33959961 C-22.35021729 141.61780518 -22.66063965 140.89601074 -22.98046875 140.15234375 C-23.3360083 139.33169434 -23.69154785 138.51104492 -24.05786133 137.66552734 C-24.43064209 136.76527832 -24.80342285 135.8650293 -25.1875 134.9375 C-25.57897217 134.00373535 -25.97044434 133.0699707 -26.3737793 132.10791016 C-37.65186808 104.68159716 -47.16532336 73.03513053 -45 43 C-43.9609375 40.99609375 -43.9609375 40.99609375 -42.375 39.4375 C-41.73433594 38.78007812 -41.09367187 38.12265625 -40.43359375 37.4453125 C-39.63050781 36.63835937 -38.82742187 35.83140625 -38 35 C-36.96523535 33.87681436 -35.93569908 32.74879952 -34.91015625 31.6171875 C-6.15111294 0 -6.15111294 0 0 0 Z " fill="#102E4C" transform="translate(69,216)"/>
|
||||
<path d="M0 0 C1.59210443 0.19384035 3.18328392 0.39998945 4.76708984 0.65283203 C7.31474939 1.04893654 9.86693016 1.36788847 12.42578125 1.68359375 C13.38677734 1.80669922 14.34777344 1.92980469 15.33789062 2.05664062 C17.33969762 2.31294553 19.34233589 2.56283096 21.34570312 2.80664062 C22.30025391 2.93103516 23.25480469 3.05542969 24.23828125 3.18359375 C25.10493408 3.29243896 25.97158691 3.40128418 26.86450195 3.51342773 C29 4 29 4 31 6 C19.05731751 22.23621232 6.54582201 37.06119262 -7.99414062 51.05957031 C-9.66094422 52.67200398 -11.29351748 54.31012803 -12.921875 55.9609375 C-16.02048788 58.84690047 -18.35924098 60.90576672 -22.69506836 61.15478516 C-31.19537521 60.30124961 -39.58888709 59.05526314 -48.00048828 57.58081055 C-50.2320428 57.19063968 -52.46538718 56.81243536 -54.69921875 56.43554688 C-69.19057751 53.95410074 -83.44674857 50.71955617 -97.5 46.375 C-98.58514893 46.04089111 -99.67029785 45.70678223 -100.78833008 45.36254883 C-115.20131564 40.8116811 -129.13556124 34.99176444 -143 29 C-139.47451607 27.86680874 -139.47451607 27.86680874 -137.36914062 28.45776367 C-133.88438949 29.25533501 -130.40146662 29.20305726 -126.84375 29.23828125 C-126.0373558 29.2480751 -125.23096161 29.25786896 -124.40013123 29.26795959 C-80.36574252 29.69094452 -40.38558538 21.2116177 -1 1 C-0.67 0.67 -0.34 0.34 0 0 Z " fill="#CACACA" transform="translate(419,378)"/>
|
||||
<path d="M0 0 C0.33 0 0.66 0 1 0 C0.54241542 5.57530685 -0.00963428 11.11941049 -0.75688171 16.66413879 C-1.96921515 25.94387584 -2.2799563 35.08036483 -2.20581055 44.43139648 C-2.18737526 46.82872069 -2.18530326 49.22565849 -2.18554688 51.62304688 C-2.18111758 53.19271302 -2.17528192 54.76237589 -2.16796875 56.33203125 C-2.16449936 57.08578033 -2.16102997 57.83952942 -2.15745544 58.61611938 C-2.107274 63.08519094 -1.75778791 67.41478548 -1.203125 71.85546875 C-0.7559133 76.63465733 -0.7559133 76.63465733 -2.81640625 79.71875 C-4.17572266 80.60046875 -4.17572266 80.60046875 -5.5625 81.5 C-6.57957031 82.21929687 -7.59664062 82.93859375 -8.64453125 83.6796875 C-9.19318848 84.0519043 -9.7418457 84.42412109 -10.30712891 84.80761719 C-13.27375934 86.8971792 -16.00188846 89.25947784 -18.75 91.625 C-19.32588867 92.11516602 -19.90177734 92.60533203 -20.49511719 93.11035156 C-26.03420506 97.89066027 -31.18136664 103.04232782 -36.29003906 108.27587891 C-36.89170898 108.89124512 -37.49337891 109.50661133 -38.11328125 110.140625 C-38.64735596 110.69266602 -39.18143066 111.24470703 -39.73168945 111.81347656 C-41 113 -41 113 -42 113 C-39.39637407 74.08692624 -25.01488452 30.5999688 0 0 Z " fill="#C9C9C9" transform="translate(67,121)"/>
|
||||
<path d="M0 0 C3.73567719 3.58839242 6.97626107 7.38481074 10.125 11.5 C27.95827798 34.44747688 47.75103261 55.4679918 71 73 C72.85201549 74.40389998 74.70247297 75.80974419 76.55004883 77.21948242 C77.69754502 78.09389391 78.84827665 78.96408097 80.00268555 79.8293457 C83.88895451 82.77790902 83.88895451 82.77790902 85 85 C52.20778818 74.78055904 24.68036329 53.18789294 0 30 C-1.00160156 29.06220703 -1.00160156 29.06220703 -2.0234375 28.10546875 C-6.83024352 23.46178963 -10.91186335 18.27741277 -15 13 C-13.48922551 8.60868215 -10.08337724 6.68263208 -6.375 4.25 C-5.45847656 3.63318359 -5.45847656 3.63318359 -4.5234375 3.00390625 C-3.02104672 1.99448744 -1.51123091 0.9961353 0 0 Z " fill="#FCD25B" transform="translate(133,307)"/>
|
||||
<path d="M0 0 C0.66 0.33 1.32 0.66 2 1 C0.56771766 4.23150562 -0.87174573 7.45976369 -2.3125 10.6875 C-2.71662109 11.59951172 -3.12074219 12.51152344 -3.53710938 13.45117188 C-4.66778378 15.97910246 -5.82869067 18.49070223 -7 21 C-7.69609375 22.54494141 -7.69609375 22.54494141 -8.40625 24.12109375 C-10 26 -10 26 -12.85546875 26.5234375 C-13.95503906 26.47445313 -15.05460937 26.42546875 -16.1875 26.375 C-17.33992188 26.33117187 -18.49234375 26.28734375 -19.6796875 26.2421875 C-23.49034786 25.96423345 -27.22552883 25.59207391 -31 25 C-28.93427159 23.43289569 -26.85603559 21.89939837 -24.73046875 20.4140625 C-16.04612066 14.30433681 -7.51947331 7.51947331 0 0 Z " fill="#F8BD38" transform="translate(470,341)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 18 KiB |
+42
@@ -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
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useTournament } from '@/context/TournamentContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowRight, ArrowLeft, RotateCcw, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
export const Header = () => {
|
||||
const location = useLocation();
|
||||
const { teams, resetTournament, resetStoredTournament } = 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 && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="rounded-xl text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Zurücksetzen
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Turnier zurücksetzen?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Alle Runden und Punkte werden gelöscht. Die Teams bleiben erhalten.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={resetTournament}>Zurücksetzen</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="rounded-xl text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Speicher zurücksetzen
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Gespeicherten Speicher löschen?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Alle gespeicherten Teams, Felder und Runden werden aus dem Browser gelöscht. Die aktuelle Sitzung wird auf Anfang gesetzt.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={resetStoredTournament}>Speicher löschen</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Timer, Play, Pause, RotateCcw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useTournament } from '@/context/TournamentContext';
|
||||
import { Round } from '@/types/tournament';
|
||||
import { DEFAULT_ROUND_TIMER_SECONDS, hydrateRoundTimerState } from '@/utils/roundTimer';
|
||||
|
||||
interface RoundTimerProps {
|
||||
round: Round;
|
||||
}
|
||||
|
||||
export const RoundTimer = ({ round }: RoundTimerProps) => {
|
||||
const { updateRoundTimer } = useTournament();
|
||||
const [timerState, setTimerState] = useState(() => hydrateRoundTimerState(round.timer));
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editMinutes, setEditMinutes] = useState(String(Math.floor(DEFAULT_ROUND_TIMER_SECONDS / 60)));
|
||||
const [editSeconds, setEditSeconds] = useState('0');
|
||||
|
||||
useEffect(() => {
|
||||
setTimerState(hydrateRoundTimerState(round.timer));
|
||||
setIsEditing(false);
|
||||
}, [round.id]);
|
||||
|
||||
useEffect(() => {
|
||||
updateRoundTimer(round.id, timerState);
|
||||
}, [round.id, timerState, updateRoundTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!timerState.isRunning || timerState.remainingSeconds <= 0) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setTimerState((prev) => {
|
||||
if (!prev.isRunning) return prev;
|
||||
|
||||
if (prev.remainingSeconds <= 1) {
|
||||
return {
|
||||
...prev,
|
||||
remainingSeconds: 0,
|
||||
isRunning: false,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
remainingSeconds: prev.remainingSeconds - 1,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [timerState.isRunning, timerState.remainingSeconds]);
|
||||
|
||||
const togglePause = () => {
|
||||
setTimerState((prev) => {
|
||||
if (prev.remainingSeconds === 0) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
isRunning: !prev.isRunning,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setTimerState((prev) => ({
|
||||
...prev,
|
||||
remainingSeconds: prev.totalSeconds,
|
||||
isRunning: false,
|
||||
updatedAt: Date.now(),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleTimeEdit = () => {
|
||||
const newMinutes = Math.max(0, Math.min(99, parseInt(editMinutes) || 0));
|
||||
const newSeconds = Math.max(0, Math.min(59, parseInt(editSeconds) || 0));
|
||||
|
||||
const newTotal = newMinutes * 60 + newSeconds;
|
||||
setTimerState({
|
||||
totalSeconds: newTotal,
|
||||
remainingSeconds: newTotal,
|
||||
isRunning: false,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const startEditing = () => {
|
||||
const mins = Math.floor(timerState.totalSeconds / 60);
|
||||
const secs = timerState.totalSeconds % 60;
|
||||
setEditMinutes(mins.toString());
|
||||
setEditSeconds(secs.toString());
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const formatTime = (secs: number): string => {
|
||||
const minutes = Math.floor(secs / 60);
|
||||
const seconds = secs % 60;
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const isTimeExpired = timerState.remainingSeconds === 0;
|
||||
const floatingClassName = isTimeExpired
|
||||
? 'fixed left-1/2 top-1/2 z-[80] w-[min(92vw,24rem)] -translate-x-1/2 -translate-y-1/2 animate-timer-wiggle shadow-[0_28px_90px_-18px_hsl(0_84%_60%/0.78)] ring-4 ring-destructive/70 bg-background/96 backdrop-blur-xl'
|
||||
: 'relative';
|
||||
|
||||
return (
|
||||
<>
|
||||
{isTimeExpired && (
|
||||
<div className="fixed inset-0 z-[70] bg-destructive/20 backdrop-blur-md" aria-hidden="true" />
|
||||
)}
|
||||
|
||||
<div className={`flex items-center gap-3 px-4 py-3 card-apple transition-all duration-300 ${floatingClassName}`}>
|
||||
<div className={`p-2 rounded-xl transition-colors ${
|
||||
isTimeExpired
|
||||
? 'bg-destructive text-destructive-foreground shadow-[0_0_0_6px_hsl(var(--destructive)/0.16)]'
|
||||
: timerState.isRunning
|
||||
? 'bg-primary/10 text-primary animate-pulse'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}>
|
||||
<Timer className={`w-5 h-5 ${isTimeExpired ? 'animate-pulse' : ''}`} />
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="99"
|
||||
value={editMinutes}
|
||||
onChange={(e) => setEditMinutes(e.target.value)}
|
||||
className="w-12 h-8 text-center rounded-lg"
|
||||
placeholder="0"
|
||||
/>
|
||||
<span className="font-bold text-foreground">:</span>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="59"
|
||||
value={editSeconds}
|
||||
onChange={(e) => setEditSeconds(e.target.value)}
|
||||
className="w-12 h-8 text-center rounded-lg"
|
||||
placeholder="0"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleTimeEdit}
|
||||
className="rounded-lg h-8 px-2 text-xs"
|
||||
>
|
||||
OK
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={startEditing}
|
||||
className="cursor-pointer text-left hover:opacity-80 transition-opacity min-w-0"
|
||||
title="Klicke um Zeit zu bearbeiten"
|
||||
>
|
||||
<p className="text-sm font-medium text-muted-foreground">Rundenzeit</p>
|
||||
<p className={`font-bold tabular-nums transition-colors ${
|
||||
isTimeExpired ? 'text-4xl sm:text-5xl text-destructive drop-shadow-[0_0_18px_hsl(var(--destructive)/0.45)]' : 'text-2xl text-foreground'
|
||||
}`}>
|
||||
{formatTime(timerState.remainingSeconds)}
|
||||
</p>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={togglePause}
|
||||
disabled={isTimeExpired || isEditing}
|
||||
className="rounded-xl h-9 w-9"
|
||||
title={timerState.isRunning ? 'Pausieren' : 'Starten'}
|
||||
>
|
||||
{timerState.isRunning ? (
|
||||
<Pause className="w-4 h-4" />
|
||||
) : (
|
||||
<Play className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={reset}
|
||||
disabled={isEditing}
|
||||
className="rounded-xl h-9 w-9"
|
||||
title="Zurücksetzen"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,193 @@
|
||||
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';
|
||||
import { RoundTimer } from '@/components/RoundTimer';
|
||||
|
||||
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 flex-col sm:flex-row gap-3 items-start sm:items-center">
|
||||
{currentRound && <RoundTimer round={currentRound} />}
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,5 @@
|
||||
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
|
||||
|
||||
const AspectRatio = AspectRatioPrimitive.Root;
|
||||
|
||||
export { AspectRatio };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { useToast, toast } from "@/hooks/use-toast";
|
||||
|
||||
export { useToast, toast };
|
||||
@@ -0,0 +1,221 @@
|
||||
import React, { createContext, useContext, useState, ReactNode, useMemo, useEffect, useCallback } from 'react';
|
||||
import { Team, Round, TeamScore, Match, MatchResult, RoundTimerState } from '@/types/tournament';
|
||||
import { generateMatches, createNextRoundMatches } from '@/utils/tournamentUtils';
|
||||
import { clearTournamentState, loadTournamentState, saveTournamentState } from '@/utils/tournamentStorage';
|
||||
|
||||
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;
|
||||
resetStoredTournament: () => void;
|
||||
updateRoundTimer: (roundId: string, timer: RoundTimerState) => 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 [persistedState] = useState(() => loadTournamentState());
|
||||
|
||||
const [teams, setTeams] = useState<Team[]>(persistedState?.teams ?? []);
|
||||
const [fieldCount, setFieldCountState] = useState<number>(persistedState?.fieldCount ?? 4);
|
||||
const [rounds, setRounds] = useState<Round[]>(persistedState?.rounds ?? []);
|
||||
|
||||
useEffect(() => {
|
||||
saveTournamentState({
|
||||
teams,
|
||||
fieldCount,
|
||||
rounds,
|
||||
});
|
||||
}, [teams, fieldCount, rounds]);
|
||||
|
||||
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 = () => {
|
||||
// Keep the registered teams but clear rounds and computed stats
|
||||
// Scores / points are derived from `rounds`, so clearing rounds
|
||||
// resets all per-team statistics while preserving team list.
|
||||
setRounds([]);
|
||||
};
|
||||
|
||||
const resetStoredTournament = () => {
|
||||
clearTournamentState();
|
||||
setTeams([]);
|
||||
setFieldCountState(4);
|
||||
setRounds([]);
|
||||
};
|
||||
|
||||
const updateRoundTimer = useCallback((roundId: string, timer: RoundTimerState) => {
|
||||
setRounds((prev) =>
|
||||
prev.map((round) =>
|
||||
round.id === roundId ? { ...round, timer } : round
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const startNewRound = () => {
|
||||
if (currentRound && !currentRound.completed) return;
|
||||
|
||||
let state;
|
||||
|
||||
if (rounds.length === 0) {
|
||||
// First round: initial random generation
|
||||
state = generateMatches(teams, fieldCount);
|
||||
} else {
|
||||
// Subsequent rounds: rotation system
|
||||
const lastRound = rounds[rounds.length - 1];
|
||||
state = createNextRoundMatches(lastRound, 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 = Math.abs(scoreB - scoreA);
|
||||
|
||||
let pointsA: number;
|
||||
let pointsB: number;
|
||||
|
||||
if (scoreA > scoreB) {
|
||||
// Team A gewinnt
|
||||
pointsA = diff + 2;
|
||||
pointsB = -diff;
|
||||
} else {
|
||||
// Team B gewinnt
|
||||
pointsA = -diff;
|
||||
pointsB = diff + 2;
|
||||
}
|
||||
|
||||
const result: MatchResult = {
|
||||
scoreA,
|
||||
scoreB,
|
||||
pointsA,
|
||||
pointsB,
|
||||
};
|
||||
|
||||
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,
|
||||
resetStoredTournament,
|
||||
updateRoundTimer,
|
||||
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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
@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;
|
||||
}
|
||||
|
||||
@keyframes timer-wiggle {
|
||||
0%, 100% {
|
||||
transform: translateX(0) rotate(0deg) scale(1.03);
|
||||
}
|
||||
20% {
|
||||
transform: translateX(-2px) rotate(-1deg) scale(1.03);
|
||||
}
|
||||
40% {
|
||||
transform: translateX(2px) rotate(1deg) scale(1.03);
|
||||
}
|
||||
60% {
|
||||
transform: translateX(-1px) rotate(-0.5deg) scale(1.03);
|
||||
}
|
||||
80% {
|
||||
transform: translateX(1px) rotate(0.5deg) scale(1.03);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-timer-wiggle {
|
||||
animation: timer-wiggle 0.55s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import "./index.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
@@ -0,0 +1,78 @@
|
||||
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';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const Index = () => {
|
||||
const { teams } = useTournament();
|
||||
|
||||
const { addTeam } = useTournament();
|
||||
|
||||
const loadSampleTeams = () => {
|
||||
if (teams.length > 0) return;
|
||||
|
||||
const samples = [
|
||||
// Bundesliga (6)
|
||||
{ name: 'Berlin Spikers', club: 'BV Berlin', battleCry: 'Sieg!', league: 'bundesliga' as const },
|
||||
{ name: 'Hamburg Waves', club: 'HV Hamburg', battleCry: 'Auf gehts!', league: 'bundesliga' as const },
|
||||
{ name: 'Munich Smash', club: 'MV München', battleCry: 'Smash it!', league: 'bundesliga' as const },
|
||||
{ name: 'Cologne Crushers', club: 'CV Köln', battleCry: 'Köln kracht!', league: 'bundesliga' as const },
|
||||
{ name: 'Frankfurt Flyers', club: 'FV Frankfurt', battleCry: 'Flieg hoch!', league: 'bundesliga' as const },
|
||||
{ name: 'Stuttgart Surge', club: 'SV Stuttgart', battleCry: 'Surge!', league: 'bundesliga' as const },
|
||||
|
||||
// Champions (6)
|
||||
{ name: 'Prague Pioneers', club: 'PC Prag', battleCry: 'Força!', league: 'champions' as const },
|
||||
{ name: 'Vienna Victors', club: 'VC Wien', battleCry: 'Sieg voraus!', league: 'champions' as const },
|
||||
{ name: 'Zurich Zephyrs', club: 'ZC Zürich', battleCry: 'Zephyr!', league: 'champions' as const },
|
||||
{ name: 'Brussels Blitz', club: 'BC Brüssel', battleCry: 'Blitz!', league: 'champions' as const },
|
||||
{ name: 'Warsaw Warriors', club: 'WC Warschau', battleCry: 'Vorwärts!', league: 'champions' as const },
|
||||
{ name: 'Oslo Olympians', club: 'OC Oslo', battleCry: 'Go!', league: 'champions' as const },
|
||||
];
|
||||
|
||||
samples.forEach((t) => addTeam(t));
|
||||
};
|
||||
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-1 space-y-6">
|
||||
<TeamForm />
|
||||
<FieldSettings />
|
||||
<div>
|
||||
<Button onClick={loadSampleTeams} className="w-full rounded-xl" disabled={teams.length > 0}>
|
||||
Sample-Teams laden (6 Bundesliga / 6 Champions)
|
||||
</Button>
|
||||
</div>
|
||||
</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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createNextRoundMatches } from "@/utils/tournamentUtils";
|
||||
import { Match, Round, Team } from "@/types/tournament";
|
||||
|
||||
const createTeam = (id: string): Team => ({
|
||||
id,
|
||||
name: `Team ${id}`,
|
||||
club: `Club ${id}`,
|
||||
battleCry: `Cry ${id}`,
|
||||
league: "bundesliga",
|
||||
});
|
||||
|
||||
const createMatch = (teamA: Team, teamB: Team, fieldNumber: number): Match => ({
|
||||
id: `match-${teamA.id}-${teamB.id}`,
|
||||
teamA,
|
||||
teamB,
|
||||
fieldNumber,
|
||||
league: "bundesliga",
|
||||
});
|
||||
|
||||
const createPreviousRound = (playingPairs: Array<[Team, Team]>, waitingTeams: Team[] = []): Round => ({
|
||||
id: "round-1",
|
||||
roundNumber: 1,
|
||||
matches: playingPairs.map(([teamA, teamB], index) => createMatch(teamA, teamB, index + 1)),
|
||||
bundesligaWaiting: waitingTeams,
|
||||
championsWaiting: [],
|
||||
completed: true,
|
||||
});
|
||||
|
||||
describe("createNextRoundMatches", () => {
|
||||
it("keeps the anchor team fixed when there are no waiting teams", () => {
|
||||
const teamA = createTeam("A");
|
||||
const teamB = createTeam("B");
|
||||
const teamC = createTeam("C");
|
||||
const teamD = createTeam("D");
|
||||
|
||||
const nextRound = createNextRoundMatches(createPreviousRound([[teamA, teamB], [teamC, teamD]]), 2);
|
||||
|
||||
expect(nextRound.bundesligaMatches.map((match) => [match.teamA.id, match.teamB.id])).toEqual([
|
||||
["A", "D"],
|
||||
["B", "C"],
|
||||
]);
|
||||
expect(nextRound.bundesligaWaiting).toEqual([]);
|
||||
expect(nextRound.bundesligaFields).toBe(2);
|
||||
});
|
||||
|
||||
it("brings one waiting team forward and moves the last playing team to waiting", () => {
|
||||
const teamA = createTeam("A");
|
||||
const teamB = createTeam("B");
|
||||
const teamC = createTeam("C");
|
||||
const teamD = createTeam("D");
|
||||
const waitingTeam = createTeam("E");
|
||||
|
||||
const nextRound = createNextRoundMatches(
|
||||
createPreviousRound([[teamA, teamB], [teamC, teamD]], [waitingTeam]),
|
||||
2
|
||||
);
|
||||
|
||||
expect(nextRound.bundesligaMatches.map((match) => [match.teamA.id, match.teamB.id])).toEqual([
|
||||
["E", "A"],
|
||||
["B", "C"],
|
||||
]);
|
||||
expect(nextRound.bundesligaWaiting.map((team) => team.id)).toEqual(["D"]);
|
||||
expect(nextRound.bundesligaFields).toBe(2);
|
||||
});
|
||||
|
||||
it("brings multiple waiting teams forward and moves the same number of playing teams to waiting", () => {
|
||||
const teamA = createTeam("A");
|
||||
const teamB = createTeam("B");
|
||||
const teamC = createTeam("C");
|
||||
const teamD = createTeam("D");
|
||||
const teamE = createTeam("E");
|
||||
const teamF = createTeam("F");
|
||||
const waitingTeamG = createTeam("G");
|
||||
const waitingTeamH = createTeam("H");
|
||||
|
||||
const nextRound = createNextRoundMatches(
|
||||
createPreviousRound(
|
||||
[[teamA, teamB], [teamC, teamD], [teamE, teamF]],
|
||||
[waitingTeamG, waitingTeamH]
|
||||
),
|
||||
2
|
||||
);
|
||||
|
||||
expect(nextRound.bundesligaMatches.map((match) => [match.teamA.id, match.teamB.id])).toEqual([
|
||||
["G", "H"],
|
||||
["A", "B"],
|
||||
]);
|
||||
expect(nextRound.bundesligaWaiting.map((team) => team.id)).toEqual(["C", "D", "E", "F"]);
|
||||
expect(nextRound.bundesligaFields).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createDefaultRoundTimerState, hydrateRoundTimerState } from '@/utils/roundTimer';
|
||||
|
||||
describe('roundTimer', () => {
|
||||
it('creates the default five minute timer state', () => {
|
||||
const now = 1_000;
|
||||
|
||||
expect(createDefaultRoundTimerState(now)).toEqual({
|
||||
totalSeconds: 300,
|
||||
remainingSeconds: 300,
|
||||
isRunning: false,
|
||||
updatedAt: now,
|
||||
});
|
||||
});
|
||||
|
||||
it('hydrates a running timer by subtracting elapsed time after reload', () => {
|
||||
const hydrated = hydrateRoundTimerState(
|
||||
{
|
||||
totalSeconds: 300,
|
||||
remainingSeconds: 300,
|
||||
isRunning: true,
|
||||
updatedAt: 10_000,
|
||||
},
|
||||
15_500
|
||||
);
|
||||
|
||||
expect(hydrated).toEqual({
|
||||
totalSeconds: 300,
|
||||
remainingSeconds: 295,
|
||||
isRunning: true,
|
||||
updatedAt: 15_500,
|
||||
});
|
||||
});
|
||||
|
||||
it('stops a running timer once it has expired during reload hydration', () => {
|
||||
const hydrated = hydrateRoundTimerState(
|
||||
{
|
||||
totalSeconds: 60,
|
||||
remainingSeconds: 4,
|
||||
isRunning: true,
|
||||
updatedAt: 10_000,
|
||||
},
|
||||
15_000
|
||||
);
|
||||
|
||||
expect(hydrated).toEqual({
|
||||
totalSeconds: 60,
|
||||
remainingSeconds: 0,
|
||||
isRunning: false,
|
||||
updatedAt: 10_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import "@testing-library/jest-dom";
|
||||
|
||||
const createLocalStorageMock = () => {
|
||||
const store = new Map<string, string>();
|
||||
|
||||
return {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, String(value));
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
store.delete(key);
|
||||
},
|
||||
clear: () => {
|
||||
store.clear();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: createLocalStorageMock(),
|
||||
writable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => {},
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { clearTournamentState, loadTournamentState, saveTournamentState, TOURNAMENT_STORAGE_KEY } from '@/utils/tournamentStorage';
|
||||
import { Round, Team } from '@/types/tournament';
|
||||
|
||||
const createTeam = (id: string): Team => ({
|
||||
id,
|
||||
name: `Team ${id}`,
|
||||
club: `Club ${id}`,
|
||||
battleCry: `Cry ${id}`,
|
||||
league: 'bundesliga',
|
||||
});
|
||||
|
||||
const createRound = (): Round => ({
|
||||
id: 'round-1',
|
||||
roundNumber: 1,
|
||||
matches: [],
|
||||
bundesligaWaiting: [],
|
||||
championsWaiting: [],
|
||||
completed: false,
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearTournamentState();
|
||||
});
|
||||
|
||||
describe('tournamentStorage', () => {
|
||||
it('persists and restores tournament state from localStorage', () => {
|
||||
const state = {
|
||||
teams: [createTeam('A')],
|
||||
fieldCount: 6,
|
||||
rounds: [createRound()],
|
||||
};
|
||||
|
||||
saveTournamentState(state);
|
||||
|
||||
expect(window.localStorage.getItem(TOURNAMENT_STORAGE_KEY)).not.toBeNull();
|
||||
expect(loadTournamentState()).toEqual(state);
|
||||
});
|
||||
|
||||
it('falls back to null for invalid stored data', () => {
|
||||
window.localStorage.setItem(TOURNAMENT_STORAGE_KEY, '{broken json');
|
||||
|
||||
expect(loadTournamentState()).toBeNull();
|
||||
});
|
||||
|
||||
it('clears stored tournament state', () => {
|
||||
saveTournamentState({
|
||||
teams: [createTeam('A')],
|
||||
fieldCount: 4,
|
||||
rounds: [createRound()],
|
||||
});
|
||||
|
||||
clearTournamentState();
|
||||
|
||||
expect(window.localStorage.getItem(TOURNAMENT_STORAGE_KEY)).toBeNull();
|
||||
expect(loadTournamentState()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
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 RoundTimerState {
|
||||
totalSeconds: number;
|
||||
remainingSeconds: number;
|
||||
isRunning: boolean;
|
||||
updatedAt: 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;
|
||||
timer?: RoundTimerState;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { RoundTimerState } from '@/types/tournament';
|
||||
|
||||
export const DEFAULT_ROUND_TIMER_SECONDS = 300;
|
||||
|
||||
export const createDefaultRoundTimerState = (now = Date.now()): RoundTimerState => ({
|
||||
totalSeconds: DEFAULT_ROUND_TIMER_SECONDS,
|
||||
remainingSeconds: DEFAULT_ROUND_TIMER_SECONDS,
|
||||
isRunning: false,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
export const hydrateRoundTimerState = (
|
||||
timer: RoundTimerState | undefined,
|
||||
now = Date.now()
|
||||
): RoundTimerState => {
|
||||
if (!timer) {
|
||||
return createDefaultRoundTimerState(now);
|
||||
}
|
||||
|
||||
if (!timer.isRunning) {
|
||||
return timer;
|
||||
}
|
||||
|
||||
const elapsedSeconds = Math.floor((now - timer.updatedAt) / 1000);
|
||||
const remainingSeconds = Math.max(0, timer.remainingSeconds - elapsedSeconds);
|
||||
|
||||
return {
|
||||
...timer,
|
||||
remainingSeconds,
|
||||
isRunning: remainingSeconds > 0,
|
||||
updatedAt: remainingSeconds > 0 ? now : timer.updatedAt,
|
||||
};
|
||||
};
|
||||
|
||||
export const snapshotRoundTimerState = (timer: Omit<RoundTimerState, 'updatedAt'>, now = Date.now()): RoundTimerState => ({
|
||||
...timer,
|
||||
updatedAt: now,
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Round, Team } from '@/types/tournament';
|
||||
|
||||
export interface PersistedTournamentState {
|
||||
teams: Team[];
|
||||
fieldCount: number;
|
||||
rounds: Round[];
|
||||
}
|
||||
|
||||
export const TOURNAMENT_STORAGE_KEY = 'nvj-tournament-state';
|
||||
|
||||
type StorageLike = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
|
||||
|
||||
const memoryStorage = (() => {
|
||||
const store = new Map<string, string>();
|
||||
|
||||
return {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
store.delete(key);
|
||||
},
|
||||
} satisfies StorageLike;
|
||||
})();
|
||||
|
||||
const getStorage = (): StorageLike => {
|
||||
if (typeof window === 'undefined') return memoryStorage;
|
||||
|
||||
try {
|
||||
const storage = window.localStorage;
|
||||
|
||||
if (
|
||||
typeof storage?.getItem === 'function' &&
|
||||
typeof storage?.setItem === 'function' &&
|
||||
typeof storage?.removeItem === 'function'
|
||||
) {
|
||||
return storage;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to in-memory storage when the browser storage API is unavailable.
|
||||
}
|
||||
|
||||
return memoryStorage;
|
||||
};
|
||||
|
||||
export const loadTournamentState = (): PersistedTournamentState | null => {
|
||||
const rawState = getStorage().getItem(TOURNAMENT_STORAGE_KEY);
|
||||
if (!rawState) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawState) as Partial<PersistedTournamentState>;
|
||||
|
||||
if (!Array.isArray(parsed.teams) || !Array.isArray(parsed.rounds)) return null;
|
||||
|
||||
return {
|
||||
teams: parsed.teams,
|
||||
fieldCount: typeof parsed.fieldCount === 'number' && parsed.fieldCount >= 1 ? parsed.fieldCount : 4,
|
||||
rounds: parsed.rounds,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const saveTournamentState = (state: PersistedTournamentState): void => {
|
||||
getStorage().setItem(TOURNAMENT_STORAGE_KEY, JSON.stringify(state));
|
||||
};
|
||||
|
||||
export const clearTournamentState = (): void => {
|
||||
getStorage().removeItem(TOURNAMENT_STORAGE_KEY);
|
||||
};
|
||||
@@ -0,0 +1,221 @@
|
||||
import { Team, Match, TournamentState, League, Round } from '@/types/tournament';
|
||||
|
||||
type LeagueFieldAllocation = {
|
||||
bundesligaFields: number;
|
||||
championsFields: number;
|
||||
};
|
||||
|
||||
const shuffleTeams = (teams: Team[]): Team[] => {
|
||||
return [...teams].sort(() => Math.random() - 0.5);
|
||||
};
|
||||
|
||||
const getLeagueFieldAllocation = (
|
||||
bundesligaTeams: Team[],
|
||||
championsTeams: Team[],
|
||||
fieldCount: number
|
||||
): LeagueFieldAllocation => {
|
||||
const bundesligaHasTeams = bundesligaTeams.length >= 2;
|
||||
const championsHasTeams = championsTeams.length >= 2;
|
||||
|
||||
if (bundesligaHasTeams && championsHasTeams) {
|
||||
const bundesligaFields = Math.floor(fieldCount / 2);
|
||||
return {
|
||||
bundesligaFields,
|
||||
championsFields: fieldCount - bundesligaFields,
|
||||
};
|
||||
}
|
||||
|
||||
if (bundesligaHasTeams) {
|
||||
return { bundesligaFields: fieldCount, championsFields: 0 };
|
||||
}
|
||||
|
||||
if (championsHasTeams) {
|
||||
return { bundesligaFields: 0, championsFields: fieldCount };
|
||||
}
|
||||
|
||||
return { bundesligaFields: 0, championsFields: 0 };
|
||||
};
|
||||
|
||||
const pairOrderedTeamsIntoMatches = (
|
||||
orderedTeams: Team[],
|
||||
availableFields: number,
|
||||
league: League,
|
||||
fieldOffset: number
|
||||
): { matches: Match[]; waiting: Team[] } => {
|
||||
const matches: Match[] = [];
|
||||
const waiting: Team[] = [];
|
||||
|
||||
let teamIndex = 0;
|
||||
let fieldNumber = fieldOffset + 1;
|
||||
|
||||
while (teamIndex < orderedTeams.length - 1 && matches.length < availableFields) {
|
||||
matches.push({
|
||||
id: crypto.randomUUID(),
|
||||
teamA: orderedTeams[teamIndex],
|
||||
teamB: orderedTeams[teamIndex + 1],
|
||||
fieldNumber,
|
||||
league,
|
||||
});
|
||||
teamIndex += 2;
|
||||
fieldNumber++;
|
||||
}
|
||||
|
||||
while (teamIndex < orderedTeams.length) {
|
||||
waiting.push(orderedTeams[teamIndex]);
|
||||
teamIndex++;
|
||||
}
|
||||
|
||||
return { matches, waiting };
|
||||
};
|
||||
|
||||
const createLeagueMatches = (
|
||||
leagueTeams: Team[],
|
||||
availableFields: number,
|
||||
league: League,
|
||||
fieldOffset: number
|
||||
): { matches: Match[]; waiting: Team[] } => {
|
||||
if (leagueTeams.length < 2) {
|
||||
return { matches: [], waiting: leagueTeams };
|
||||
}
|
||||
|
||||
return pairOrderedTeamsIntoMatches(leagueTeams, availableFields, league, fieldOffset);
|
||||
};
|
||||
|
||||
export const generateMatches = (teams: Team[], fieldCount: number): TournamentState => {
|
||||
const bundesligaTeams = teams.filter((t) => t.league === 'bundesliga');
|
||||
const championsTeams = teams.filter((t) => t.league === 'champions');
|
||||
|
||||
const { bundesligaFields, championsFields } = getLeagueFieldAllocation(
|
||||
bundesligaTeams,
|
||||
championsTeams,
|
||||
fieldCount
|
||||
);
|
||||
|
||||
const bundesliga = createLeagueMatches(shuffleTeams(bundesligaTeams), bundesligaFields, 'bundesliga', 0);
|
||||
const champions = createLeagueMatches(
|
||||
shuffleTeams(championsTeams),
|
||||
championsFields,
|
||||
'champions',
|
||||
bundesligaFields
|
||||
);
|
||||
|
||||
return {
|
||||
bundesligaMatches: bundesliga.matches,
|
||||
championsMatches: champions.matches,
|
||||
bundesligaWaiting: bundesliga.waiting,
|
||||
championsWaiting: champions.waiting,
|
||||
bundesligaFields,
|
||||
championsFields,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Rotation system for subsequent rounds
|
||||
* Implements 3 cases:
|
||||
* - Case 1: Even teams (no waiting) - all rotate using Circle Rotation algorithm
|
||||
* - Case 2: Odd teams (1 waiting) - waiting joins, last playing goes to waiting
|
||||
* - Case 3: Multiple waiting (2+) - all waiting teams move forward, last N playing go to waiting
|
||||
*/
|
||||
export const createNextRoundMatches = (
|
||||
previousRound: Round,
|
||||
fieldCount: number
|
||||
): TournamentState => {
|
||||
const bundesligaMatches = previousRound.matches.filter((m) => m.league === 'bundesliga');
|
||||
const championsMatches = previousRound.matches.filter((m) => m.league === 'champions');
|
||||
|
||||
const { bundesligaFields, championsFields } = getLeagueFieldAllocation(
|
||||
bundesligaMatches.flatMap((match) => [match.teamA, match.teamB]).concat(previousRound.bundesligaWaiting),
|
||||
championsMatches.flatMap((match) => [match.teamA, match.teamB]).concat(previousRound.championsWaiting),
|
||||
fieldCount
|
||||
);
|
||||
|
||||
const bundesliga = createRotatedLeagueMatches(
|
||||
bundesligaMatches,
|
||||
previousRound.bundesligaWaiting,
|
||||
bundesligaFields,
|
||||
'bundesliga',
|
||||
0
|
||||
);
|
||||
|
||||
const champions = createRotatedLeagueMatches(
|
||||
championsMatches,
|
||||
previousRound.championsWaiting,
|
||||
championsFields,
|
||||
'champions',
|
||||
bundesligaFields
|
||||
);
|
||||
|
||||
return {
|
||||
bundesligaMatches: bundesliga.matches,
|
||||
championsMatches: champions.matches,
|
||||
bundesligaWaiting: bundesliga.waiting,
|
||||
championsWaiting: champions.waiting,
|
||||
bundesligaFields,
|
||||
championsFields,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Circle Rotation Algorithm (Roundrobin)
|
||||
* Keeps first team fixed, rotates all others by 1 position
|
||||
* Example: [A, B, C, D] → [A, D, B, C] → [A, C, D, B] → [A, B, C, D]
|
||||
*/
|
||||
function rotateTeamsInCircle(teams: Team[]): Team[] {
|
||||
if (teams.length <= 2) return teams;
|
||||
|
||||
const fixed = teams[0];
|
||||
const rotating = teams.slice(1);
|
||||
|
||||
// Rotate: move last to first
|
||||
const rotated = [rotating[rotating.length - 1], ...rotating.slice(0, -1)];
|
||||
|
||||
return [fixed, ...rotated];
|
||||
}
|
||||
|
||||
const createRotatedLeagueMatches = (
|
||||
currentMatches: Match[],
|
||||
waitingTeams: Team[],
|
||||
availableFields: number,
|
||||
league: League,
|
||||
fieldOffset: number
|
||||
): { matches: Match[]; waiting: Team[] } => {
|
||||
if (currentMatches.length === 0) {
|
||||
return { matches: [], waiting: waitingTeams };
|
||||
}
|
||||
|
||||
const playingTeams = currentMatches.flatMap((match) => [match.teamA, match.teamB]);
|
||||
const allTeamsForLeague = [...playingTeams, ...waitingTeams];
|
||||
|
||||
if (allTeamsForLeague.length < 2) {
|
||||
return { matches: [], waiting: allTeamsForLeague };
|
||||
}
|
||||
|
||||
const rotatedTeams = rotateTeamsForNextRound(playingTeams, waitingTeams, allTeamsForLeague);
|
||||
const maxTeamsPlaying = availableFields * 2;
|
||||
const teamsToPlay = rotatedTeams.slice(0, maxTeamsPlaying);
|
||||
const newWaiting = rotatedTeams.slice(maxTeamsPlaying);
|
||||
|
||||
const pairings = pairOrderedTeamsIntoMatches(teamsToPlay, availableFields, league, fieldOffset);
|
||||
|
||||
return { matches: pairings.matches, waiting: [...pairings.waiting, ...newWaiting] };
|
||||
};
|
||||
|
||||
const rotateTeamsForNextRound = (
|
||||
playingTeams: Team[],
|
||||
waitingTeams: Team[],
|
||||
allTeamsForLeague: Team[]
|
||||
): Team[] => {
|
||||
if (waitingTeams.length === 0) {
|
||||
return rotateTeamsInCircle(allTeamsForLeague);
|
||||
}
|
||||
|
||||
if (waitingTeams.length === 1) {
|
||||
return [waitingTeams[0], ...playingTeams.slice(0, -1), playingTeams[playingTeams.length - 1]];
|
||||
}
|
||||
|
||||
return [
|
||||
...waitingTeams,
|
||||
...playingTeams.slice(0, playingTeams.length - waitingTeams.length),
|
||||
...playingTeams.slice(-waitingTeams.length),
|
||||
];
|
||||
};
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user