docs: add README, INSTALL guide, .env.example + shadcn/ui source components
This commit is contained in:
parent
74b6d83d3b
commit
a369b4afb1
47
.env.example
47
.env.example
|
|
@ -1,21 +1,38 @@
|
||||||
# Database (use shared PostgreSQL Stack 49)
|
# JIRA AI Fixer v2.0 - Environment Configuration
|
||||||
DATABASE_URL=postgresql://postgres:postgres@postgres_database:5432/jira_fixer_v2
|
# Copy this file to .env and fill in your values
|
||||||
|
|
||||||
# Redis (use shared Redis Stack 12)
|
# ===== REQUIRED =====
|
||||||
REDIS_URL=redis://redis_redis:6379
|
|
||||||
|
|
||||||
# JWT
|
# Database (PostgreSQL)
|
||||||
JWT_SECRET=your-super-secret-jwt-key-change-me
|
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/jira_fixer
|
||||||
|
|
||||||
# Email (Resend)
|
# Security (generate with: openssl rand -hex 32)
|
||||||
RESEND_API_KEY=re_LP4Vf7jA_E9fvcBtQ9aD219jA2QEBcZs7
|
SECRET_KEY=change-me-in-production
|
||||||
|
JWT_SECRET=change-me-in-production
|
||||||
|
|
||||||
# AI (OpenRouter)
|
# ===== OPTIONAL =====
|
||||||
OPENROUTER_API_KEY=your-openrouter-key
|
|
||||||
|
|
||||||
# Git (Gitea)
|
# Redis (for job queue)
|
||||||
GITEA_URL=https://gitea.startdata.com.br
|
REDIS_URL=redis://localhost:6379/0
|
||||||
GITEA_TOKEN=4b28e0a797f16e0f9f986ad03a77a320fe90d3d6
|
|
||||||
|
|
||||||
# App
|
# Email notifications (https://resend.com)
|
||||||
APP_URL=https://jira-fixer.startdata.com.br
|
RESEND_API_KEY=
|
||||||
|
EMAIL_FROM=JIRA AI Fixer <noreply@yourdomain.com>
|
||||||
|
|
||||||
|
# AI Analysis (https://openrouter.ai)
|
||||||
|
OPENROUTER_API_KEY=
|
||||||
|
|
||||||
|
# Git Integration
|
||||||
|
GITEA_URL=
|
||||||
|
GITEA_TOKEN=
|
||||||
|
|
||||||
|
# Application URL (for emails and callbacks)
|
||||||
|
APP_URL=http://localhost:8000
|
||||||
|
|
||||||
|
# JIRA Cloud OAuth
|
||||||
|
JIRA_CLIENT_ID=
|
||||||
|
JIRA_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# GitHub OAuth
|
||||||
|
GITHUB_CLIENT_ID=
|
||||||
|
GITHUB_CLIENT_SECRET=
|
||||||
|
|
|
||||||
|
|
@ -6,3 +6,6 @@ __pycache__/
|
||||||
.venv/
|
.venv/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
frontend/package-lock.json
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,295 @@
|
||||||
|
# JIRA AI Fixer v2.0 - Installation Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
JIRA AI Fixer is an enterprise AI-powered platform that automatically analyzes issues from JIRA, ServiceNow, GitHub, GitLab and other platforms, generates root cause analysis, and creates Pull Requests with fixes.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ ┌──────────────┐ ┌────────────┐
|
||||||
|
│ Frontend │────▶│ Backend │────▶│ PostgreSQL │
|
||||||
|
│ (Nginx) │ │ (FastAPI) │ │ │
|
||||||
|
│ React SPA │ │ Python 3.11 │ └────────────┘
|
||||||
|
└─────────────┘ └──────┬───────┘
|
||||||
|
│
|
||||||
|
┌──────▼───────┐
|
||||||
|
│ Redis │
|
||||||
|
│ (Queue) │
|
||||||
|
└──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tech Stack
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- Python 3.11 + FastAPI
|
||||||
|
- PostgreSQL (async via SQLAlchemy + asyncpg)
|
||||||
|
- Redis (job queue)
|
||||||
|
- JWT Authentication
|
||||||
|
- Resend (email notifications)
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- React 18 + Vite
|
||||||
|
- TailwindCSS + shadcn/ui components
|
||||||
|
- React Query (data fetching)
|
||||||
|
- Recharts (analytics)
|
||||||
|
- React Router (SPA routing)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Docker & Docker Compose (or Docker Swarm)
|
||||||
|
- PostgreSQL 14+ (or use existing instance)
|
||||||
|
- Redis (or use existing instance)
|
||||||
|
- A domain with SSL (recommended)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start (Docker Compose)
|
||||||
|
|
||||||
|
### 1. Clone the repository
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://gitea.startdata.com.br/startdata/jira-ai-fixer.git
|
||||||
|
cd jira-ai-fixer
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configure environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `.env` with your settings:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Database
|
||||||
|
DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/jira_fixer
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
|
||||||
|
# Security (generate with: openssl rand -hex 32)
|
||||||
|
SECRET_KEY=your-secret-key-here
|
||||||
|
JWT_SECRET=your-jwt-secret-here
|
||||||
|
|
||||||
|
# Email (optional - Resend.com)
|
||||||
|
RESEND_API_KEY=re_xxxxx
|
||||||
|
EMAIL_FROM=JIRA AI Fixer <noreply@yourdomain.com>
|
||||||
|
|
||||||
|
# AI Analysis (optional - OpenRouter.ai)
|
||||||
|
OPENROUTER_API_KEY=sk-or-xxxxx
|
||||||
|
|
||||||
|
# Git Integration (optional - Gitea/GitHub)
|
||||||
|
GITEA_URL=https://gitea.yourdomain.com
|
||||||
|
GITEA_TOKEN=your-token
|
||||||
|
|
||||||
|
# OAuth Integrations (optional)
|
||||||
|
JIRA_CLIENT_ID=
|
||||||
|
JIRA_CLIENT_SECRET=
|
||||||
|
GITHUB_CLIENT_ID=
|
||||||
|
GITHUB_CLIENT_SECRET=
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Start with Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Access the application
|
||||||
|
|
||||||
|
- **Frontend:** http://localhost (or your domain)
|
||||||
|
- **API Docs:** http://localhost/api/docs
|
||||||
|
- **Health Check:** http://localhost/api/health
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Production Deployment (Docker Swarm + Traefik)
|
||||||
|
|
||||||
|
### 1. Create the stack file
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
image: python:3.11-slim
|
||||||
|
command: >
|
||||||
|
bash -c "
|
||||||
|
apt-get update && apt-get install -y curl &&
|
||||||
|
pip install fastapi uvicorn[standard] sqlalchemy[asyncio] asyncpg
|
||||||
|
pydantic[email] pydantic-settings python-jose[cryptography]
|
||||||
|
passlib[bcrypt] httpx python-multipart email-validator &&
|
||||||
|
mkdir -p /app && cd /app &&
|
||||||
|
curl -sL 'https://gitea.yourdomain.com/org/jira-ai-fixer/archive/master.tar.gz' |
|
||||||
|
tar xz --strip-components=1 &&
|
||||||
|
uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
|
"
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql+asyncpg://user:pass@db_host:5432/jira_fixer
|
||||||
|
- REDIS_URL=redis://redis_host:6379
|
||||||
|
- JWT_SECRET=your-jwt-secret
|
||||||
|
- RESEND_API_KEY=re_xxxxx
|
||||||
|
- APP_URL=https://jira-fixer.yourdomain.com
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
- db_network
|
||||||
|
deploy:
|
||||||
|
replicas: 1
|
||||||
|
restart_policy:
|
||||||
|
condition: on-failure
|
||||||
|
delay: 25s
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: nginx:alpine
|
||||||
|
command: >
|
||||||
|
sh -c "apk add --no-cache curl &&
|
||||||
|
mkdir -p /app && cd /app &&
|
||||||
|
curl -sL 'https://gitea.yourdomain.com/org/jira-ai-fixer/archive/master.tar.gz' |
|
||||||
|
tar xz --strip-components=1 &&
|
||||||
|
cp -r frontend_build/* /usr/share/nginx/html/ &&
|
||||||
|
echo 'c2VydmVyIHsKICBsaXN0ZW4gODA7...' | base64 -d > /etc/nginx/conf.d/default.conf &&
|
||||||
|
nginx -g 'daemon off;'"
|
||||||
|
networks:
|
||||||
|
- proxy_network
|
||||||
|
- internal
|
||||||
|
deploy:
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.jira-fixer.rule=Host(`jira-fixer.yourdomain.com`)
|
||||||
|
- traefik.http.routers.jira-fixer.entrypoints=websecure
|
||||||
|
- traefik.http.routers.jira-fixer.tls.certresolver=le
|
||||||
|
- traefik.http.services.jira-fixer.loadbalancer.server.port=80
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxy_network:
|
||||||
|
external: true
|
||||||
|
db_network:
|
||||||
|
external: true
|
||||||
|
internal:
|
||||||
|
driver: overlay
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Nginx Config (base64 encoded in command)
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://api:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker stack deploy -c docker-compose.yml jira-fixer
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd app
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt # or install manually (see stack command)
|
||||||
|
uvicorn app.main:app --reload --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend dev server runs on http://localhost:5173 with proxy to backend.
|
||||||
|
|
||||||
|
### Build Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run build
|
||||||
|
cp -r dist/* ../frontend_build/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| POST | `/api/auth/register` | Register new user |
|
||||||
|
| POST | `/api/auth/login` | Login |
|
||||||
|
| GET | `/api/organizations` | List organizations |
|
||||||
|
| POST | `/api/organizations` | Create organization |
|
||||||
|
| GET | `/api/issues` | List issues |
|
||||||
|
| POST | `/api/issues` | Create issue |
|
||||||
|
| GET | `/api/issues/:id` | Get issue detail |
|
||||||
|
| PATCH | `/api/issues/:id` | Update issue |
|
||||||
|
| POST | `/api/webhooks/jira` | JIRA webhook |
|
||||||
|
| POST | `/api/webhooks/servicenow` | ServiceNow webhook |
|
||||||
|
| POST | `/api/webhooks/github` | GitHub webhook |
|
||||||
|
| GET | `/api/reports/summary` | Report summary |
|
||||||
|
| GET | `/api/health` | Health check |
|
||||||
|
|
||||||
|
Full API documentation available at `/api/docs` (Swagger UI).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integrations
|
||||||
|
|
||||||
|
### JIRA Cloud
|
||||||
|
1. Go to Settings > Integrations > JIRA
|
||||||
|
2. Enter your Atlassian domain, email, and API token
|
||||||
|
3. Configure webhook in JIRA to point to `https://your-domain/api/webhooks/jira`
|
||||||
|
|
||||||
|
### GitHub
|
||||||
|
1. Create a GitHub App or use personal access token
|
||||||
|
2. Configure in Settings > Integrations > GitHub
|
||||||
|
3. Set webhook URL: `https://your-domain/api/webhooks/github`
|
||||||
|
|
||||||
|
### ServiceNow
|
||||||
|
1. Configure REST integration in ServiceNow
|
||||||
|
2. Point to: `https://your-domain/api/webhooks/servicenow`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables Reference
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
|----------|----------|---------|-------------|
|
||||||
|
| `DATABASE_URL` | Yes | - | PostgreSQL connection string |
|
||||||
|
| `REDIS_URL` | No | `redis://localhost:6379` | Redis connection string |
|
||||||
|
| `SECRET_KEY` | Yes | - | App secret key |
|
||||||
|
| `JWT_SECRET` | Yes | - | JWT signing key |
|
||||||
|
| `JWT_EXPIRE_MINUTES` | No | `1440` | Token expiry (24h) |
|
||||||
|
| `RESEND_API_KEY` | No | - | Email service API key |
|
||||||
|
| `OPENROUTER_API_KEY` | No | - | AI analysis API key |
|
||||||
|
| `GITEA_URL` | No | - | Git server URL |
|
||||||
|
| `GITEA_TOKEN` | No | - | Git server access token |
|
||||||
|
| `JIRA_CLIENT_ID` | No | - | JIRA OAuth client ID |
|
||||||
|
| `JIRA_CLIENT_SECRET` | No | - | JIRA OAuth client secret |
|
||||||
|
| `GITHUB_CLIENT_ID` | No | - | GitHub OAuth client ID |
|
||||||
|
| `GITHUB_CLIENT_SECRET` | No | - | GitHub OAuth client secret |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT © StartData
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
# JIRA AI Fixer v2.0
|
||||||
|
|
||||||
|
Enterprise AI-powered issue analysis and automated fix generation platform.
|
||||||
|
|
||||||
|
## 🚀 Features
|
||||||
|
|
||||||
|
### Issue Analysis
|
||||||
|
- 🤖 **AI-Powered Analysis** — Automatic root cause analysis using LLMs
|
||||||
|
- 🔀 **Auto PR Generation** — Creates Pull Requests with suggested fixes
|
||||||
|
- 🎯 **Confidence Scoring** — AI confidence level for each analysis
|
||||||
|
- 📊 **Analytics Dashboard** — Track trends, resolution rates, and team performance
|
||||||
|
|
||||||
|
### Multi-Source Integration
|
||||||
|
- 🔵 **JIRA Cloud** — Full bidirectional sync
|
||||||
|
- ⚙️ **ServiceNow** — Incident and change management
|
||||||
|
- 🐙 **GitHub** — Issues and repository integration
|
||||||
|
- 🦊 **GitLab** — Issues and merge requests
|
||||||
|
- 💚 **Zendesk** — Support ticket analysis
|
||||||
|
- 🎫 **TicketHub** — Native integration
|
||||||
|
|
||||||
|
### Enterprise Features
|
||||||
|
- 🏢 **Multi-Organization** — Manage multiple teams/projects
|
||||||
|
- 🔐 **JWT Authentication** — Secure token-based auth
|
||||||
|
- 👥 **Team Management** — Role-based access control
|
||||||
|
- 📧 **Email Notifications** — Automated alerts via Resend
|
||||||
|
- 📈 **Reports & Analytics** — Performance metrics and insights
|
||||||
|
- 🔌 **Webhooks** — Incoming webhooks from any platform
|
||||||
|
- 📝 **Audit Logs** — Complete action history
|
||||||
|
|
||||||
|
### Modern UI
|
||||||
|
- ⚡ **React 18** + Vite (fast builds)
|
||||||
|
- 🎨 **shadcn/ui** components (Button, Dialog, Command, Toast, Skeleton...)
|
||||||
|
- 📊 **Recharts** interactive charts
|
||||||
|
- 🌙 **Dark Mode** by default
|
||||||
|
- 📱 **Responsive** layout
|
||||||
|
|
||||||
|
## 📦 Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|-----------|
|
||||||
|
| **Frontend** | React 18, Vite, TailwindCSS, shadcn/ui, Recharts |
|
||||||
|
| **Backend** | Python 3.11, FastAPI, SQLAlchemy (async) |
|
||||||
|
| **Database** | PostgreSQL 14+ |
|
||||||
|
| **Queue** | Redis |
|
||||||
|
| **Email** | Resend |
|
||||||
|
| **AI** | OpenRouter (Llama, Claude, GPT) |
|
||||||
|
|
||||||
|
## 🛠 Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone
|
||||||
|
git clone https://gitea.startdata.com.br/startdata/jira-ai-fixer.git
|
||||||
|
cd jira-ai-fixer
|
||||||
|
|
||||||
|
# Backend
|
||||||
|
pip install fastapi uvicorn sqlalchemy[asyncio] asyncpg pydantic-settings python-jose passlib httpx
|
||||||
|
uvicorn app.main:app --reload
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd frontend && npm install && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📖 Documentation
|
||||||
|
|
||||||
|
- **[Installation Guide](INSTALL.md)** — Full setup instructions
|
||||||
|
- **[API Documentation](https://jira-fixer.startdata.com.br/api/docs)** — Swagger UI
|
||||||
|
|
||||||
|
## 🌐 Live Demo
|
||||||
|
|
||||||
|
- **App:** https://jira-fixer.startdata.com.br
|
||||||
|
- **API:** https://jira-fixer.startdata.com.br/api/docs
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
MIT © StartData
|
||||||
|
|
@ -9,14 +9,24 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-toast": "^1.2.15",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@tanstack/react-query": "^5.17.0",
|
||||||
|
"axios": "^1.6.5",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.0",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"date-fns": "^3.2.0",
|
||||||
|
"lucide-react": "^0.574.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.22.0",
|
"react-router-dom": "^6.22.0",
|
||||||
"@tanstack/react-query": "^5.17.0",
|
|
||||||
"axios": "^1.6.5",
|
|
||||||
"recharts": "^2.10.4",
|
"recharts": "^2.10.4",
|
||||||
"date-fns": "^3.2.0",
|
"tailwind-merge": "^3.4.1"
|
||||||
"clsx": "^2.1.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.48",
|
"@types/react": "^18.2.48",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
CommandDialog,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
CommandSeparator,
|
||||||
|
} from "./ui/command";
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Ticket,
|
||||||
|
Plug,
|
||||||
|
Users,
|
||||||
|
BarChart3,
|
||||||
|
Settings,
|
||||||
|
Search,
|
||||||
|
Moon,
|
||||||
|
Sun,
|
||||||
|
LogOut,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
import { useTheme } from "../context/ThemeContext";
|
||||||
|
|
||||||
|
const navigationItems = [
|
||||||
|
{ icon: LayoutDashboard, label: "Dashboard", path: "/", keywords: ["home", "overview"] },
|
||||||
|
{ icon: Ticket, label: "Issues", path: "/issues", keywords: ["bugs", "tickets", "problems"] },
|
||||||
|
{ icon: Plug, label: "Integrations", path: "/integrations", keywords: ["connect", "jira", "github"] },
|
||||||
|
{ icon: Users, label: "Team", path: "/team", keywords: ["members", "people", "users"] },
|
||||||
|
{ icon: BarChart3, label: "Reports", path: "/reports", keywords: ["analytics", "stats", "charts"] },
|
||||||
|
{ icon: Settings, label: "Settings", path: "/settings", keywords: ["config", "preferences"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function CommandPalette() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { logout, user } = useAuth();
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const down = (e) => {
|
||||||
|
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen((open) => !open);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", down);
|
||||||
|
return () => document.removeEventListener("keydown", down);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const runCommand = useCallback((command) => {
|
||||||
|
setOpen(false);
|
||||||
|
command();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||||
|
<CommandInput placeholder="Type a command or search..." />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No results found.</CommandEmpty>
|
||||||
|
|
||||||
|
<CommandGroup heading="Navigation">
|
||||||
|
{navigationItems.map((item) => (
|
||||||
|
<CommandItem
|
||||||
|
key={item.path}
|
||||||
|
value={`${item.label} ${item.keywords.join(" ")}`}
|
||||||
|
onSelect={() => runCommand(() => navigate(item.path))}
|
||||||
|
>
|
||||||
|
<item.icon className="mr-2 h-4 w-4" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
|
||||||
|
<CommandSeparator />
|
||||||
|
|
||||||
|
<CommandGroup heading="Theme">
|
||||||
|
<CommandItem
|
||||||
|
value="toggle theme dark light mode"
|
||||||
|
onSelect={() => runCommand(toggleTheme)}
|
||||||
|
>
|
||||||
|
{theme === "dark" ? (
|
||||||
|
<Sun className="mr-2 h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Moon className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<span>Toggle {theme === "dark" ? "Light" : "Dark"} Mode</span>
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
|
||||||
|
<CommandSeparator />
|
||||||
|
|
||||||
|
<CommandGroup heading="Account">
|
||||||
|
<CommandItem
|
||||||
|
value="sign out logout"
|
||||||
|
onSelect={() => runCommand(logout)}
|
||||||
|
>
|
||||||
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
<span>Sign Out</span>
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</CommandDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import {
|
||||||
|
Toast,
|
||||||
|
ToastClose,
|
||||||
|
ToastDescription,
|
||||||
|
ToastProvider,
|
||||||
|
ToastTitle,
|
||||||
|
ToastViewport,
|
||||||
|
} from "./ui/toast";
|
||||||
|
import { useToast } from "../hooks/use-toast";
|
||||||
|
import { CheckCircle, XCircle, AlertTriangle, Info } from "lucide-react";
|
||||||
|
|
||||||
|
const variantIcons = {
|
||||||
|
default: Info,
|
||||||
|
success: CheckCircle,
|
||||||
|
destructive: XCircle,
|
||||||
|
warning: AlertTriangle,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Toaster() {
|
||||||
|
const { toasts } = useToast();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
{toasts.map(function ({ id, title, description, action, variant, ...props }) {
|
||||||
|
const Icon = variantIcons[variant] || variantIcons.default;
|
||||||
|
return (
|
||||||
|
<Toast key={id} variant={variant} {...props}>
|
||||||
|
<div className="flex gap-3 items-start">
|
||||||
|
<Icon className="h-5 w-5 shrink-0 mt-0.5" />
|
||||||
|
<div className="grid gap-1">
|
||||||
|
{title && <ToastTitle>{title}</ToastTitle>}
|
||||||
|
{description && <ToastDescription>{description}</ToastDescription>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
<ToastClose />
|
||||||
|
</Toast>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<ToastViewport />
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary-600 text-white hover:bg-primary-700",
|
||||||
|
destructive: "bg-red-600 text-white hover:bg-red-700",
|
||||||
|
outline: "border border-gray-600 bg-transparent hover:bg-gray-800 text-gray-100",
|
||||||
|
secondary: "bg-gray-700 text-white hover:bg-gray-600",
|
||||||
|
ghost: "hover:bg-gray-800 text-gray-100",
|
||||||
|
link: "text-primary-400 underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-4 py-2",
|
||||||
|
sm: "h-9 px-3 text-xs",
|
||||||
|
lg: "h-11 px-8",
|
||||||
|
icon: "h-10 w-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const Button = React.forwardRef(({ 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,113 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { Command as CommandPrimitive } from "cmdk";
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
import { Dialog, DialogContent } from "./dialog";
|
||||||
|
|
||||||
|
const Command = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full flex-col overflow-hidden rounded-lg bg-gray-800 text-gray-100",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Command.displayName = CommandPrimitive.displayName;
|
||||||
|
|
||||||
|
const CommandDialog = ({ children, ...props }) => {
|
||||||
|
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-gray-400 [&_[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(({ className, ...props }, ref) => (
|
||||||
|
<div className="flex items-center border-b border-gray-700 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-gray-500 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||||
|
|
||||||
|
const CommandList = React.forwardRef(({ 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((props, ref) => (
|
||||||
|
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm text-gray-400" {...props} />
|
||||||
|
));
|
||||||
|
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||||
|
|
||||||
|
const CommandGroup = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden p-1 text-gray-100 [&_[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-gray-400",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||||
|
|
||||||
|
const CommandSeparator = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 h-px bg-gray-700", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
const CommandItem = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-md px-2 py-1.5 text-sm outline-none aria-selected:bg-gray-700 aria-selected:text-white data-[disabled='true']:pointer-events-none data-[disabled='true']:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const CommandShortcut = ({ className, ...props }) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("ml-auto text-xs tracking-widest text-gray-500", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
CommandShortcut.displayName = "CommandShortcut";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
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(({ 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(({ 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 border-gray-700 bg-gray-900 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%] rounded-xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-gray-900 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-gray-800 data-[state=open]:text-gray-400">
|
||||||
|
<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 }) => (
|
||||||
|
<div
|
||||||
|
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
DialogHeader.displayName = "DialogHeader";
|
||||||
|
|
||||||
|
const DialogFooter = ({ className, ...props }) => (
|
||||||
|
<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(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-lg font-semibold leading-none tracking-tight text-white", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-gray-400", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogClose,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
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(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default select-none items-center rounded-md px-2 py-1.5 text-sm outline-none focus:bg-gray-700 data-[state=open]:bg-gray-700",
|
||||||
|
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(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-lg border border-gray-700 bg-gray-800 p-1 text-gray-100 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(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-lg border border-gray-700 bg-gray-800 p-1 text-gray-100 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(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-md px-2 py-1.5 text-sm outline-none transition-colors focus:bg-gray-700 focus:text-white data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-gray-700 focus:text-white data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
));
|
||||||
|
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-gray-700 focus:text-white data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
));
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("px-2 py-1.5 text-sm font-semibold text-gray-300", inset && "pl-8", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-gray-700", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuShortcut = ({ className, ...props }) => {
|
||||||
|
return (
|
||||||
|
<span className={cn("ml-auto text-xs tracking-widest text-gray-500", className)} {...props} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
const Skeleton = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("animate-pulse rounded-md bg-gray-700", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Skeleton.displayName = "Skeleton";
|
||||||
|
|
||||||
|
export { Skeleton };
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
const Switch = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitives.Root
|
||||||
|
className={cn(
|
||||||
|
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-900 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary-600 data-[state=unchecked]:bg-gray-700",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<SwitchPrimitives.Thumb
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none block h-5 w-5 rounded-full bg-white 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,102 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as ToastPrimitives from "@radix-ui/react-toast";
|
||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
const ToastProvider = ToastPrimitives.Provider;
|
||||||
|
|
||||||
|
const ToastViewport = React.forwardRef(({ 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-lg 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-gray-700 bg-gray-800 text-gray-100",
|
||||||
|
success: "border-green-800 bg-green-900/80 text-green-100",
|
||||||
|
destructive: "border-red-800 bg-red-900/80 text-red-100",
|
||||||
|
warning: "border-yellow-800 bg-yellow-900/80 text-yellow-100",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const Toast = React.forwardRef(({ className, variant, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<ToastPrimitives.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(toastVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Toast.displayName = ToastPrimitives.Root.displayName;
|
||||||
|
|
||||||
|
const ToastAction = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border border-gray-600 bg-transparent px-3 text-sm font-medium ring-offset-gray-900 transition-colors hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastAction.displayName = ToastPrimitives.Action.displayName;
|
||||||
|
|
||||||
|
const ToastClose = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Close
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"absolute right-2 top-2 rounded-md p-1 text-gray-400 opacity-0 transition-opacity hover:text-gray-100 focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
toast-close=""
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</ToastPrimitives.Close>
|
||||||
|
));
|
||||||
|
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||||
|
|
||||||
|
const ToastTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||||
|
|
||||||
|
const ToastDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm opacity-90", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
ToastProvider,
|
||||||
|
ToastViewport,
|
||||||
|
Toast,
|
||||||
|
ToastTitle,
|
||||||
|
ToastDescription,
|
||||||
|
ToastClose,
|
||||||
|
ToastAction,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
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(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 overflow-hidden rounded-md border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-100 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,151 @@
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
const TOAST_LIMIT = 5;
|
||||||
|
const TOAST_REMOVE_DELAY = 5000;
|
||||||
|
|
||||||
|
const actionTypes = {
|
||||||
|
ADD_TOAST: "ADD_TOAST",
|
||||||
|
UPDATE_TOAST: "UPDATE_TOAST",
|
||||||
|
DISMISS_TOAST: "DISMISS_TOAST",
|
||||||
|
REMOVE_TOAST: "REMOVE_TOAST",
|
||||||
|
};
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
function genId() {
|
||||||
|
count = (count + 1) % Number.MAX_SAFE_INTEGER;
|
||||||
|
return count.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastTimeouts = new Map();
|
||||||
|
|
||||||
|
const addToRemoveQueue = (toastId) => {
|
||||||
|
if (toastTimeouts.has(toastId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
toastTimeouts.delete(toastId);
|
||||||
|
dispatch({
|
||||||
|
type: actionTypes.REMOVE_TOAST,
|
||||||
|
toastId: toastId,
|
||||||
|
});
|
||||||
|
}, TOAST_REMOVE_DELAY);
|
||||||
|
|
||||||
|
toastTimeouts.set(toastId, timeout);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reducer = (state, action) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case actionTypes.ADD_TOAST:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||||
|
};
|
||||||
|
|
||||||
|
case actionTypes.UPDATE_TOAST:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
case actionTypes.DISMISS_TOAST: {
|
||||||
|
const { toastId } = action;
|
||||||
|
|
||||||
|
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 actionTypes.REMOVE_TOAST:
|
||||||
|
if (action.toastId === undefined) {
|
||||||
|
return { ...state, toasts: [] };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||||
|
};
|
||||||
|
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const listeners = [];
|
||||||
|
|
||||||
|
let memoryState = { toasts: [] };
|
||||||
|
|
||||||
|
function dispatch(action) {
|
||||||
|
memoryState = reducer(memoryState, action);
|
||||||
|
listeners.forEach((listener) => {
|
||||||
|
listener(memoryState);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast({ variant = "default", ...props }) {
|
||||||
|
const id = genId();
|
||||||
|
|
||||||
|
const update = (props) =>
|
||||||
|
dispatch({
|
||||||
|
type: actionTypes.UPDATE_TOAST,
|
||||||
|
toast: { ...props, id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const dismiss = () => dispatch({ type: actionTypes.DISMISS_TOAST, toastId: id });
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: actionTypes.ADD_TOAST,
|
||||||
|
toast: {
|
||||||
|
...props,
|
||||||
|
id,
|
||||||
|
variant,
|
||||||
|
open: true,
|
||||||
|
onOpenChange: (open) => {
|
||||||
|
if (!open) dismiss();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
dismiss,
|
||||||
|
update,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function useToast() {
|
||||||
|
const [state, setState] = React.useState(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) => dispatch({ type: actionTypes.DISMISS_TOAST, toastId }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useToast, toast };
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue