docs: add README, INSTALL guide, .env.example + shadcn/ui source components

This commit is contained in:
Ricel Leite 2026-02-18 21:43:57 -03:00
parent 74b6d83d3b
commit a369b4afb1
17 changed files with 1282 additions and 19 deletions

View File

@ -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=

3
.gitignore vendored
View File

@ -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

295
INSTALL.md Normal file
View File

@ -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

75
README.md Normal file
View File

@ -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

View File

@ -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",

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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 };

View File

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

View File

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

View File

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

View File

@ -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 };

View File

@ -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 };

View File

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

View File

@ -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 };

View File

@ -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 };

View File

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