From a369b4afb1c0e8e5a564edf6d262536926e36a93 Mon Sep 17 00:00:00 2001 From: Ricel Leite Date: Wed, 18 Feb 2026 21:43:57 -0300 Subject: [PATCH] docs: add README, INSTALL guide, .env.example + shadcn/ui source components --- .env.example | 47 ++- .gitignore | 3 + INSTALL.md | 295 +++++++++++++++++++ README.md | 75 +++++ frontend/package.json | 18 +- frontend/src/components/command-palette.jsx | 108 +++++++ frontend/src/components/toaster.jsx | 43 +++ frontend/src/components/ui/button.jsx | 44 +++ frontend/src/components/ui/command.jsx | 113 +++++++ frontend/src/components/ui/dialog.jsx | 89 ++++++ frontend/src/components/ui/dropdown-menu.jsx | 149 ++++++++++ frontend/src/components/ui/skeleton.jsx | 13 + frontend/src/components/ui/switch.jsx | 23 ++ frontend/src/components/ui/toast.jsx | 102 +++++++ frontend/src/components/ui/tooltip.jsx | 22 ++ frontend/src/hooks/use-toast.js | 151 ++++++++++ frontend/src/lib/utils.js | 6 + 17 files changed, 1282 insertions(+), 19 deletions(-) create mode 100644 INSTALL.md create mode 100644 README.md create mode 100644 frontend/src/components/command-palette.jsx create mode 100644 frontend/src/components/toaster.jsx create mode 100644 frontend/src/components/ui/button.jsx create mode 100644 frontend/src/components/ui/command.jsx create mode 100644 frontend/src/components/ui/dialog.jsx create mode 100644 frontend/src/components/ui/dropdown-menu.jsx create mode 100644 frontend/src/components/ui/skeleton.jsx create mode 100644 frontend/src/components/ui/switch.jsx create mode 100644 frontend/src/components/ui/toast.jsx create mode 100644 frontend/src/components/ui/tooltip.jsx create mode 100644 frontend/src/hooks/use-toast.js create mode 100644 frontend/src/lib/utils.js diff --git a/.env.example b/.env.example index a361647..745a2e7 100644 --- a/.env.example +++ b/.env.example @@ -1,21 +1,38 @@ -# Database (use shared PostgreSQL Stack 49) -DATABASE_URL=postgresql://postgres:postgres@postgres_database:5432/jira_fixer_v2 +# JIRA AI Fixer v2.0 - Environment Configuration +# Copy this file to .env and fill in your values -# Redis (use shared Redis Stack 12) -REDIS_URL=redis://redis_redis:6379 +# ===== REQUIRED ===== -# JWT -JWT_SECRET=your-super-secret-jwt-key-change-me +# Database (PostgreSQL) +DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/jira_fixer -# Email (Resend) -RESEND_API_KEY=re_LP4Vf7jA_E9fvcBtQ9aD219jA2QEBcZs7 +# Security (generate with: openssl rand -hex 32) +SECRET_KEY=change-me-in-production +JWT_SECRET=change-me-in-production -# AI (OpenRouter) -OPENROUTER_API_KEY=your-openrouter-key +# ===== OPTIONAL ===== -# Git (Gitea) -GITEA_URL=https://gitea.startdata.com.br -GITEA_TOKEN=4b28e0a797f16e0f9f986ad03a77a320fe90d3d6 +# Redis (for job queue) +REDIS_URL=redis://localhost:6379/0 -# App -APP_URL=https://jira-fixer.startdata.com.br +# Email notifications (https://resend.com) +RESEND_API_KEY= +EMAIL_FROM=JIRA AI Fixer + +# 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= diff --git a/.gitignore b/.gitignore index fd20fe4..b3ab88f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ __pycache__/ .venv/ *.egg-info/ package-lock.json +frontend/node_modules/ +frontend/dist/ +frontend/package-lock.json diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..9b4cfb8 --- /dev/null +++ b/INSTALL.md @@ -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 + +# 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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..5e0b365 --- /dev/null +++ b/README.md @@ -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 diff --git a/frontend/package.json b/frontend/package.json index f7a13bb..92be907 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,14 +9,24 @@ "preview": "vite preview" }, "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-dom": "^18.2.0", "react-router-dom": "^6.22.0", - "@tanstack/react-query": "^5.17.0", - "axios": "^1.6.5", "recharts": "^2.10.4", - "date-fns": "^3.2.0", - "clsx": "^2.1.0" + "tailwind-merge": "^3.4.1" }, "devDependencies": { "@types/react": "^18.2.48", diff --git a/frontend/src/components/command-palette.jsx b/frontend/src/components/command-palette.jsx new file mode 100644 index 0000000..9e78bcc --- /dev/null +++ b/frontend/src/components/command-palette.jsx @@ -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 ( + + + + No results found. + + + {navigationItems.map((item) => ( + runCommand(() => navigate(item.path))} + > + + {item.label} + + ))} + + + + + + runCommand(toggleTheme)} + > + {theme === "dark" ? ( + + ) : ( + + )} + Toggle {theme === "dark" ? "Light" : "Dark"} Mode + + + + + + + runCommand(logout)} + > + + Sign Out + + + + + ); +} diff --git a/frontend/src/components/toaster.jsx b/frontend/src/components/toaster.jsx new file mode 100644 index 0000000..68a8924 --- /dev/null +++ b/frontend/src/components/toaster.jsx @@ -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 ( + + {toasts.map(function ({ id, title, description, action, variant, ...props }) { + const Icon = variantIcons[variant] || variantIcons.default; + return ( + +
+ +
+ {title && {title}} + {description && {description}} +
+
+ {action} + +
+ ); + })} + +
+ ); +} diff --git a/frontend/src/components/ui/button.jsx b/frontend/src/components/ui/button.jsx new file mode 100644 index 0000000..888f67e --- /dev/null +++ b/frontend/src/components/ui/button.jsx @@ -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 ( + + ); +}); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/frontend/src/components/ui/command.jsx b/frontend/src/components/ui/command.jsx new file mode 100644 index 0000000..4858e94 --- /dev/null +++ b/frontend/src/components/ui/command.jsx @@ -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) => ( + +)); +Command.displayName = CommandPrimitive.displayName; + +const CommandDialog = ({ children, ...props }) => { + return ( + + + + {children} + + + + ); +}; + +const CommandInput = React.forwardRef(({ className, ...props }, ref) => ( +
+ + +
+)); +CommandInput.displayName = CommandPrimitive.Input.displayName; + +const CommandList = React.forwardRef(({ className, ...props }, ref) => ( + +)); +CommandList.displayName = CommandPrimitive.List.displayName; + +const CommandEmpty = React.forwardRef((props, ref) => ( + +)); +CommandEmpty.displayName = CommandPrimitive.Empty.displayName; + +const CommandGroup = React.forwardRef(({ className, ...props }, ref) => ( + +)); +CommandGroup.displayName = CommandPrimitive.Group.displayName; + +const CommandSeparator = React.forwardRef(({ className, ...props }, ref) => ( + +)); +CommandSeparator.displayName = CommandPrimitive.Separator.displayName; + +const CommandItem = React.forwardRef(({ className, ...props }, ref) => ( + +)); +CommandItem.displayName = CommandPrimitive.Item.displayName; + +const CommandShortcut = ({ className, ...props }) => { + return ( + + ); +}; +CommandShortcut.displayName = "CommandShortcut"; + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +}; diff --git a/frontend/src/components/ui/dialog.jsx b/frontend/src/components/ui/dialog.jsx new file mode 100644 index 0000000..158ba24 --- /dev/null +++ b/frontend/src/components/ui/dialog.jsx @@ -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) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ className, ...props }) => ( +
+); +DialogHeader.displayName = "DialogHeader"; + +const DialogFooter = ({ className, ...props }) => ( +
+); +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/frontend/src/components/ui/dropdown-menu.jsx b/frontend/src/components/ui/dropdown-menu.jsx new file mode 100644 index 0000000..2327ed0 --- /dev/null +++ b/frontend/src/components/ui/dropdown-menu.jsx @@ -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) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ className, ...props }) => { + return ( + + ); +}; +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/frontend/src/components/ui/skeleton.jsx b/frontend/src/components/ui/skeleton.jsx new file mode 100644 index 0000000..ea1ac9f --- /dev/null +++ b/frontend/src/components/ui/skeleton.jsx @@ -0,0 +1,13 @@ +import * as React from "react"; +import { cn } from "../../lib/utils"; + +const Skeleton = React.forwardRef(({ className, ...props }, ref) => ( +
+)); +Skeleton.displayName = "Skeleton"; + +export { Skeleton }; diff --git a/frontend/src/components/ui/switch.jsx b/frontend/src/components/ui/switch.jsx new file mode 100644 index 0000000..687868b --- /dev/null +++ b/frontend/src/components/ui/switch.jsx @@ -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) => ( + + + +)); +Switch.displayName = SwitchPrimitives.Root.displayName; + +export { Switch }; diff --git a/frontend/src/components/ui/toast.jsx b/frontend/src/components/ui/toast.jsx new file mode 100644 index 0000000..05fed9e --- /dev/null +++ b/frontend/src/components/ui/toast.jsx @@ -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) => ( + +)); +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 ( + + ); +}); +Toast.displayName = ToastPrimitives.Root.displayName; + +const ToastAction = React.forwardRef(({ className, ...props }, ref) => ( + +)); +ToastAction.displayName = ToastPrimitives.Action.displayName; + +const ToastClose = React.forwardRef(({ className, ...props }, ref) => ( + + + +)); +ToastClose.displayName = ToastPrimitives.Close.displayName; + +const ToastTitle = React.forwardRef(({ className, ...props }, ref) => ( + +)); +ToastTitle.displayName = ToastPrimitives.Title.displayName; + +const ToastDescription = React.forwardRef(({ className, ...props }, ref) => ( + +)); +ToastDescription.displayName = ToastPrimitives.Description.displayName; + +export { + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +}; diff --git a/frontend/src/components/ui/tooltip.jsx b/frontend/src/components/ui/tooltip.jsx new file mode 100644 index 0000000..1b466af --- /dev/null +++ b/frontend/src/components/ui/tooltip.jsx @@ -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) => ( + +)); +TooltipContent.displayName = TooltipPrimitive.Content.displayName; + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; diff --git a/frontend/src/hooks/use-toast.js b/frontend/src/hooks/use-toast.js new file mode 100644 index 0000000..c70dd23 --- /dev/null +++ b/frontend/src/hooks/use-toast.js @@ -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 }; diff --git a/frontend/src/lib/utils.js b/frontend/src/lib/utils.js new file mode 100644 index 0000000..378ccef --- /dev/null +++ b/frontend/src/lib/utils.js @@ -0,0 +1,6 @@ +import { clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs) { + return twMerge(clsx(inputs)); +}