traceability

This commit is contained in:
2026-01-25 14:22:22 +01:00
commit f965340abe
109 changed files with 25321 additions and 0 deletions

14
.dockerignore Normal file
View File

@@ -0,0 +1,14 @@
node_modules
dist
.git
.gitignore
*.md
.env
.env.*
.DS_Store
*.log
npm-debug.log*
.vscode
.idea
coverage
.nyc_output

24
.gitignore vendored Normal file
View File

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

32
Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
COPY bun.lockb ./
# Install dependencies
RUN npm install
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM nginx:alpine AS production
# Copy custom nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy built assets from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Expose port 80
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

73
README.md Normal file
View File

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

BIN
bun.lockb Normal file

Binary file not shown.

20
components.json Normal file
View File

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

144
deploy.sh Normal file
View File

@@ -0,0 +1,144 @@
#!/bin/bash
# ============================================
# ASF Traceability Matrix Deployment Script
# ============================================
# This script deploys the Traceability Matrix web app
# using Docker Compose with Caddy reverse proxy
#
# Domain: Traceability.nabd-co.com
# ============================================
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Configuration
APP_NAME="traceability"
APP_DIR="/opt/traceability"
CADDY_DIR="/root/caddy"
REPO_URL="" # Add your git repo URL if using git deployment
echo -e "${BLUE}============================================${NC}"
echo -e "${BLUE} ASF Traceability Matrix Deployment${NC}"
echo -e "${BLUE}============================================${NC}"
# Function to print status
print_status() {
echo -e "${GREEN}[✓]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[!]${NC} $1"
}
print_error() {
echo -e "${RED}[✗]${NC} $1"
}
# Check if running as root
if [ "$EUID" -ne 0 ]; then
print_error "Please run as root (sudo ./deploy.sh)"
exit 1
fi
# Step 1: Create application directory
echo ""
echo -e "${BLUE}Step 1: Setting up application directory...${NC}"
mkdir -p $APP_DIR
print_status "Created directory: $APP_DIR"
# Step 2: Copy files to application directory (if running from repo)
echo ""
echo -e "${BLUE}Step 2: Copying application files...${NC}"
if [ -f "docker-compose.yml" ]; then
cp -r . $APP_DIR/
print_status "Copied application files to $APP_DIR"
else
print_warning "No local files found. Please ensure files are in $APP_DIR"
fi
cd $APP_DIR
# Step 3: Ensure Caddy network exists
echo ""
echo -e "${BLUE}Step 3: Checking Docker network...${NC}"
if ! docker network ls | grep -q "caddy_default"; then
print_warning "Caddy network not found. Creating..."
docker network create caddy_default
print_status "Created caddy_default network"
else
print_status "Caddy network exists"
fi
# Step 4: Update Caddy configuration
echo ""
echo -e "${BLUE}Step 4: Updating Caddy configuration...${NC}"
# Check if Traceability entry already exists in Caddyfile
if grep -q "Traceability.nabd-co.com" "$CADDY_DIR/Caddyfile" 2>/dev/null; then
print_status "Caddy configuration already exists"
else
# Append Traceability configuration to Caddyfile
cat >> "$CADDY_DIR/Caddyfile" << 'EOF'
# -------------------------
# Traceability Matrix Proxy
# -------------------------
Traceability.nabd-co.com {
reverse_proxy traceability_web:80
encode gzip
}
EOF
print_status "Added Traceability configuration to Caddyfile"
fi
# Step 5: Build and start the application
echo ""
echo -e "${BLUE}Step 5: Building and starting application...${NC}"
docker compose down --remove-orphans 2>/dev/null || true
docker compose build --no-cache
docker compose up -d
print_status "Application started"
# Step 6: Reload Caddy
echo ""
echo -e "${BLUE}Step 6: Reloading Caddy...${NC}"
cd $CADDY_DIR
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null || {
print_warning "Could not reload Caddy automatically. Restarting container..."
docker compose restart caddy 2>/dev/null || docker restart caddy 2>/dev/null || true
}
print_status "Caddy reloaded"
# Step 7: Health check
echo ""
echo -e "${BLUE}Step 7: Running health check...${NC}"
sleep 5
if docker ps | grep -q "traceability_web"; then
print_status "Container is running"
else
print_error "Container failed to start. Check logs with: docker logs traceability_web"
exit 1
fi
# Final output
echo ""
echo -e "${GREEN}============================================${NC}"
echo -e "${GREEN} Deployment Complete!${NC}"
echo -e "${GREEN}============================================${NC}"
echo ""
echo -e "Application URL: ${BLUE}https://Traceability.nabd-co.com${NC}"
echo ""
echo -e "Useful commands:"
echo -e " View logs: ${YELLOW}docker logs -f traceability_web${NC}"
echo -e " Restart: ${YELLOW}docker compose -f $APP_DIR/docker-compose.yml restart${NC}"
echo -e " Stop: ${YELLOW}docker compose -f $APP_DIR/docker-compose.yml down${NC}"
echo -e " Rebuild: ${YELLOW}docker compose -f $APP_DIR/docker-compose.yml up -d --build${NC}"
echo ""

18
docker-compose.yml Normal file
View File

@@ -0,0 +1,18 @@
version: '3.8'
services:
traceability-web:
build:
context: .
dockerfile: Dockerfile
container_name: traceability_web
restart: always
networks:
- caddy_network
labels:
- "traefik.enable=false"
networks:
caddy_network:
external: true
name: caddy_default

26
eslint.config.js Normal file
View File

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

26
index.html Normal file
View File

@@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ASF Traceability Matrix | NABD Solutions</title>
<meta name="description" content="ASF Sensor Hub Traceability Dashboard - Requirements management, gap analysis, and documentation for the Agricultural Sensor Framework" />
<meta name="author" content="NABD Solutions" />
<meta property="og:title" content="ASF Traceability Matrix | NABD Solutions" />
<meta property="og:description" content="Requirements management, gap analysis, and documentation for the Agricultural Sensor Framework" />
<meta property="og:type" content="website" />
<meta property="og:image" content="/images/nabd-logo.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@NABDSolutions" />
<meta name="twitter:image" content="/images/nabd-logo.png" />
<link rel="icon" type="image/png" href="/images/nabd-logo.png" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

29
nginx.conf Normal file
View File

@@ -0,0 +1,29 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript application/json;
# Handle client-side routing
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
}

11022
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

95
package.json Normal file
View File

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

6
postcss.config.js Normal file
View File

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

View File

@@ -0,0 +1,128 @@
import requests
import pandas as pd
import sys
import json
# --- CONFIGURATION ---
BASE_URL = "https://openproject.nabd-co.com/"
API_KEY = "dfc009a268f8490c2502458bad364c2f0ae27762c6b4a38c4dac6d394ca3058f"
PROJECT_IDENTIFIER = "asf"
AUTH = ('apikey', API_KEY)
def get_type_id_map():
url = f"{BASE_URL}/api/v3/types"
try:
response = requests.get(url, auth=AUTH)
response.raise_for_status()
return {t['name'].lower(): str(t['id']) for t in response.json()['_embedded']['elements']}
except Exception as e:
print(f"❌ Error fetching type definitions: {e}")
return {}
def fetch_work_packages(types_filter=None):
url = f"{BASE_URL}/api/v3/projects/{PROJECT_IDENTIFIER}/work_packages"
params = {"pageSize": 1000} # Fetch more items in one go
if types_filter:
type_map = get_type_id_map()
type_ids = [type_map[t.lower()] for t in types_filter if t.lower() in type_map]
if not type_ids:
print(f"⚠️ Warning: Types {types_filter} not found. Fetching all types instead.")
else:
filters = [{"type": {"operator": "=", "values": type_ids}}]
params['filters'] = json.dumps(filters)
try:
response = requests.get(url, auth=AUTH, params=params)
response.raise_for_status()
return response.json()['_embedded']['elements']
except Exception as e:
print(f"❌ API Error: {e}")
return []
def get_relations(wp_id):
url = f"{BASE_URL}/api/v3/work_packages/{wp_id}/relations"
try:
resp = requests.get(url, auth=AUTH)
relations = resp.json()['_embedded']['elements']
rel_list = []
for r in relations:
rel_type = r['type']
# Safely get the ID of the related work package
to_link = r['_links'].get('to', {}).get('href', '')
from_link = r['_links'].get('from', {}).get('href', '')
other_id = to_link.split('/')[-1] if f"/{wp_id}" not in to_link else from_link.split('/')[-1]
if other_id:
rel_list.append(f"{rel_type}(#{other_id})")
return "; ".join(rel_list)
except:
return ""
def export_data(types_list=None, output_format='csv'):
print(f"🔍 Fetching data for: {types_list if types_list else 'All Types'}...")
wps = fetch_work_packages(types_list)
if not wps:
print("No work packages found.")
return
data = []
for wp in wps:
wp_id = wp['id']
# --- SAFE PARENT ID CHECK ---
parent_id = ""
parent_link = wp['_links'].get('parent')
# Check if parent exists AND has an href attribute
if parent_link and parent_link.get('href'):
parent_id = parent_link['href'].split('/')[-1]
# Relations call
relations = get_relations(wp_id)
data.append({
"ID": wp_id,
"Type": wp['_links']['type']['title'],
"Status": wp['_links']['status']['title'],
"Title": wp['subject'],
"Description": wp['description']['raw'] if wp.get('description') else "",
"Parent_ID": parent_id,
"Relations": relations
})
df = pd.DataFrame(data)
filename = f"traceability_export.{output_format}"
if output_format == 'xlsx':
df.to_excel(filename, index=False)
else:
df.to_csv(filename, index=False, encoding='utf-8-sig')
print(f"✅ Successfully exported {len(df)} items to {filename}")
def get_type_mapping():
url = f"{BASE_URL}/api/v3/types"
try:
response = requests.get(url, auth=AUTH)
response.raise_for_status()
return {t['name'].lower(): t['id'] for t in response.json()['_embedded']['elements']}
except Exception as e:
print(f"❌ Error fetching types: {e}")
sys.exit(1)
if __name__ == "__main__":
type_map = get_type_mapping()
print(type(type_map))
print(list(type_map.keys()))
# Usage: python get_traceability.py requirements task
requested_types = [] #list(type_map.keys()) #sys.argv[1:] if len(sys.argv) > 1 else None
export_data(types_list=requested_types, output_format='csv')

File diff suppressed because one or more lines are too long

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
public/images/nabd-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

1
public/placeholder.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 3.2 KiB

14
public/robots.txt Normal file
View File

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

42
src/App.css Normal file
View File

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

35
src/App.tsx Normal file
View File

@@ -0,0 +1,35 @@
import { Toaster } from "@/components/ui/toaster";
import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import Dashboard from "./pages/Dashboard";
import DocumentationPage from "./pages/DocumentationPage";
import AnalysisPage from "./pages/AnalysisPage";
import ALMTypePage from "./pages/ALMTypePage";
import TraceabilityMatrixPage from "./pages/TraceabilityMatrixPage";
import NotFound from "./pages/NotFound";
const queryClient = new QueryClient();
const App = () => (
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<Toaster />
<Sonner />
<BrowserRouter>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/documentation" element={<DocumentationPage />} />
<Route path="/analysis" element={<AnalysisPage />} />
<Route path="/matrix" element={<TraceabilityMatrixPage />} />
<Route path="/alm/:type" element={<ALMTypePage />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
</TooltipProvider>
</QueryClientProvider>
);
export default App;

View File

@@ -0,0 +1,311 @@
import { useState, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Upload, FileText, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
import { WorkPackage, WorkPackageType } from '@/types/traceability';
interface CSVUploadProps {
onDataLoaded: (workPackages: WorkPackage[]) => void;
onClose?: () => void;
}
interface ParseResult {
success: boolean;
workPackages: WorkPackage[];
logs: string[];
errors: string[];
typeCounts: Record<string, number>;
}
function parseCSVContent(csvText: string): ParseResult {
const logs: string[] = [];
const errors: string[] = [];
const workPackages: WorkPackage[] = [];
logs.push(`Starting CSV parse, ${csvText.length} characters`);
// Remove BOM if present
const cleanText = csvText.replace(/^\uFEFF/, '');
const lines = cleanText.split('\n');
logs.push(`Total lines: ${lines.length}`);
// Check header
const header = lines[0];
if (!header.includes('ID') || !header.includes('Type')) {
errors.push('Invalid CSV header. Expected: ID,Type,Status,Title,Description,Parent_ID,Relations');
return { success: false, workPackages: [], logs, errors, typeCounts: {} };
}
logs.push(`Header validated: ${header.substring(0, 60)}...`);
// Parse content
let currentRow: string[] = [];
let inQuotedField = false;
let currentField = '';
const content = lines.slice(1).join('\n');
for (let i = 0; i < content.length; i++) {
const char = content[i];
const nextChar = content[i + 1];
if (inQuotedField) {
if (char === '"') {
if (nextChar === '"') {
currentField += '"';
i++;
} else {
inQuotedField = false;
}
} else {
currentField += char;
}
} else {
if (char === '"' && currentField === '') {
inQuotedField = true;
} else if (char === ',') {
currentRow.push(currentField);
currentField = '';
} else if (char === '\n') {
currentRow.push(currentField);
currentField = '';
if (currentRow.length >= 4 && /^\d+$/.test(currentRow[0]?.trim())) {
const [id, type, status, title, description = '', parentId = '', relations = ''] = currentRow;
const wpType = type?.toLowerCase().replace(/\s+/g, '') as WorkPackageType;
workPackages.push({
id: parseInt(id, 10),
type: wpType,
status: status || '',
title: title || '',
description: description || '',
parentId: parentId?.trim() || '',
relations: relations?.trim() || ''
});
}
currentRow = [];
} else {
currentField += char;
}
}
}
// Handle last row
if (currentField || currentRow.length > 0) {
currentRow.push(currentField);
if (currentRow.length >= 4 && /^\d+$/.test(currentRow[0]?.trim())) {
const [id, type, status, title, description = '', parentId = '', relations = ''] = currentRow;
const wpType = type?.toLowerCase().replace(/\s+/g, '') as WorkPackageType;
workPackages.push({
id: parseInt(id, 10),
type: wpType,
status: status || '',
title: title || '',
description: description || '',
parentId: parentId?.trim() || '',
relations: relations?.trim() || ''
});
}
}
logs.push(`Parsed ${workPackages.length} work packages`);
// Calculate type distribution
const typeCounts = workPackages.reduce((acc, wp) => {
acc[wp.type] = (acc[wp.type] || 0) + 1;
return acc;
}, {} as Record<string, number>);
logs.push(`Type distribution: ${JSON.stringify(typeCounts)}`);
return {
success: workPackages.length > 0,
workPackages,
logs,
errors,
typeCounts
};
}
export function CSVUpload({ onDataLoaded, onClose }: CSVUploadProps) {
const [isDragging, setIsDragging] = useState(false);
const [parseResult, setParseResult] = useState<ParseResult | null>(null);
const [fileName, setFileName] = useState<string>('');
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFile = async (file: File) => {
setFileName(file.name);
const text = await file.text();
const result = parseCSVContent(text);
setParseResult(result);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (file && file.name.endsWith('.csv')) {
handleFile(file);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = () => {
setIsDragging(false);
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
handleFile(file);
}
};
const handleApply = () => {
if (parseResult?.success && parseResult.workPackages.length > 0) {
onDataLoaded(parseResult.workPackages);
onClose?.();
}
};
const handleReset = () => {
setParseResult(null);
setFileName('');
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" />
Upload Traceability CSV
</CardTitle>
<CardDescription>
Upload a new traceability_export.csv file to update the data
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Drop zone */}
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer ${
isDragging
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-primary/50'
}`}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept=".csv"
onChange={handleFileSelect}
className="hidden"
/>
<FileText className="h-10 w-10 mx-auto mb-3 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
{fileName ? (
<span className="font-medium text-foreground">{fileName}</span>
) : (
<>
Drag and drop a CSV file here, or{' '}
<span className="text-primary underline">click to browse</span>
</>
)}
</p>
</div>
{/* Parse Results */}
{parseResult && (
<div className="space-y-3">
<div className="flex items-center gap-2">
{parseResult.success ? (
<>
<CheckCircle className="h-5 w-5 text-green-500" />
<span className="font-medium text-green-700">
Successfully parsed {parseResult.workPackages.length} work packages
</span>
</>
) : (
<>
<XCircle className="h-5 w-5 text-red-500" />
<span className="font-medium text-red-700">Parse failed</span>
</>
)}
</div>
{/* Type counts */}
{parseResult.success && Object.keys(parseResult.typeCounts).length > 0 && (
<div className="flex flex-wrap gap-2">
{Object.entries(parseResult.typeCounts)
.sort(([, a], [, b]) => b - a)
.map(([type, count]) => (
<Badge key={type} variant="secondary" className="capitalize">
{type}: {count}
</Badge>
))}
</div>
)}
{/* Errors */}
{parseResult.errors.length > 0 && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
<div className="flex items-center gap-2 text-red-700 mb-2">
<AlertCircle className="h-4 w-4" />
<span className="font-medium">Errors</span>
</div>
{parseResult.errors.map((error, i) => (
<p key={i} className="text-sm text-red-600">{error}</p>
))}
</div>
)}
{/* Parse logs */}
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
View parse logs
</summary>
<ScrollArea className="h-32 mt-2 bg-slate-950 rounded p-2">
<div className="font-mono text-green-400 space-y-0.5">
{parseResult.logs.map((log, i) => (
<div key={i}>{log}</div>
))}
</div>
</ScrollArea>
</details>
{/* Actions */}
<div className="flex gap-2 pt-2">
{parseResult.success && (
<Button onClick={handleApply}>
<CheckCircle className="h-4 w-4 mr-2" />
Apply Data
</Button>
)}
<Button variant="outline" onClick={handleReset}>
Reset
</Button>
{onClose && (
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
)}
</div>
</div>
)}
</CardContent>
</Card>
);
}

View File

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

View File

@@ -0,0 +1,39 @@
import { Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTheme } from "@/hooks/useTheme";
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-popover border-border">
<DropdownMenuItem onClick={() => setTheme("light")}>
<Sun className="mr-2 h-4 w-4" />
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
<Moon className="mr-2 h-4 w-4" />
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
<span className="mr-2">💻</span>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,136 @@
import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { FileText, Upload, Trash2, MoreVertical, Clock } from 'lucide-react';
import { DocumentFile } from '@/types/documentation';
import { MarkdownViewer } from './MarkdownViewer';
import { DocumentUpload } from './DocumentUpload';
import { formatDistanceToNow } from 'date-fns';
interface DocumentCardProps {
document: DocumentFile;
categories: string[];
onUpdate: (id: string, content: string, fileName?: string) => void;
onDelete: (id: string) => void;
}
export function DocumentCard({ document, categories, onUpdate, onDelete }: DocumentCardProps) {
const [uploadOpen, setUploadOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const handleUpdate = (data: { content: string; fileName: string }) => {
onUpdate(document.id, data.content, data.fileName);
};
const lastUpdated = formatDistanceToNow(new Date(document.lastUpdated), { addSuffix: true });
return (
<>
<AccordionItem value={document.id}>
<AccordionTrigger className="hover:no-underline group">
<div className="flex items-center justify-between w-full pr-4">
<div className="flex items-center gap-3 text-left">
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
<div>
<div className="font-medium">{document.title}</div>
<div className="text-sm text-muted-foreground">
{document.description}
</div>
</div>
</div>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Badge variant="outline" className="text-xs">
<Clock className="h-3 w-3 mr-1" />
{lastUpdated}
</Badge>
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={(e) => {
e.stopPropagation();
setUploadOpen(true);
}}>
<Upload className="h-4 w-4 mr-2" />
Update Document
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={(e) => {
e.stopPropagation();
setDeleteOpen(true);
}}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="pl-7">
<MarkdownViewer content={document.content} />
</div>
</AccordionContent>
</AccordionItem>
<DocumentUpload
open={uploadOpen}
onOpenChange={setUploadOpen}
mode="update"
existingDoc={document}
categories={categories}
onSave={handleUpdate}
/>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Document</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{document.title}"? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => onDelete(document.id)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,278 @@
import { useState, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Upload, FileText, CheckCircle } from 'lucide-react';
import { DocumentFile } from '@/types/documentation';
interface DocumentUploadProps {
open: boolean;
onOpenChange: (open: boolean) => void;
mode: 'update' | 'add';
existingDoc?: DocumentFile;
categories: string[];
onSave: (data: {
title: string;
description: string;
category: string;
content: string;
fileName: string;
}) => void;
}
export function DocumentUpload({
open,
onOpenChange,
mode,
existingDoc,
categories,
onSave
}: DocumentUploadProps) {
const [title, setTitle] = useState(existingDoc?.title || '');
const [description, setDescription] = useState(existingDoc?.description || '');
const [category, setCategory] = useState(existingDoc?.category || categories[0] || '');
const [newCategory, setNewCategory] = useState('');
const [content, setContent] = useState(existingDoc?.content || '');
const [fileName, setFileName] = useState(existingDoc?.fileName || '');
const [isDragging, setIsDragging] = useState(false);
const [uploadSuccess, setUploadSuccess] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileRead = (file: File) => {
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target?.result as string;
setContent(text);
setFileName(file.name);
setUploadSuccess(true);
// Auto-extract title from first heading if adding new doc
if (mode === 'add' && !title) {
const match = text.match(/^#\s+(.+)$/m);
if (match) {
setTitle(match[1]);
}
}
};
reader.readAsText(file);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (file && file.name.endsWith('.md')) {
handleFileRead(file);
}
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
handleFileRead(file);
}
};
const handleSave = () => {
const finalCategory = newCategory || category;
onSave({
title,
description,
category: finalCategory,
content,
fileName
});
onOpenChange(false);
// Reset state
setTitle('');
setDescription('');
setCategory(categories[0] || '');
setNewCategory('');
setContent('');
setFileName('');
setUploadSuccess(false);
};
const resetForm = () => {
if (existingDoc) {
setTitle(existingDoc.title);
setDescription(existingDoc.description);
setCategory(existingDoc.category);
setContent(existingDoc.content);
setFileName(existingDoc.fileName);
} else {
setTitle('');
setDescription('');
setCategory(categories[0] || '');
setContent('');
setFileName('');
}
setNewCategory('');
setUploadSuccess(false);
};
return (
<Dialog open={open} onOpenChange={(isOpen) => {
if (!isOpen) resetForm();
onOpenChange(isOpen);
}}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{mode === 'update' ? 'Update Document' : 'Add New Document'}
</DialogTitle>
<DialogDescription>
{mode === 'update'
? `Upload a new .md file to update "${existingDoc?.title}"`
: 'Upload a .md file and provide document details'
}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* File Upload Area */}
<div
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
isDragging ? 'border-primary bg-primary/5' : 'border-border'
} ${uploadSuccess ? 'border-green-500 bg-green-500/5' : ''}`}
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
>
<input
type="file"
ref={fileInputRef}
onChange={handleFileSelect}
accept=".md"
className="hidden"
/>
{uploadSuccess ? (
<div className="flex flex-col items-center gap-2">
<CheckCircle className="h-10 w-10 text-green-500" />
<p className="font-medium text-green-600">{fileName}</p>
<p className="text-sm text-muted-foreground">
{content.length.toLocaleString()} characters loaded
</p>
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
>
Choose Different File
</Button>
</div>
) : (
<div className="flex flex-col items-center gap-2">
<Upload className="h-10 w-10 text-muted-foreground" />
<p className="font-medium">Drop .md file here or click to browse</p>
<Button
variant="outline"
onClick={() => fileInputRef.current?.click()}
>
<FileText className="h-4 w-4 mr-2" />
Select Markdown File
</Button>
</div>
)}
</div>
{/* Document Details (only show for add mode or always for better UX) */}
{mode === 'add' && (
<>
<div className="space-y-2">
<Label htmlFor="title">Document Title</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g., System Architecture Overview"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of the document..."
rows={2}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Category</Label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger>
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
{categories.map((cat) => (
<SelectItem key={cat} value={cat}>
{cat}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="newCategory">Or Create New</Label>
<Input
id="newCategory"
value={newCategory}
onChange={(e) => setNewCategory(e.target.value)}
placeholder="New category name..."
/>
</div>
</div>
</>
)}
{/* Content Preview */}
{content && (
<div className="space-y-2">
<Label>Content Preview</Label>
<div className="border rounded-lg p-3 bg-muted/50 max-h-40 overflow-y-auto">
<pre className="text-xs text-muted-foreground whitespace-pre-wrap">
{content.slice(0, 500)}
{content.length > 500 && '...'}
</pre>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={!content || (mode === 'add' && (!title || !category && !newCategory))}
>
{mode === 'update' ? 'Update Document' : 'Add Document'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,213 @@
import ReactMarkdown from 'react-markdown';
import rehypeRaw from 'rehype-raw';
import remarkGfm from 'remark-gfm';
import { ScrollArea } from '@/components/ui/scroll-area';
import { MermaidDiagram } from './MermaidDiagram';
import { PlantUMLDiagram } from './PlantUMLDiagram';
interface MarkdownViewerProps {
content: string;
className?: string;
}
export function MarkdownViewer({ content, className = '' }: MarkdownViewerProps) {
return (
<ScrollArea className={`h-[500px] ${className}`}>
<div className="prose prose-sm dark:prose-invert max-w-none p-4">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={{
h1: ({ children }) => (
<h1 className="text-2xl font-bold text-foreground mb-4 pb-2 border-b border-border">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-xl font-semibold text-foreground mt-6 mb-3">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-lg font-medium text-foreground mt-4 mb-2">
{children}
</h3>
),
h4: ({ children }) => (
<h4 className="text-base font-medium text-foreground mt-3 mb-2">
{children}
</h4>
),
p: ({ children }) => (
<p className="text-muted-foreground mb-3 leading-relaxed">
{children}
</p>
),
ul: ({ children }) => (
<ul className="list-disc pl-6 space-y-1 mb-4 text-muted-foreground">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="list-decimal pl-6 space-y-1 mb-4 text-muted-foreground">
{children}
</ol>
),
li: ({ children }) => (
<li className="text-muted-foreground pl-1">{children}</li>
),
code: ({ className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : '';
const codeContent = String(children).replace(/\n$/, '');
// Handle Mermaid diagrams
if (language === 'mermaid') {
return <MermaidDiagram chart={codeContent} />;
}
// Handle PlantUML diagrams
if (language === 'plantuml' || language === 'puml') {
return <PlantUMLDiagram code={codeContent} />;
}
// Inline code (no language specified and short)
const isInline = !className && !String(children).includes('\n');
if (isInline) {
return (
<code className="bg-muted px-1.5 py-0.5 rounded text-sm font-mono text-foreground" {...props}>
{children}
</code>
);
}
// Code block
return (
<code className="block bg-muted p-4 rounded-lg text-sm font-mono overflow-x-auto mb-4" {...props}>
{children}
</code>
);
},
pre: ({ children, ...props }) => {
// Check if the child is a Mermaid or PlantUML diagram (already rendered)
const childElement = children as React.ReactElement;
if (childElement?.type === MermaidDiagram || childElement?.type === PlantUMLDiagram) {
return <>{children}</>;
}
return (
<pre className="bg-muted p-4 rounded-lg overflow-x-auto mb-4" {...props}>
{children}
</pre>
);
},
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-primary pl-4 italic text-muted-foreground my-4">
{children}
</blockquote>
),
// Enhanced table support for HTML tables
table: ({ children }) => (
<div className="overflow-x-auto mb-4">
<table className="min-w-full border-collapse border border-border rounded-lg">
{children}
</table>
</div>
),
thead: ({ children }) => (
<thead className="bg-muted">{children}</thead>
),
tbody: ({ children }) => (
<tbody className="divide-y divide-border">{children}</tbody>
),
tr: ({ children }) => (
<tr className="hover:bg-muted/50 transition-colors">{children}</tr>
),
th: ({ children, style }) => (
<th
className="px-4 py-2 text-left font-medium text-foreground border border-border bg-muted"
style={style}
>
{children}
</th>
),
td: ({ children, style }) => (
<td
className="px-4 py-2 text-muted-foreground border border-border"
style={style}
>
{children}
</td>
),
hr: () => <hr className="my-6 border-border" />,
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{children}
</a>
),
strong: ({ children }) => (
<strong className="font-semibold text-foreground">{children}</strong>
),
em: ({ children }) => (
<em className="italic">{children}</em>
),
// Support for definition lists (HTML)
dl: ({ children }) => (
<dl className="mb-4 space-y-2">{children}</dl>
),
dt: ({ children }) => (
<dt className="font-medium text-foreground">{children}</dt>
),
dd: ({ children }) => (
<dd className="ml-4 text-muted-foreground">{children}</dd>
),
// Support for figures and captions
figure: ({ children }) => (
<figure className="my-4">{children}</figure>
),
figcaption: ({ children }) => (
<figcaption className="text-center text-sm text-muted-foreground mt-2">
{children}
</figcaption>
),
// Support for images
img: ({ src, alt, ...props }) => (
<img
src={src}
alt={alt || ''}
className="max-w-full h-auto rounded-lg my-4"
{...props}
/>
),
// Support for details/summary
details: ({ children }) => (
<details className="my-4 border border-border rounded-lg p-4 bg-card">
{children}
</details>
),
summary: ({ children }) => (
<summary className="font-medium cursor-pointer text-foreground hover:text-primary">
{children}
</summary>
),
// Div support for custom HTML blocks
div: ({ className, children, ...props }) => (
<div className={className} {...props}>{children}</div>
),
// Span support
span: ({ className, children, style, ...props }) => (
<span className={className} style={style} {...props}>{children}</span>
),
}}
>
{content}
</ReactMarkdown>
</div>
</ScrollArea>
);
}

View File

@@ -0,0 +1,59 @@
import { useEffect, useRef, useState } from 'react';
import mermaid from 'mermaid';
// Initialize mermaid
mermaid.initialize({
startOnLoad: false,
theme: 'default',
securityLevel: 'loose',
fontFamily: 'inherit',
});
interface MermaidDiagramProps {
chart: string;
}
export function MermaidDiagram({ chart }: MermaidDiagramProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [svg, setSvg] = useState<string>('');
const [error, setError] = useState<string>('');
useEffect(() => {
const renderChart = async () => {
if (!containerRef.current || !chart.trim()) return;
try {
const id = `mermaid-${Math.random().toString(36).substr(2, 9)}`;
const { svg } = await mermaid.render(id, chart.trim());
setSvg(svg);
setError('');
} catch (err) {
console.error('Mermaid rendering error:', err);
setError(err instanceof Error ? err.message : 'Failed to render diagram');
}
};
renderChart();
}, [chart]);
if (error) {
return (
<div className="border border-destructive/50 bg-destructive/10 rounded-lg p-4 my-4">
<p className="text-sm text-destructive font-medium">Mermaid Diagram Error</p>
<pre className="text-xs text-muted-foreground mt-2 whitespace-pre-wrap">{error}</pre>
<details className="mt-2">
<summary className="text-xs text-muted-foreground cursor-pointer">Show source</summary>
<pre className="text-xs bg-muted p-2 rounded mt-1 overflow-x-auto">{chart}</pre>
</details>
</div>
);
}
return (
<div
ref={containerRef}
className="my-4 overflow-x-auto bg-card border rounded-lg p-4"
dangerouslySetInnerHTML={{ __html: svg }}
/>
);
}

View File

@@ -0,0 +1,108 @@
import { useState, useEffect } from 'react';
interface PlantUMLDiagramProps {
code: string;
}
// Encode PlantUML code for the server
function encodePlantUML(code: string): string {
// Use the PlantUML text encoding
const encoder = new TextEncoder();
const data = encoder.encode(code);
// Convert to base64-like encoding that PlantUML server expects
let encoded = '';
for (let i = 0; i < data.length; i += 3) {
const b1 = data[i] || 0;
const b2 = data[i + 1] || 0;
const b3 = data[i + 2] || 0;
encoded += encode6bit((b1 >> 2) & 0x3F);
encoded += encode6bit(((b1 & 0x3) << 4) | ((b2 >> 4) & 0xF));
encoded += encode6bit(((b2 & 0xF) << 2) | ((b3 >> 6) & 0x3));
encoded += encode6bit(b3 & 0x3F);
}
return encoded;
}
function encode6bit(b: number): string {
if (b < 10) return String.fromCharCode(48 + b);
b -= 10;
if (b < 26) return String.fromCharCode(65 + b);
b -= 26;
if (b < 26) return String.fromCharCode(97 + b);
b -= 26;
if (b === 0) return '-';
if (b === 1) return '_';
return '?';
}
// Alternative: Use deflate compression for PlantUML
function compressPlantUML(s: string): string {
// Simple URL-safe base64 encoding for PlantUML
const encoded = btoa(unescape(encodeURIComponent(s)))
.replace(/\+/g, '-')
.replace(/\//g, '_');
return encoded;
}
export function PlantUMLDiagram({ code }: PlantUMLDiagramProps) {
const [imageUrl, setImageUrl] = useState<string>('');
const [error, setError] = useState<string>('');
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!code.trim()) {
setError('Empty PlantUML code');
setLoading(false);
return;
}
try {
// Use PlantUML public server with encoded diagram
const encoded = encodePlantUML(code.trim());
const url = `https://www.plantuml.com/plantuml/svg/${encoded}`;
setImageUrl(url);
setError('');
} catch (err) {
console.error('PlantUML encoding error:', err);
setError(err instanceof Error ? err.message : 'Failed to encode diagram');
}
setLoading(false);
}, [code]);
if (loading) {
return (
<div className="border rounded-lg p-4 my-4 bg-muted animate-pulse">
<div className="h-32 flex items-center justify-center text-muted-foreground">
Loading PlantUML diagram...
</div>
</div>
);
}
if (error) {
return (
<div className="border border-destructive/50 bg-destructive/10 rounded-lg p-4 my-4">
<p className="text-sm text-destructive font-medium">PlantUML Diagram Error</p>
<pre className="text-xs text-muted-foreground mt-2 whitespace-pre-wrap">{error}</pre>
<details className="mt-2">
<summary className="text-xs text-muted-foreground cursor-pointer">Show source</summary>
<pre className="text-xs bg-muted p-2 rounded mt-1 overflow-x-auto">{code}</pre>
</details>
</div>
);
}
return (
<div className="my-4 overflow-x-auto bg-card border rounded-lg p-4">
<img
src={imageUrl}
alt="PlantUML Diagram"
className="max-w-full h-auto"
onError={() => setError('Failed to load diagram from PlantUML server')}
/>
</div>
);
}

View File

@@ -0,0 +1,69 @@
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { AppSidebar } from "./AppSidebar";
import { ThemeToggle } from "@/components/ThemeToggle";
import { RefreshCw, Clock } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { format } from "date-fns";
interface AppLayoutProps {
children: React.ReactNode;
lastUpdated?: Date;
onRefresh?: () => void;
isRefreshing?: boolean;
}
export function AppLayout({
children,
lastUpdated,
onRefresh,
isRefreshing = false,
}: AppLayoutProps) {
return (
<SidebarProvider>
<div className="min-h-screen flex w-full">
<AppSidebar />
<div className="flex-1 flex flex-col">
{/* Top Header */}
<header className="h-14 border-b border-border flex items-center justify-between px-4 bg-card">
<div className="flex items-center gap-4">
<SidebarTrigger />
<h1 className="font-semibold text-lg text-foreground">ASF Sensor Hub - Traceability Dashboard</h1>
</div>
<div className="flex items-center gap-4">
{lastUpdated && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="h-4 w-4" />
<span className="hidden sm:inline">Last updated:</span>
<Badge variant="secondary">
{format(lastUpdated, "MMM d, yyyy HH:mm")}
</Badge>
</div>
)}
{onRefresh && (
<Button
variant="outline"
size="sm"
onClick={onRefresh}
disabled={isRefreshing}
className="gap-2"
>
<RefreshCw
className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
/>
<span className="hidden sm:inline">Update Now</span>
</Button>
)}
<ThemeToggle />
</div>
</header>
{/* Main Content */}
<main className="flex-1 overflow-auto p-6 bg-background">
{children}
</main>
</div>
</div>
</SidebarProvider>
);
}

View File

@@ -0,0 +1,160 @@
import { useState } from "react";
import {
FileText,
Search,
GitBranch,
CheckSquare,
AlertTriangle,
Target,
Layers,
Bug,
TestTube,
Calendar,
Milestone,
FolderKanban,
BookOpen,
LayoutDashboard,
ChevronDown,
ChevronRight,
} from "lucide-react";
import { NavLink } from "@/components/NavLink";
import { useLocation } from "react-router-dom";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
import { cn } from "@/lib/utils";
const mainItems = [
{ title: "Dashboard", url: "/", icon: LayoutDashboard },
{ title: "Traceability Matrix", url: "/matrix", icon: GitBranch },
{ title: "Documentation", url: "/documentation", icon: BookOpen },
{ title: "Gap Analysis", url: "/analysis", icon: Search },
];
const almItems = [
{ title: "Features", url: "/alm/feature", icon: Target, type: "feature" },
{ title: "Requirements", url: "/alm/requirements", icon: CheckSquare, type: "requirements" },
{ title: "SW Requirements", url: "/alm/swreq", icon: FileText, type: "swreq" },
{ title: "Test Cases", url: "/alm/test-case", icon: TestTube, type: "test case" },
{ title: "Epics", url: "/alm/epic", icon: Layers, type: "epic" },
{ title: "User Stories", url: "/alm/user-story", icon: FolderKanban, type: "user story" },
{ title: "Tasks", url: "/alm/task", icon: CheckSquare, type: "task" },
{ title: "Bugs", url: "/alm/bug", icon: Bug, type: "bug" },
{ title: "Risks", url: "/alm/risk", icon: AlertTriangle, type: "risk" },
{ title: "Milestones", url: "/alm/milestone", icon: Milestone, type: "milestone" },
{ title: "Phases", url: "/alm/phase", icon: Calendar, type: "phase" },
];
export function AppSidebar() {
const { state } = useSidebar();
const collapsed = state === "collapsed";
const location = useLocation();
const currentPath = location.pathname;
const [almExpanded, setAlmExpanded] = useState(
almItems.some((item) => currentPath.startsWith(item.url))
);
return (
<Sidebar collapsible="icon" className="border-r border-sidebar-border bg-sidebar-background">
<SidebarContent className="pt-4">
{/* Logo/Header */}
<div className="px-4 pb-4 border-b border-sidebar-border mb-4">
<div className="flex items-center gap-3">
<img
src="/images/nabd-logo.png"
alt="NABD Solutions"
className={cn(
"transition-all duration-200",
collapsed ? "h-8 w-8 object-contain" : "h-10"
)}
/>
{!collapsed && (
<div className="flex flex-col">
<span className="font-bold text-base text-sidebar-foreground">Traceability</span>
<span className="text-xs text-sidebar-foreground/60">ASF Sensor Hub</span>
</div>
)}
</div>
</div>
{/* Main Navigation */}
<SidebarGroup>
<SidebarGroupLabel className="text-sidebar-foreground/60">Navigation</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{mainItems.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild>
<NavLink
to={item.url}
end={item.url === "/"}
className={cn(
"flex items-center gap-2 px-2 py-1.5 rounded-md transition-colors",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
"text-sidebar-foreground"
)}
activeClassName="bg-sidebar-accent text-sidebar-primary font-medium"
>
<item.icon className="h-4 w-4 shrink-0" />
{!collapsed && <span>{item.title}</span>}
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{/* ALM Items */}
<SidebarGroup>
<SidebarGroupLabel
className="cursor-pointer flex items-center justify-between text-sidebar-foreground/60 hover:text-sidebar-foreground"
onClick={() => setAlmExpanded(!almExpanded)}
>
<span>ALM Traceability</span>
{!collapsed && (
almExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)
)}
</SidebarGroupLabel>
{(almExpanded || collapsed) && (
<SidebarGroupContent>
<SidebarMenu>
{almItems.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild>
<NavLink
to={item.url}
className={cn(
"flex items-center gap-2 px-2 py-1.5 rounded-md transition-colors",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
"text-sidebar-foreground"
)}
activeClassName="bg-sidebar-accent text-sidebar-primary font-medium"
>
<item.icon className="h-4 w-4 shrink-0" />
{!collapsed && <span>{item.title}</span>}
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
)}
</SidebarGroup>
</SidebarContent>
</Sidebar>
);
}

View File

@@ -0,0 +1,213 @@
import { WorkPackage, ParsedRelation } from "@/types/traceability";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Badge } from "@/components/ui/badge";
import { Link, ChevronRight, FileText, ExternalLink } from "lucide-react";
import { cn } from "@/lib/utils";
const OPENPROJECT_BASE_URL = "https://openproject.nabd-co.com/projects/asf/work_packages";
function getWorkPackageUrl(id: number): string {
return `${OPENPROJECT_BASE_URL}/${id}/activity`;
}
interface WorkPackageCardProps {
workPackage: WorkPackage;
allWorkPackages?: WorkPackage[];
showRelations?: boolean;
}
function parseRelations(relationsStr: string): ParsedRelation[] {
if (!relationsStr) return [];
const relations: ParsedRelation[] = [];
const matches = relationsStr.matchAll(/(\w+)\(#(\d+)\)/g);
for (const match of matches) {
relations.push({
type: match[1],
targetId: parseInt(match[2], 10),
});
}
return relations;
}
function getStatusColor(status: string): string {
const statusLower = status.toLowerCase();
if (statusLower.includes("done") || statusLower.includes("closed") || statusLower.includes("resolved")) {
return "bg-green-500/10 text-green-700 border-green-500/20";
}
if (statusLower.includes("progress") || statusLower.includes("active")) {
return "bg-blue-500/10 text-blue-700 border-blue-500/20";
}
if (statusLower.includes("blocked") || statusLower.includes("rejected")) {
return "bg-red-500/10 text-red-700 border-red-500/20";
}
if (statusLower.includes("new") || statusLower.includes("open")) {
return "bg-amber-500/10 text-amber-700 border-amber-500/20";
}
return "bg-muted text-muted-foreground";
}
function getRelationColor(type: string): string {
switch (type.toLowerCase()) {
case "includes":
return "bg-purple-500/10 text-purple-700 border-purple-500/20";
case "blocks":
return "bg-red-500/10 text-red-700 border-red-500/20";
case "relates":
return "bg-blue-500/10 text-blue-700 border-blue-500/20";
case "duplicates":
return "bg-amber-500/10 text-amber-700 border-amber-500/20";
case "follows":
return "bg-green-500/10 text-green-700 border-green-500/20";
case "requires":
return "bg-indigo-500/10 text-indigo-700 border-indigo-500/20";
default:
return "bg-muted text-muted-foreground";
}
}
// Simple HTML to text converter for descriptions
function htmlToText(html: string): string {
if (!html) return "";
return html
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<p[^>]*>/gi, "")
.replace(/<\/p>/gi, "\n")
.replace(/<li[^>]*>/gi, "• ")
.replace(/<\/li>/gi, "\n")
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/\n\s*\n/g, "\n\n")
.trim();
}
export function WorkPackageCard({
workPackage,
allWorkPackages = [],
showRelations = true,
}: WorkPackageCardProps) {
const relations = parseRelations(workPackage.relations);
const description = htmlToText(workPackage.description);
// Find related work packages
const relatedWPs = relations.map((rel) => {
const found = allWorkPackages.find((wp) => wp.id === rel.targetId);
return {
...rel,
workPackage: found,
};
});
return (
<Accordion type="single" collapsible className="w-full">
<AccordionItem value={`wp-${workPackage.id}`} className="border rounded-lg mb-2">
<AccordionTrigger className="px-4 py-3 hover:no-underline hover:bg-accent/50 rounded-t-lg">
<div className="flex items-center gap-3 flex-1 text-left">
<a
href={getWorkPackageUrl(workPackage.id)}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 hover:underline"
>
<Badge variant="outline" className="font-mono text-xs hover:bg-primary/10 cursor-pointer">
#{workPackage.id}
<ExternalLink className="h-3 w-3 ml-1" />
</Badge>
</a>
<span className="font-medium flex-1">{workPackage.title}</span>
<Badge
variant="outline"
className={cn("text-xs", getStatusColor(workPackage.status))}
>
{workPackage.status}
</Badge>
{relations.length > 0 && (
<Badge variant="secondary" className="text-xs">
{relations.length} links
</Badge>
)}
</div>
</AccordionTrigger>
<AccordionContent className="px-4 pb-4">
<div className="space-y-4">
{/* Description */}
{description && (
<div className="space-y-2">
<h4 className="text-sm font-medium flex items-center gap-2">
<FileText className="h-4 w-4" />
Description
</h4>
<div className="text-sm text-muted-foreground whitespace-pre-wrap bg-muted/30 p-3 rounded-md max-h-60 overflow-y-auto">
{description}
</div>
</div>
)}
{/* Relations */}
{showRelations && relatedWPs.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-medium flex items-center gap-2">
<Link className="h-4 w-4" />
Relations ({relatedWPs.length})
</h4>
<div className="flex flex-wrap gap-2">
{relatedWPs.map((rel, idx) => (
<a
key={`${rel.type}-${rel.targetId}-${idx}`}
href={getWorkPackageUrl(rel.targetId)}
target="_blank"
rel="noopener noreferrer"
className={cn(
"inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs border hover:opacity-80 transition-opacity",
getRelationColor(rel.type)
)}
>
<span className="font-medium">{rel.type}</span>
<ChevronRight className="h-3 w-3" />
<span className="font-mono">#{rel.targetId}</span>
<ExternalLink className="h-3 w-3" />
{rel.workPackage && (
<span className="max-w-[200px] truncate text-muted-foreground">
{rel.workPackage.title}
</span>
)}
</a>
))}
</div>
</div>
)}
{/* Parent */}
{workPackage.parentId && (
<div className="text-sm text-muted-foreground">
<span className="font-medium">Parent:</span>{" "}
<a
href={getWorkPackageUrl(parseInt(workPackage.parentId, 10))}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 hover:underline text-primary"
>
#{workPackage.parentId}
<ExternalLink className="h-3 w-3" />
</a>
</div>
)}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
);
}

View File

@@ -0,0 +1,52 @@
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item ref={ref} className={cn("border-b", className)} {...props} />
));
AccordionItem.displayName = "AccordionItem";
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -0,0 +1,104 @@
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
);
AlertDialogHeader.displayName = "AlertDialogHeader";
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
);
AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@@ -0,0 +1,43 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h5 ref={ref} className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props} />
),
);
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
),
);
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription };

View File

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

View File

@@ -0,0 +1,38 @@
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image ref={ref} className={cn("aspect-square h-full w-full", className)} {...props} />
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn("flex h-full w-full items-center justify-center rounded-full bg-muted", className)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };

View File

@@ -0,0 +1,29 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,90 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode;
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
Breadcrumb.displayName = "Breadcrumb";
const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWithoutRef<"ol">>(
({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className,
)}
{...props}
/>
),
);
BreadcrumbList.displayName = "BreadcrumbList";
const BreadcrumbItem = React.forwardRef<HTMLLIElement, React.ComponentPropsWithoutRef<"li">>(
({ className, ...props }, ref) => (
<li ref={ref} className={cn("inline-flex items-center gap-1.5", className)} {...props} />
),
);
BreadcrumbItem.displayName = "BreadcrumbItem";
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return <Comp ref={ref} className={cn("transition-colors hover:text-foreground", className)} {...props} />;
});
BreadcrumbLink.displayName = "BreadcrumbLink";
const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWithoutRef<"span">>(
({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
),
);
BreadcrumbPage.displayName = "BreadcrumbPage";
const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<"li">) => (
<li role="presentation" aria-hidden="true" className={cn("[&>svg]:size-3.5", className)} {...props}>
{children ?? <ChevronRight />}
</li>
);
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
);
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

View File

@@ -0,0 +1,47 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
},
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,54 @@
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell: "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(buttonVariants({ variant: "ghost" }), "h-9 w-9 p-0 font-normal aria-selected:opacity-100"),
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle: "aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
export { Calendar };

View File

@@ -0,0 +1,43 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)} {...props} />
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
),
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} />
),
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
),
);
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />,
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
),
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@@ -0,0 +1,224 @@
import * as React from "react";
import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
const Carousel = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement> & CarouselProps>(
({ orientation = "horizontal", opts, setApi, plugins, className, children, ...props }, ref) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return;
}
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) {
return;
}
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) {
return;
}
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation: orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
},
);
Carousel.displayName = "Carousel";
const CarouselContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel();
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn("flex", orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", className)}
{...props}
/>
</div>
);
},
);
CarouselContent.displayName = "CarouselContent";
const CarouselItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { orientation } = useCarousel();
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn("min-w-0 shrink-0 grow-0 basis-full", orientation === "horizontal" ? "pl-4" : "pt-4", className)}
{...props}
/>
);
},
);
CarouselItem.displayName = "CarouselItem";
const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
);
},
);
CarouselPrevious.displayName = "CarouselPrevious";
const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
);
},
);
CarouselNext.displayName = "CarouselNext";
export { type CarouselApi, Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext };

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

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

View File

@@ -0,0 +1,26 @@
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View File

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

View File

@@ -0,0 +1,132 @@
import * as React from "react";
import { type DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { cn } from "@/lib/utils";
import { Dialog, DialogContent } from "@/components/ui/dialog";
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />);
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator ref={ref} className={cn("-mx-1 h-px bg-border", className)} {...props} />
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />;
};
CommandShortcut.displayName = "CommandShortcut";
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@@ -0,0 +1,178 @@
import * as React from "react";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const ContextMenu = ContextMenuPrimitive.Root;
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
const ContextMenuGroup = ContextMenuPrimitive.Group;
const ContextMenuPortal = ContextMenuPrimitive.Portal;
const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[state=open]:bg-accent data-[state=open]:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
));
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
/>
));
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
));
ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName;
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
));
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold text-foreground", inset && "pl-8", className)}
{...props}
/>
));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-border", className)} {...props} />
));
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
const ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />;
};
ContextMenuShortcut.displayName = "ContextMenuShortcut";
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};

View File

@@ -0,0 +1,95 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@@ -0,0 +1,87 @@
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils";
const Drawer = ({ shouldScaleBackground = true, ...props }: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} />
);
Drawer.displayName = "Drawer";
const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerPortal = DrawerPrimitive.Portal;
const DrawerClose = DrawerPrimitive.Close;
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay ref={ref} className={cn("fixed inset-0 z-50 bg-black/80", className)} {...props} />
));
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className,
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
));
DrawerContent.displayName = "DrawerContent";
const DrawerHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)} {...props} />
);
DrawerHeader.displayName = "DrawerHeader";
const DrawerFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />
);
DrawerFooter.displayName = "DrawerFooter";
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};

View File

@@ -0,0 +1,179 @@
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[state=open]:bg-accent focus:bg-accent",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />;
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

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

@@ -0,0 +1,129 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import { Controller, ControllerProps, FieldPath, FieldValues, FormProvider, useFormContext } from "react-hook-form";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
);
},
);
FormItem.displayName = "FormItem";
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return <Label ref={ref} className={cn(error && "text-destructive", className)} htmlFor={formItemId} {...props} />;
});
FormLabel.displayName = "FormLabel";
const FormControl = React.forwardRef<React.ElementRef<typeof Slot>, React.ComponentPropsWithoutRef<typeof Slot>>(
({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
aria-invalid={!!error}
{...props}
/>
);
},
);
FormControl.displayName = "FormControl";
const FormDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return <p ref={ref} id={formDescriptionId} className={cn("text-sm text-muted-foreground", className)} {...props} />;
},
);
FormDescription.displayName = "FormDescription";
const FormMessage = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;
if (!body) {
return null;
}
return (
<p ref={ref} id={formMessageId} className={cn("text-sm font-medium text-destructive", className)} {...props}>
{body}
</p>
);
},
);
FormMessage.displayName = "FormMessage";
export { useFormField, Form, FormItem, FormLabel, FormControl, FormDescription, FormMessage, FormField };

View File

@@ -0,0 +1,27 @@
import * as React from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "@/lib/utils";
const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger;
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent };

View File

@@ -0,0 +1,61 @@
import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { Dot } from "lucide-react";
import { cn } from "@/lib/utils";
const InputOTP = React.forwardRef<React.ElementRef<typeof OTPInput>, React.ComponentPropsWithoutRef<typeof OTPInput>>(
({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn("flex items-center gap-2 has-[:disabled]:opacity-50", containerClassName)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
),
);
InputOTP.displayName = "InputOTP";
const InputOTPGroup = React.forwardRef<React.ElementRef<"div">, React.ComponentPropsWithoutRef<"div">>(
({ className, ...props }, ref) => <div ref={ref} className={cn("flex items-center", className)} {...props} />,
);
InputOTPGroup.displayName = "InputOTPGroup";
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
return (
<div
ref={ref}
className={cn(
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-2 ring-ring ring-offset-background",
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink h-4 w-px bg-foreground duration-1000" />
</div>
)}
</div>
);
});
InputOTPSlot.displayName = "InputOTPSlot";
const InputOTPSeparator = React.forwardRef<React.ElementRef<"div">, React.ComponentPropsWithoutRef<"div">>(
({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Dot />
</div>
),
);
InputOTPSeparator.displayName = "InputOTPSeparator";
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };

View File

@@ -0,0 +1,17 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70");
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@@ -0,0 +1,207 @@
import * as React from "react";
import * as MenubarPrimitive from "@radix-ui/react-menubar";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const MenubarMenu = MenubarPrimitive.Menu;
const MenubarGroup = MenubarPrimitive.Group;
const MenubarPortal = MenubarPrimitive.Portal;
const MenubarSub = MenubarPrimitive.Sub;
const MenubarRadioGroup = MenubarPrimitive.RadioGroup;
const Menubar = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Root
ref={ref}
className={cn("flex h-10 items-center space-x-1 rounded-md border bg-background p-1", className)}
{...props}
/>
));
Menubar.displayName = MenubarPrimitive.Root.displayName;
const MenubarTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Trigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none data-[state=open]:bg-accent data-[state=open]:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
/>
));
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;
const MenubarSubTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[state=open]:bg-accent data-[state=open]:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
));
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
const MenubarSubContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;
const MenubarContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
>(({ className, align = "start", alignOffset = -4, sideOffset = 8, ...props }, ref) => (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
ref={ref}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</MenubarPrimitive.Portal>
));
MenubarContent.displayName = MenubarPrimitive.Content.displayName;
const MenubarItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
/>
));
MenubarItem.displayName = MenubarPrimitive.Item.displayName;
const MenubarCheckboxItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<MenubarPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
));
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;
const MenubarRadioItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<MenubarPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
));
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;
const MenubarLabel = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
));
MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
const MenubarSeparator = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
));
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
const MenubarShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />;
};
MenubarShortcut.displayname = "MenubarShortcut";
export {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarLabel,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarPortal,
MenubarSubContent,
MenubarSubTrigger,
MenubarGroup,
MenubarSub,
MenubarShortcut,
};

View File

@@ -0,0 +1,120 @@
import * as React from "react";
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { cva } from "class-variance-authority";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn("relative z-10 flex max-w-max flex-1 items-center justify-center", className)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
));
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName;
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn("group flex flex-1 list-none items-center justify-center space-x-1", className)}
{...props}
/>
));
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
const NavigationMenuItem = NavigationMenuPrimitive.Item;
const navigationMenuTriggerStyle = cva(
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50",
);
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
));
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName;
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto",
className,
)}
{...props}
/>
));
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName;
const NavigationMenuLink = NavigationMenuPrimitive.Link;
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className,
)}
ref={ref}
{...props}
/>
</div>
));
NavigationMenuViewport.displayName = NavigationMenuPrimitive.Viewport.displayName;
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className,
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
));
NavigationMenuIndicator.displayName = NavigationMenuPrimitive.Indicator.displayName;
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
};

View File

@@ -0,0 +1,81 @@
import * as React from "react";
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
import { ButtonProps, buttonVariants } from "@/components/ui/button";
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
);
Pagination.displayName = "Pagination";
const PaginationContent = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(
({ className, ...props }, ref) => (
<ul ref={ref} className={cn("flex flex-row items-center gap-1", className)} {...props} />
),
);
PaginationContent.displayName = "PaginationContent";
const PaginationItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
));
PaginationItem.displayName = "PaginationItem";
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<ButtonProps, "size"> &
React.ComponentProps<"a">;
const PaginationLink = ({ className, isActive, size = "icon", ...props }: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className,
)}
{...props}
/>
);
PaginationLink.displayName = "PaginationLink";
const PaginationPrevious = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink aria-label="Go to previous page" size="default" className={cn("gap-1 pl-2.5", className)} {...props}>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
);
PaginationPrevious.displayName = "PaginationPrevious";
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink aria-label="Go to next page" size="default" className={cn("gap-1 pr-2.5", className)} {...props}>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
);
PaginationNext.displayName = "PaginationNext";
const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
<span aria-hidden className={cn("flex h-9 w-9 items-center justify-center", className)} {...props}>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
);
PaginationEllipsis.displayName = "PaginationEllipsis";
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
};

View File

@@ -0,0 +1,29 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent };

View File

@@ -0,0 +1,23 @@
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils";
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn("relative h-4 w-full overflow-hidden rounded-full bg-secondary", className)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };

View File

@@ -0,0 +1,36 @@
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return <RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />;
});
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
});
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem };

View File

@@ -0,0 +1,37 @@
import { GripVertical } from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "@/lib/utils";
const ResizablePanelGroup = ({ className, ...props }: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn("flex h-full w-full data-[panel-group-direction=vertical]:flex-col", className)}
{...props}
/>
);
const ResizablePanel = ResizablePrimitive.Panel;
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className,
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };

View File

@@ -0,0 +1,38 @@
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/utils";
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root ref={ref} className={cn("relative overflow-hidden", className)} {...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };

View File

@@ -0,0 +1,143 @@
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label ref={ref} className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)} {...props} />
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};

View File

@@ -0,0 +1,20 @@
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn("shrink-0 bg-border", orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", className)}
{...props}
/>
));
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

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

@@ -0,0 +1,107 @@
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
},
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-secondary hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
),
);
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
);
SheetHeader.displayName = "SheetHeader";
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
);
SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title ref={ref} className={cn("text-lg font-semibold text-foreground", className)} {...props} />
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetOverlay,
SheetPortal,
SheetTitle,
SheetTrigger,
};

View File

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

View File

@@ -0,0 +1,7 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("animate-pulse rounded-md bg-muted", className)} {...props} />;
}
export { Skeleton };

View File

@@ -0,0 +1,23 @@
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils";
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn("relative flex w-full touch-none select-none items-center", className)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

View File

@@ -0,0 +1,27 @@
import { useTheme } from "next-themes";
import { Toaster as Sonner, toast } from "sonner";
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
);
};
export { Toaster, toast };

View File

@@ -0,0 +1,27 @@
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

View File

@@ -0,0 +1,72 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
</div>
),
);
Table.displayName = "Table";
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />,
);
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
),
);
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<tfoot ref={ref} className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)} {...props} />
),
);
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn("border-b transition-colors data-[state=selected]:bg-muted hover:bg-muted/50", className)}
{...props}
/>
),
);
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className,
)}
{...props}
/>
),
);
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<td ref={ref} className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)} {...props} />
),
);
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
({ className, ...props }, ref) => (
<caption ref={ref} className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />
),
);
TableCaption.displayName = "TableCaption";
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };

View File

@@ -0,0 +1,53 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
});
Textarea.displayName = "Textarea";
export { Textarea };

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

@@ -0,0 +1,111 @@
import * as React from "react";
import * as ToastPrimitives from "@radix-ui/react-toast";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className,
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive: "destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return <ToastPrimitives.Root ref={ref} className={cn(toastVariants({ variant }), className)} {...props} />;
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors group-[.destructive]:border-muted/40 hover:bg-secondary group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 group-[.destructive]:focus:ring-destructive disabled:pointer-events-none disabled:opacity-50",
className,
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity group-hover:opacity-100 group-[.destructive]:text-red-300 hover:text-foreground group-[.destructive]:hover:text-red-50 focus:opacity-100 focus:outline-none focus:ring-2 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className,
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title ref={ref} className={cn("text-sm font-semibold", className)} {...props} />
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description ref={ref} className={cn("text-sm opacity-90", className)} {...props} />
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};

View File

@@ -0,0 +1,24 @@
import { useToast } from "@/hooks/use-toast";
import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from "@/components/ui/toast";
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
}

View File

@@ -0,0 +1,49 @@
import * as React from "react";
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import { type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { toggleVariants } from "@/components/ui/toggle";
const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariants>>({
size: "default",
variant: "default",
});
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> & VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root ref={ref} className={cn("flex items-center justify-center gap-1", className)} {...props}>
<ToggleGroupContext.Provider value={{ variant, size }}>{children}</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
));
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> & VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className,
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
});
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
export { ToggleGroup, ToggleGroupItem };

View File

@@ -0,0 +1,37 @@
import * as React from "react";
import * as TogglePrimitive from "@radix-ui/react-toggle";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const toggleVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-3",
sm: "h-9 px-2.5",
lg: "h-11 px-5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root ref={ref} className={cn(toggleVariants({ variant, size, className }))} {...props} />
));
Toggle.displayName = TogglePrimitive.Root.displayName;
export { Toggle, toggleVariants };

View File

@@ -0,0 +1,28 @@
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

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

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

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

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

@@ -0,0 +1,186 @@
import * as React from "react";
import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType["ADD_TOAST"];
toast: ToasterToast;
}
| {
type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast>;
}
| {
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
}
| {
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
};
case "DISMISS_TOAST": {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
),
};
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
export { useToast, toast };

View File

@@ -0,0 +1,238 @@
import { useState, useEffect, useCallback } from 'react';
import { DocumentFile } from '@/types/documentation';
const STORAGE_KEY = 'documentation_files';
// Default documentation structure
const defaultDocuments: DocumentFile[] = [
{
id: 'about-asf',
title: 'About ASF (Agricultural Sensor Framework)',
description: 'Overview of the 3-tier distributed sensor architecture for smart agriculture.',
category: 'System Overview',
content: '# About ASF\n\nUpload the About_ASF.md file to see full documentation.',
fileName: 'About_ASF.md',
lastUpdated: new Date().toISOString()
},
{
id: 'system-assumptions',
title: 'System Assumptions & Limitations',
description: 'Design constraints and environmental assumptions.',
category: 'System Overview',
content: '# System Assumptions\n\nUpload the System_Assumptions_Limitations.md file to see full documentation.',
fileName: 'System_Assumptions_Limitations.md',
lastUpdated: new Date().toISOString()
},
{
id: 'fg-daq',
title: 'FG-DAQ: Data Acquisition',
description: 'Sensor reading, sampling, and data collection features.',
category: 'Feature Groups',
content: '# Data Acquisition Features\n\nUpload the DAQ_Sensor_Data_Acquisition_Features.md file to see full documentation.',
fileName: 'DAQ_Sensor_Data_Acquisition_Features.md',
lastUpdated: new Date().toISOString()
},
{
id: 'fg-dqc',
title: 'FG-DQC: Quality & Calibration',
description: 'Data validation, calibration management, and quality assurance.',
category: 'Feature Groups',
content: '# Quality & Calibration Features\n\nUpload the DQC_Data_Quality_Calibration_Features.md file to see full documentation.',
fileName: 'DQC_Data_Quality_Calibration_Features.md',
lastUpdated: new Date().toISOString()
},
{
id: 'fg-com',
title: 'FG-COM: Communication',
description: 'Network connectivity and data transmission features.',
category: 'Feature Groups',
content: '# Communication Features\n\nUpload the COM_Communication_Features.md file to see full documentation.',
fileName: 'COM_Communication_Features.md',
lastUpdated: new Date().toISOString()
},
{
id: 'fg-diag',
title: 'FG-DIAG: Diagnostics',
description: 'System health monitoring and fault detection.',
category: 'Feature Groups',
content: '# Diagnostics Features\n\nUpload the DIAG_Diagnostics_Health_Monitoring_Features.md file to see full documentation.',
fileName: 'DIAG_Diagnostics_Health_Monitoring_Features.md',
lastUpdated: new Date().toISOString()
},
{
id: 'fg-data',
title: 'FG-DATA: Persistence',
description: 'Data storage and retention management.',
category: 'Feature Groups',
content: '# Data Persistence Features\n\nUpload the DATA_Persistence_Data_Management_Features.md file to see full documentation.',
fileName: 'DATA_Persistence_Data_Management_Features.md',
lastUpdated: new Date().toISOString()
},
{
id: 'fg-ota',
title: 'FG-OTA: Over-The-Air Updates',
description: 'Firmware and configuration update mechanisms.',
category: 'Feature Groups',
content: '# OTA Update Features\n\nUpload the OTA_Firmware_Update_OTA_Features.md file to see full documentation.',
fileName: 'OTA_Firmware_Update_OTA_Features.md',
lastUpdated: new Date().toISOString()
},
{
id: 'fg-sec',
title: 'FG-SEC: Security',
description: 'Authentication, encryption, and access control.',
category: 'Feature Groups',
content: '# Security Features\n\nUpload the SEC_Security_Safety_Features.md file to see full documentation.',
fileName: 'SEC_Security_Safety_Features.md',
lastUpdated: new Date().toISOString()
},
{
id: 'fg-sys',
title: 'FG-SYS: System Management',
description: 'State machine, teardown, and local HMI.',
category: 'Feature Groups',
content: '# System Management Features\n\nUpload the SYS_System_Management_Features.md file to see full documentation.',
fileName: 'SYS_System_Management_Features.md',
lastUpdated: new Date().toISOString()
},
{
id: 'state-machine',
title: 'System State Machine Specification',
description: 'Formal definition of all operational states and transitions.',
category: 'State Machine',
content: '# State Machine Specification\n\nUpload the System_State_Machine_Specification.md file to see full documentation.',
fileName: 'System_State_Machine_Specification.md',
lastUpdated: new Date().toISOString()
},
{
id: 'srs',
title: 'Software Requirements Specification (SRS)',
description: 'Complete SRS document with 200+ SWRs across all feature groups.',
category: 'Requirements',
content: '# Software Requirements Specification\n\nUpload the SRS.md file to see full documentation.',
fileName: 'SRS.md',
lastUpdated: new Date().toISOString()
},
{
id: 'traceability',
title: 'Annex A: Traceability Matrix',
description: 'Complete F → SR → SWR → Component → Test mapping.',
category: 'Traceability & Verification',
content: '# Traceability Matrix\n\nUpload the Annex_A_Traceability.md file to see full documentation.',
fileName: 'Annex_A_Traceability.md',
lastUpdated: new Date().toISOString()
},
{
id: 'vv-matrix',
title: 'V&V Matrix',
description: 'Verification and Validation method assignments.',
category: 'Traceability & Verification',
content: '# V&V Matrix\n\nUpload the VV_Matrix.md file to see full documentation.',
fileName: 'VV_Matrix.md',
lastUpdated: new Date().toISOString()
},
{
id: 'interfaces',
title: 'Annex B: External Interfaces',
description: 'Hardware and software interface specifications.',
category: 'Interfaces & Budgets',
content: '# External Interfaces\n\nUpload the Annex_B_Interfaces.md file to see full documentation.',
fileName: 'Annex_B_Interfaces.md',
lastUpdated: new Date().toISOString()
},
{
id: 'budgets',
title: 'Annex C: Resource Budgets',
description: 'RAM, Flash, CPU, and timing allocations.',
category: 'Interfaces & Budgets',
content: '# Resource Budgets\n\nUpload the Annex_C_Budgets.md file to see full documentation.',
fileName: 'Annex_C_Budgets.md',
lastUpdated: new Date().toISOString()
}
];
export function useDocumentation() {
const [documents, setDocuments] = useState<DocumentFile[]>([]);
const [loading, setLoading] = useState(true);
// Load documents from localStorage
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
try {
setDocuments(JSON.parse(stored));
} catch {
setDocuments(defaultDocuments);
}
} else {
setDocuments(defaultDocuments);
}
setLoading(false);
}, []);
// Save to localStorage whenever documents change
const saveDocuments = useCallback((docs: DocumentFile[]) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(docs));
setDocuments(docs);
}, []);
// Update an existing document
const updateDocument = useCallback((id: string, content: string, fileName?: string) => {
setDocuments(prev => {
const updated = prev.map(doc =>
doc.id === id
? {
...doc,
content,
fileName: fileName || doc.fileName,
lastUpdated: new Date().toISOString()
}
: doc
);
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
return updated;
});
}, []);
// Add a new document
const addDocument = useCallback((doc: Omit<DocumentFile, 'id' | 'lastUpdated'>) => {
const newDoc: DocumentFile = {
...doc,
id: `doc-${Date.now()}`,
lastUpdated: new Date().toISOString()
};
setDocuments(prev => {
const updated = [...prev, newDoc];
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
return updated;
});
return newDoc;
}, []);
// Delete a document
const deleteDocument = useCallback((id: string) => {
setDocuments(prev => {
const updated = prev.filter(doc => doc.id !== id);
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
return updated;
});
}, []);
// Get unique categories
const categories = [...new Set(documents.map(d => d.category))];
// Get documents by category
const getDocsByCategory = useCallback((category: string) => {
return documents.filter(d => d.category === category);
}, [documents]);
return {
documents,
categories,
loading,
updateDocument,
addDocument,
deleteDocument,
getDocsByCategory
};
}

56
src/hooks/useTheme.tsx Normal file
View File

@@ -0,0 +1,56 @@
import { createContext, useContext, useEffect, useState, ReactNode } from "react";
type Theme = "dark" | "light" | "system";
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
const STORAGE_KEY = "nabd-theme";
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => {
if (typeof window !== "undefined") {
const stored = localStorage.getItem(STORAGE_KEY) as Theme;
return stored || "system";
}
return "system";
});
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove("light", "dark");
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
root.classList.add(systemTheme);
} else {
root.classList.add(theme);
}
}, [theme]);
const setTheme = (newTheme: Theme) => {
localStorage.setItem(STORAGE_KEY, newTheme);
setThemeState(newTheme);
};
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}

View File

@@ -0,0 +1,189 @@
import { useState, useEffect, useCallback } from 'react';
import { WorkPackage, TraceabilityData, WorkPackageType } from '@/types/traceability';
export function useTraceabilityData() {
const [data, setData] = useState<TraceabilityData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date>(new Date());
const [parseLog, setParseLog] = useState<string[]>([]);
const parseCSV = useCallback((csvText: string): { workPackages: WorkPackage[], logs: string[] } => {
const logs: string[] = [];
logs.push(`[CSV Parser] Starting parse, total length: ${csvText.length} chars`);
// Remove BOM if present
const cleanText = csvText.replace(/^\uFEFF/, '');
const workPackages: WorkPackage[] = [];
// CSV format: ID,Type,Status,Title,Description,Parent_ID,Relations
// Description can be multi-line and contain HTML
let pos = 0;
const lines = cleanText.split('\n');
logs.push(`[CSV Parser] Total lines: ${lines.length}`);
// Skip header
const header = lines[0];
logs.push(`[CSV Parser] Header: ${header}`);
let currentRow: string[] = [];
let inQuotedField = false;
let currentField = '';
let lineNum = 1;
// Process character by character for proper CSV parsing
const content = lines.slice(1).join('\n');
for (let i = 0; i < content.length; i++) {
const char = content[i];
const nextChar = content[i + 1];
if (inQuotedField) {
if (char === '"') {
if (nextChar === '"') {
// Escaped quote
currentField += '"';
i++;
} else {
// End of quoted field
inQuotedField = false;
}
} else {
currentField += char;
}
} else {
if (char === '"' && currentField === '') {
// Start of quoted field
inQuotedField = true;
} else if (char === ',') {
currentRow.push(currentField);
currentField = '';
} else if (char === '\n') {
currentRow.push(currentField);
currentField = '';
// Process the completed row if it has enough fields and starts with an ID
if (currentRow.length >= 4 && /^\d+$/.test(currentRow[0]?.trim())) {
const [id, type, status, title, description = '', parentId = '', relations = ''] = currentRow;
const wpType = type?.toLowerCase().replace(/\s+/g, '') as WorkPackageType;
workPackages.push({
id: parseInt(id, 10),
type: wpType,
status: status || '',
title: title || '',
description: description || '',
parentId: parentId?.trim() || '',
relations: relations?.trim() || ''
});
if (workPackages.length <= 5) {
logs.push(`[CSV Parser] Parsed WP #${id}: ${type} - "${title?.substring(0, 50)}..."`);
}
}
currentRow = [];
lineNum++;
} else {
currentField += char;
}
}
}
// Handle last row if exists
if (currentField || currentRow.length > 0) {
currentRow.push(currentField);
if (currentRow.length >= 4 && /^\d+$/.test(currentRow[0]?.trim())) {
const [id, type, status, title, description = '', parentId = '', relations = ''] = currentRow;
const wpType = type?.toLowerCase().replace(/\s+/g, '') as WorkPackageType;
workPackages.push({
id: parseInt(id, 10),
type: wpType,
status: status || '',
title: title || '',
description: description || '',
parentId: parentId?.trim() || '',
relations: relations?.trim() || ''
});
}
}
logs.push(`[CSV Parser] Total work packages parsed: ${workPackages.length}`);
// Log type distribution
const typeDist = workPackages.reduce((acc, wp) => {
acc[wp.type] = (acc[wp.type] || 0) + 1;
return acc;
}, {} as Record<string, number>);
logs.push(`[CSV Parser] Type distribution: ${JSON.stringify(typeDist)}`);
return { workPackages, logs };
}, []);
const loadData = useCallback(async () => {
setLoading(true);
setError(null);
setParseLog([]);
try {
const response = await fetch('/data/traceability_export.csv');
if (!response.ok) {
throw new Error('Failed to load traceability data');
}
const csvText = await response.text();
const { workPackages, logs } = parseCSV(csvText);
setParseLog(logs);
const now = new Date();
setData({
lastUpdated: now,
workPackages
});
setLastUpdated(now);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
}, [parseCSV]);
const refresh = useCallback(() => {
loadData();
}, [loadData]);
useEffect(() => {
loadData();
}, [loadData]);
// Group by type
const groupedByType = data?.workPackages.reduce((acc, wp) => {
const type = wp.type;
if (!acc[type]) acc[type] = [];
acc[type].push(wp);
return acc;
}, {} as Record<WorkPackageType, WorkPackage[]>) || {};
// Get counts by type
const typeCounts = Object.entries(groupedByType).reduce((acc, [type, items]) => {
acc[type as WorkPackageType] = (items as WorkPackage[]).length;
return acc;
}, {} as Record<WorkPackageType, number>);
return {
data,
loading,
error,
lastUpdated,
refresh,
groupedByType,
typeCounts,
parseLog,
setData // Expose setData for manual data updates
};
}

158
src/index.css Normal file
View File

@@ -0,0 +1,158 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* NABD Solutions - ASF Traceability Matrix Design System
Modern dark blue theme with light mode support
All colors are HSL */
@layer base {
:root {
/* Light Mode - Clean professional blues */
--background: 210 25% 98%;
--foreground: 215 50% 15%;
--card: 0 0% 100%;
--card-foreground: 215 50% 15%;
--popover: 0 0% 100%;
--popover-foreground: 215 50% 15%;
/* Primary - NABD Dark Blue */
--primary: 215 60% 25%;
--primary-foreground: 210 40% 98%;
/* Secondary - Light blue accent */
--secondary: 210 40% 94%;
--secondary-foreground: 215 50% 20%;
--muted: 210 30% 94%;
--muted-foreground: 215 20% 45%;
/* Accent - Gold/Orange from NABD logo */
--accent: 38 90% 50%;
--accent-foreground: 215 50% 15%;
--destructive: 0 72% 51%;
--destructive-foreground: 210 40% 98%;
--border: 214 25% 88%;
--input: 214 25% 88%;
--ring: 215 60% 35%;
--radius: 0.5rem;
/* Sidebar - Light mode */
--sidebar-background: 215 50% 18%;
--sidebar-foreground: 210 30% 90%;
--sidebar-primary: 38 90% 55%;
--sidebar-primary-foreground: 215 50% 15%;
--sidebar-accent: 215 45% 25%;
--sidebar-accent-foreground: 210 30% 95%;
--sidebar-border: 215 40% 25%;
--sidebar-ring: 38 90% 55%;
}
.dark {
/* Dark Mode - Deep professional blue */
--background: 215 55% 10%;
--foreground: 210 30% 95%;
--card: 215 50% 13%;
--card-foreground: 210 30% 95%;
--popover: 215 50% 13%;
--popover-foreground: 210 30% 95%;
/* Primary - Bright accent for dark mode */
--primary: 38 90% 55%;
--primary-foreground: 215 50% 10%;
/* Secondary */
--secondary: 215 40% 20%;
--secondary-foreground: 210 30% 95%;
--muted: 215 40% 18%;
--muted-foreground: 210 20% 60%;
/* Accent - Gold from NABD logo */
--accent: 38 85% 50%;
--accent-foreground: 215 50% 10%;
--destructive: 0 62% 45%;
--destructive-foreground: 210 40% 98%;
--border: 215 35% 22%;
--input: 215 35% 22%;
--ring: 38 80% 55%;
/* Sidebar - Dark mode */
--sidebar-background: 215 55% 8%;
--sidebar-foreground: 210 25% 90%;
--sidebar-primary: 38 90% 55%;
--sidebar-primary-foreground: 215 50% 10%;
--sidebar-accent: 215 45% 18%;
--sidebar-accent-foreground: 210 25% 95%;
--sidebar-border: 215 40% 15%;
--sidebar-ring: 38 90% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
/* Print styles for PDF export */
@media print {
body {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.print\:hidden {
display: none !important;
}
.print\:space-y-4 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(1rem * var(--tw-space-y-reverse));
}
.print\:bg-transparent {
background-color: transparent !important;
}
.print\:border-0 {
border-width: 0 !important;
}
/* Hide sidebar and navigation during print */
[data-sidebar],
nav,
aside {
display: none !important;
}
/* Ensure main content fills the page */
main {
margin: 0 !important;
padding: 1rem !important;
width: 100% !important;
}
/* Better page breaks */
.card, .accordion-item {
page-break-inside: avoid;
}
h1, h2, h3 {
page-break-after: avoid;
}
}

213
src/lib/exportUtils.ts Normal file
View File

@@ -0,0 +1,213 @@
import jsPDF from 'jspdf';
import html2canvas from 'html2canvas';
import { WorkPackage } from '@/types/traceability';
interface GapData {
category: string;
severity: string;
gaps: {
id: string;
title: string;
description: string;
questions: string[];
recommendation: string;
esp32Impact: string;
}[];
}
interface TraceabilityChain {
feature: WorkPackage;
requirements: {
requirement: WorkPackage;
swRequirements: {
swReq: WorkPackage;
testCases: WorkPackage[];
}[];
}[];
}
// Generate Markdown for Gap Analysis
export function generateGapAnalysisMarkdown(gapData: GapData[]): string {
const date = new Date().toISOString().split('T')[0];
let md = `# ASF Sensor Hub - Requirements Gap Analysis Report\n\n`;
md += `**Generated:** ${date}\n`;
md += `**Standard:** ISO/IEC/IEEE 29148 Compliance Review\n\n`;
md += `---\n\n`;
// Summary
const totalGaps = gapData.reduce((acc, g) => acc + g.gaps.length, 0);
const criticalGaps = gapData.filter(g => g.severity === 'critical');
const highGaps = gapData.filter(g => g.severity === 'high');
md += `## Executive Summary\n\n`;
md += `- **Total Gaps Identified:** ${totalGaps}\n`;
md += `- **Critical Issues:** ${criticalGaps.reduce((acc, g) => acc + g.gaps.length, 0)} in ${criticalGaps.length} categories\n`;
md += `- **High Priority Issues:** ${highGaps.reduce((acc, g) => acc + g.gaps.length, 0)} in ${highGaps.length} categories\n\n`;
md += `---\n\n`;
// Detail by category
for (const category of gapData) {
md += `## ${category.category}\n\n`;
md += `**Severity:** ${category.severity.toUpperCase()}\n\n`;
for (const gap of category.gaps) {
md += `### ${gap.id}: ${gap.title}\n\n`;
md += `${gap.description}\n\n`;
md += `**Questions to Resolve:**\n`;
for (const q of gap.questions) {
md += `- ${q}\n`;
}
md += `\n`;
md += `**Recommendation:** ${gap.recommendation}\n\n`;
md += `**ESP32-S3 Impact:** ${gap.esp32Impact}\n\n`;
md += `---\n\n`;
}
}
return md;
}
// Generate Markdown for Traceability Matrix
export function generateTraceabilityMarkdown(
chains: TraceabilityChain[],
stats: {
totalFeatures: number;
totalRequirements: number;
totalSWReqs: number;
totalTestCases: number;
featureCoverage: number;
reqCoverage: number;
swReqCoverage: number;
}
): string {
const date = new Date().toISOString().split('T')[0];
let md = `# Traceability Matrix Report\n\n`;
md += `**Generated:** ${date}\n`;
md += `**Project:** ASF Sensor Hub\n\n`;
md += `---\n\n`;
// Coverage Summary
md += `## Coverage Summary\n\n`;
md += `| Metric | Count | Coverage |\n`;
md += `|--------|-------|----------|\n`;
md += `| Features | ${stats.totalFeatures} | ${stats.featureCoverage}% linked to requirements |\n`;
md += `| Requirements | ${stats.totalRequirements} | ${stats.reqCoverage}% linked to SW requirements |\n`;
md += `| SW Requirements | ${stats.totalSWReqs} | ${stats.swReqCoverage}% have test cases |\n`;
md += `| Test Cases | ${stats.totalTestCases} | - |\n\n`;
md += `---\n\n`;
// Traceability Chains
md += `## Traceability Chains\n\n`;
for (const chain of chains) {
md += `### Feature #${chain.feature.id}: ${chain.feature.title}\n\n`;
md += `**Status:** ${chain.feature.status}\n\n`;
if (chain.requirements.length === 0) {
md += `_No linked requirements_\n\n`;
} else {
for (const reqChain of chain.requirements) {
md += `#### Requirement #${reqChain.requirement.id}: ${reqChain.requirement.title}\n\n`;
if (reqChain.swRequirements.length === 0) {
md += `_No linked SW requirements_\n\n`;
} else {
md += `| SW Requirement | Test Cases |\n`;
md += `|----------------|------------|\n`;
for (const swChain of reqChain.swRequirements) {
const tests = swChain.testCases.map(tc => `#${tc.id}`).join(', ') || '_None_';
md += `| #${swChain.swReq.id}: ${swChain.swReq.title.substring(0, 40)}... | ${tests} |\n`;
}
md += `\n`;
}
}
}
md += `---\n\n`;
}
return md;
}
// Download markdown file
export function downloadMarkdown(content: string, filename: string): void {
const blob = new Blob([content], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// Generate PDF from HTML element
export async function generatePDFFromElement(
elementId: string,
filename: string,
title: string
): Promise<void> {
const element = document.getElementById(elementId);
if (!element) {
console.error('Element not found:', elementId);
return;
}
try {
const canvas = await html2canvas(element, {
scale: 2,
useCORS: true,
logging: false,
backgroundColor: '#ffffff',
});
const imgData = canvas.toDataURL('image/png');
const pdf = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4',
});
const pageWidth = pdf.internal.pageSize.getWidth();
const pageHeight = pdf.internal.pageSize.getHeight();
const margin = 10;
// Add title
pdf.setFontSize(16);
pdf.text(title, margin, 15);
pdf.setFontSize(10);
pdf.text(`Generated: ${new Date().toLocaleDateString()}`, margin, 22);
// Calculate image dimensions
const imgWidth = pageWidth - 2 * margin;
const imgHeight = (canvas.height * imgWidth) / canvas.width;
let heightLeft = imgHeight;
let position = 30;
// Add first page
pdf.addImage(imgData, 'PNG', margin, position, imgWidth, imgHeight);
heightLeft -= pageHeight - position;
// Add additional pages if needed
while (heightLeft > 0) {
position = heightLeft - imgHeight;
pdf.addPage();
pdf.addImage(imgData, 'PNG', margin, position, imgWidth, imgHeight);
heightLeft -= pageHeight;
}
pdf.save(filename);
} catch (error) {
console.error('PDF generation failed:', error);
throw error;
}
}
// Simple PDF export using browser print
export function printToPDF(): void {
window.print();
}

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

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

10
src/main.tsx Normal file
View File

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

215
src/pages/ALMTypePage.tsx Normal file
View File

@@ -0,0 +1,215 @@
import { useParams } from "react-router-dom";
import { AppLayout } from "@/components/layout/AppLayout";
import { useTraceabilityData } from "@/hooks/useTraceabilityData";
import { WorkPackageCard } from "@/components/traceability/WorkPackageCard";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useState, useMemo } from "react";
import {
Target,
CheckSquare,
FileText,
TestTube,
Layers,
Bug,
AlertTriangle,
Calendar,
Milestone,
FolderKanban,
Search,
} from "lucide-react";
import { WorkPackageType } from "@/types/traceability";
const typeConfig: Record<
string,
{ title: string; icon: React.ReactNode; description: string }
> = {
feature: {
title: "Features",
icon: <Target className="h-6 w-6" />,
description: "System features and capabilities",
},
requirements: {
title: "System Requirements",
icon: <CheckSquare className="h-6 w-6" />,
description: "High-level system requirements (SR-*)",
},
swreq: {
title: "Software Requirements",
icon: <FileText className="h-6 w-6" />,
description: "Detailed software requirements (SWR-*)",
},
"test-case": {
title: "Test Cases",
icon: <TestTube className="h-6 w-6" />,
description: "Verification and validation test cases",
},
epic: {
title: "Epics",
icon: <Layers className="h-6 w-6" />,
description: "Large feature containers",
},
"user-story": {
title: "User Stories",
icon: <FolderKanban className="h-6 w-6" />,
description: "User-focused requirements",
},
task: {
title: "Tasks",
icon: <CheckSquare className="h-6 w-6" />,
description: "Implementation tasks",
},
bug: {
title: "Bugs",
icon: <Bug className="h-6 w-6" />,
description: "Defects and issues",
},
risk: {
title: "Risks",
icon: <AlertTriangle className="h-6 w-6" />,
description: "Identified project risks",
},
milestone: {
title: "Milestones",
icon: <Milestone className="h-6 w-6" />,
description: "Project milestones",
},
phase: {
title: "Phases",
icon: <Calendar className="h-6 w-6" />,
description: "Project phases",
},
"summary-task": {
title: "Summary Tasks",
icon: <FolderKanban className="h-6 w-6" />,
description: "Task containers",
},
};
export default function ALMTypePage() {
const { type } = useParams<{ type: string }>();
const { data, loading, lastUpdated, refresh, groupedByType } =
useTraceabilityData();
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
// Convert URL param to actual type (e.g., "test-case" -> "test case")
const actualType = type?.replace("-", " ") as WorkPackageType;
const items = groupedByType[actualType] || [];
const config = typeConfig[type || ""] || {
title: type || "Unknown",
icon: <FileText className="h-6 w-6" />,
description: "",
};
// Get unique statuses
const statuses = useMemo(() => {
const statusSet = new Set<string>();
items.forEach((item) => statusSet.add(item.status));
return Array.from(statusSet).sort();
}, [items]);
// Filter items
const filteredItems = useMemo(() => {
return items.filter((item) => {
const matchesSearch =
searchQuery === "" ||
item.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.id.toString().includes(searchQuery);
const matchesStatus =
statusFilter === "all" || item.status === statusFilter;
return matchesSearch && matchesStatus;
});
}, [items, searchQuery, statusFilter]);
return (
<AppLayout lastUpdated={lastUpdated} onRefresh={refresh} isRefreshing={loading}>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary/10">{config.icon}</div>
<div>
<h1 className="text-2xl font-bold">{config.title}</h1>
<p className="text-muted-foreground">{config.description}</p>
</div>
<Badge variant="secondary" className="ml-auto text-lg px-3 py-1">
{items.length} items
</Badge>
</div>
{/* Filters */}
<Card>
<CardContent className="pt-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by ID, title, or description..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full sm:w-[180px]">
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
{statuses.map((status) => (
<SelectItem key={status} value={status}>
{status}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Results */}
<div className="space-y-2">
{loading ? (
<div className="text-center py-8 text-muted-foreground">
Loading...
</div>
) : filteredItems.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
{items.length === 0
? `No ${config.title.toLowerCase()} found in the traceability data.`
: "No items match your search criteria."}
</CardContent>
</Card>
) : (
filteredItems.map((item) => (
<WorkPackageCard
key={item.id}
workPackage={item}
allWorkPackages={data?.workPackages || []}
/>
))
)}
</div>
{/* Summary */}
{filteredItems.length > 0 && filteredItems.length !== items.length && (
<p className="text-sm text-muted-foreground text-center">
Showing {filteredItems.length} of {items.length} items
</p>
)}
</div>
</AppLayout>
);
}

1082
src/pages/AnalysisPage.tsx Normal file

File diff suppressed because it is too large Load Diff

307
src/pages/Dashboard.tsx Normal file
View File

@@ -0,0 +1,307 @@
import { AppLayout } from "@/components/layout/AppLayout";
import { useTraceabilityData } from "@/hooks/useTraceabilityData";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area";
import { CSVUpload } from "@/components/CSVUpload";
import { WorkPackage } from "@/types/traceability";
import {
Target,
CheckSquare,
FileText,
TestTube,
Layers,
Bug,
AlertTriangle,
GitBranch,
TrendingUp,
Activity,
Download,
ChevronDown,
Terminal,
Upload,
} from "lucide-react";
import { Link } from "react-router-dom";
import { useState } from "react";
const typeIcons: Record<string, React.ReactNode> = {
feature: <Target className="h-5 w-5" />,
requirements: <CheckSquare className="h-5 w-5" />,
swreq: <FileText className="h-5 w-5" />,
"test case": <TestTube className="h-5 w-5" />,
epic: <Layers className="h-5 w-5" />,
bug: <Bug className="h-5 w-5" />,
risk: <AlertTriangle className="h-5 w-5" />,
task: <CheckSquare className="h-5 w-5" />,
};
const typeColors: Record<string, string> = {
feature: "bg-purple-500/10 text-purple-700 border-purple-500/20",
requirements: "bg-blue-500/10 text-blue-700 border-blue-500/20",
swreq: "bg-indigo-500/10 text-indigo-700 border-indigo-500/20",
"test case": "bg-green-500/10 text-green-700 border-green-500/20",
epic: "bg-amber-500/10 text-amber-700 border-amber-500/20",
bug: "bg-red-500/10 text-red-700 border-red-500/20",
risk: "bg-orange-500/10 text-orange-700 border-orange-500/20",
task: "bg-slate-500/10 text-slate-700 border-slate-500/20",
};
export default function Dashboard() {
const { data, loading, lastUpdated, refresh, typeCounts, groupedByType, parseLog, setData } =
useTraceabilityData();
const [showDebug, setShowDebug] = useState(false);
const [showUpload, setShowUpload] = useState(false);
const totalItems = data?.workPackages.length || 0;
// Calculate traceability coverage
const features = groupedByType["feature"] || [];
const requirements = groupedByType["requirements"] || [];
const swreqs = groupedByType["swreq"] || [];
const testCases = groupedByType["test case"] || [];
const handleDownloadCSV = () => {
window.open('/data/traceability_export.csv', '_blank');
};
const handleDataLoaded = (workPackages: WorkPackage[]) => {
setData({
lastUpdated: new Date(),
workPackages
});
setShowUpload(false);
};
return (
<AppLayout
lastUpdated={lastUpdated}
onRefresh={refresh}
isRefreshing={loading}
>
<div className="space-y-6">
{/* Header Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Total Items</CardTitle>
<GitBranch className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalItems}</div>
<p className="text-xs text-muted-foreground">
Work packages tracked
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Features</CardTitle>
<Target className="h-4 w-4 text-purple-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{features.length}</div>
<p className="text-xs text-muted-foreground">
System features defined
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Requirements</CardTitle>
<CheckSquare className="h-4 w-4 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{requirements.length + swreqs.length}
</div>
<p className="text-xs text-muted-foreground">
SR + SWR total
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Test Cases</CardTitle>
<TestTube className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{testCases.length}</div>
<p className="text-xs text-muted-foreground">
Verification tests
</p>
</CardContent>
</Card>
</div>
{/* Data Management */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Download className="h-5 w-5" />
Data Management
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-3">
<Button variant="outline" onClick={handleDownloadCSV}>
<Download className="h-4 w-4 mr-2" />
Download CSV
</Button>
<Button variant="outline" onClick={() => setShowUpload(!showUpload)}>
<Upload className="h-4 w-4 mr-2" />
Upload CSV
</Button>
<Button variant="outline" onClick={refresh} disabled={loading}>
Reload Data
</Button>
</div>
{showUpload && (
<CSVUpload
onDataLoaded={handleDataLoaded}
onClose={() => setShowUpload(false)}
/>
)}
<Collapsible open={showDebug} onOpenChange={setShowDebug}>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="flex items-center gap-2">
<Terminal className="h-4 w-4" />
Debug Logs
<ChevronDown className={`h-4 w-4 transition-transform ${showDebug ? 'rotate-180' : ''}`} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<Card className="mt-2 bg-slate-950">
<CardContent className="p-4">
<ScrollArea className="h-48">
<div className="font-mono text-xs text-green-400 space-y-1">
{parseLog.length > 0 ? (
parseLog.map((log, i) => (
<div key={i} className="opacity-90">{log}</div>
))
) : (
<div className="text-slate-500">No logs available. Click "Reload Data" to see parsing logs.</div>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
</CollapsibleContent>
</Collapsible>
<div className="text-xs text-muted-foreground">
<p>To update data from OpenProject, run the Python script locally:</p>
<code className="bg-muted px-2 py-1 rounded text-xs mt-1 block">
python public/data/get_traceability.py
</code>
</div>
</CardContent>
</Card>
{/* Traceability Summary */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Type Distribution */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5" />
Work Package Distribution
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{Object.entries(typeCounts)
.sort(([, a], [, b]) => b - a)
.map(([type, count]) => (
<Link
key={type}
to={`/alm/${type.replace(" ", "-")}`}
className="flex items-center justify-between p-2 rounded-lg hover:bg-accent transition-colors"
>
<div className="flex items-center gap-3">
<div
className={`p-2 rounded-md border ${
typeColors[type] || "bg-muted"
}`}
>
{typeIcons[type] || <FileText className="h-5 w-5" />}
</div>
<span className="font-medium capitalize">{type}</span>
</div>
<Badge variant="secondary">{count}</Badge>
</Link>
))}
</div>
</CardContent>
</Card>
{/* Quick Links */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" />
Traceability Chain
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg">
<span>Features Requirements</span>
<Badge variant="outline">
{features.length} {requirements.length}
</Badge>
</div>
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg">
<span>Requirements SW Requirements</span>
<Badge variant="outline">
{requirements.length} {swreqs.length}
</Badge>
</div>
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg">
<span>SW Requirements Test Cases</span>
<Badge variant="outline">
{swreqs.length} {testCases.length}
</Badge>
</div>
<div className="pt-4 border-t">
<h4 className="text-sm font-medium mb-3">Quick Actions</h4>
<div className="flex flex-wrap gap-2">
<Link to="/documentation">
<Badge variant="outline" className="cursor-pointer hover:bg-accent">
📄 View Documentation
</Badge>
</Link>
<Link to="/analysis">
<Badge variant="outline" className="cursor-pointer hover:bg-accent">
🔍 Gap Analysis
</Badge>
</Link>
<Link to="/matrix">
<Badge variant="outline" className="cursor-pointer hover:bg-accent">
🔗 Traceability Matrix
</Badge>
</Link>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Loading State */}
{loading && (
<div className="text-center py-8 text-muted-foreground">
Loading traceability data...
</div>
)}
</div>
</AppLayout>
);
}

View File

@@ -0,0 +1,129 @@
import { useState } from 'react';
import { AppLayout } from "@/components/layout/AppLayout";
import { useTraceabilityData } from "@/hooks/useTraceabilityData";
import { useDocumentation } from "@/hooks/useDocumentation";
import { Accordion } from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { DocumentCard } from "@/components/documentation/DocumentCard";
import { DocumentUpload } from "@/components/documentation/DocumentUpload";
import {
BookOpen,
Plus,
Layers,
GitBranch,
Settings,
Activity,
Cpu,
CheckCircle
} from "lucide-react";
// Category icon mapping
const categoryIcons: Record<string, React.ReactNode> = {
'System Overview': <Layers className="h-5 w-5" />,
'Feature Groups': <GitBranch className="h-5 w-5" />,
'State Machine': <Settings className="h-5 w-5" />,
'Requirements': <CheckCircle className="h-5 w-5" />,
'Traceability & Verification': <Activity className="h-5 w-5" />,
'Interfaces & Budgets': <Cpu className="h-5 w-5" />,
};
export default function DocumentationPage() {
const { lastUpdated, refresh, loading } = useTraceabilityData();
const {
documents,
categories,
loading: docsLoading,
updateDocument,
addDocument,
deleteDocument,
getDocsByCategory
} = useDocumentation();
const [addDialogOpen, setAddDialogOpen] = useState(false);
const handleAddDocument = (data: {
title: string;
description: string;
category: string;
content: string;
fileName: string;
}) => {
addDocument(data);
};
if (docsLoading) {
return (
<AppLayout lastUpdated={lastUpdated} onRefresh={refresh} isRefreshing={loading}>
<div className="flex items-center justify-center h-64">
<p className="text-muted-foreground">Loading documentation...</p>
</div>
</AppLayout>
);
}
return (
<AppLayout lastUpdated={lastUpdated} onRefresh={refresh} isRefreshing={loading}>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<BookOpen className="h-6 w-6 text-primary" />
<h1 className="text-2xl font-bold">System Documentation</h1>
</div>
<Button onClick={() => setAddDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Add Document
</Button>
</div>
<p className="text-muted-foreground">
Complete documentation for the ASF Sensor Hub system. Upload .md files to view full documentation,
or add new documents to expand the knowledge base.
</p>
<div className="space-y-6">
{categories.map((category) => {
const docs = getDocsByCategory(category);
const icon = categoryIcons[category] || <BookOpen className="h-5 w-5" />;
return (
<Card key={category}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{icon}
{category}
<span className="text-sm font-normal text-muted-foreground">
({docs.length} {docs.length === 1 ? 'document' : 'documents'})
</span>
</CardTitle>
</CardHeader>
<CardContent>
<Accordion type="multiple" className="w-full">
{docs.map((doc) => (
<DocumentCard
key={doc.id}
document={doc}
categories={categories}
onUpdate={updateDocument}
onDelete={deleteDocument}
/>
))}
</Accordion>
</CardContent>
</Card>
);
})}
</div>
</div>
<DocumentUpload
open={addDialogOpen}
onOpenChange={setAddDialogOpen}
mode="add"
categories={categories}
onSave={handleAddDocument}
/>
</AppLayout>
);
}

1071
src/pages/Index.tsx Normal file

File diff suppressed because it is too large Load Diff

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

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

View File

@@ -0,0 +1,815 @@
import { useState, useMemo } from "react";
import { AppLayout } from "@/components/layout/AppLayout";
import { useTraceabilityData } from "@/hooks/useTraceabilityData";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
GitBranch,
Search,
Target,
CheckSquare,
FileText,
TestTube,
ChevronRight,
ChevronDown,
ExternalLink,
ArrowRight,
Layers,
AlertCircle,
Download,
Printer
} from "lucide-react";
import { cn } from "@/lib/utils";
import { WorkPackage, ParsedRelation } from "@/types/traceability";
import { generateTraceabilityMarkdown, downloadMarkdown, printToPDF } from "@/lib/exportUtils";
const OPENPROJECT_BASE_URL = "https://openproject.nabd-co.com/projects/asf/work_packages";
function getWorkPackageUrl(id: number): string {
return `${OPENPROJECT_BASE_URL}/${id}/activity`;
}
function parseRelations(relationsStr: string): ParsedRelation[] {
if (!relationsStr) return [];
const relations: ParsedRelation[] = [];
const matches = relationsStr.matchAll(/(\w+)\(#(\d+)\)/g);
for (const match of matches) {
relations.push({
type: match[1],
targetId: parseInt(match[2], 10),
});
}
return relations;
}
interface TraceabilityChain {
feature: WorkPackage;
requirements: {
requirement: WorkPackage;
swRequirements: {
swReq: WorkPackage;
testCases: WorkPackage[];
}[];
}[];
}
function getStatusColor(status: string): string {
const statusLower = status.toLowerCase();
if (statusLower.includes("done") || statusLower.includes("closed") || statusLower.includes("resolved")) {
return "bg-green-500/10 text-green-700 border-green-500/20";
}
if (statusLower.includes("progress") || statusLower.includes("active")) {
return "bg-blue-500/10 text-blue-700 border-blue-500/20";
}
if (statusLower.includes("blocked") || statusLower.includes("rejected")) {
return "bg-red-500/10 text-red-700 border-red-500/20";
}
return "bg-amber-500/10 text-amber-700 border-amber-500/20";
}
function getCoverageColor(coverage: number): string {
if (coverage >= 80) return "text-green-600";
if (coverage >= 50) return "text-amber-600";
return "text-red-600";
}
export default function TraceabilityMatrixPage() {
const { data, loading, groupedByType } = useTraceabilityData();
const [searchQuery, setSearchQuery] = useState("");
const [expandedFeatures, setExpandedFeatures] = useState<Set<number>>(new Set());
const [expandedReqs, setExpandedReqs] = useState<Set<number>>(new Set());
// Build traceability chains
const traceabilityChains = useMemo(() => {
if (!data?.workPackages) return [];
const allWPs = data.workPackages;
const features = allWPs.filter(wp => wp.type === 'feature');
const requirements = allWPs.filter(wp => wp.type === 'requirements');
const swReqs = allWPs.filter(wp => wp.type === 'swreq');
const testCases = allWPs.filter(wp => wp.type === 'test case');
// Create lookup maps
const wpById = new Map(allWPs.map(wp => [wp.id, wp]));
// Find related items by checking relations and parent
function findRelatedIds(wp: WorkPackage): number[] {
const relations = parseRelations(wp.relations);
const relatedIds = relations.map(r => r.targetId);
if (wp.parentId) {
relatedIds.push(parseInt(wp.parentId, 10));
}
return relatedIds;
}
// Build chains starting from features
const chains: TraceabilityChain[] = features.map(feature => {
// Find requirements linked to this feature
const linkedReqs = requirements.filter(req => {
const relatedIds = findRelatedIds(req);
return relatedIds.includes(feature.id) ||
req.parentId === String(feature.id) ||
findRelatedIds(feature).includes(req.id);
});
return {
feature,
requirements: linkedReqs.map(req => {
// Find SW requirements linked to this requirement
const linkedSWReqs = swReqs.filter(swReq => {
const relatedIds = findRelatedIds(swReq);
return relatedIds.includes(req.id) ||
swReq.parentId === String(req.id) ||
findRelatedIds(req).includes(swReq.id);
});
return {
requirement: req,
swRequirements: linkedSWReqs.map(swReq => {
// Find test cases linked to this SW requirement
const linkedTests = testCases.filter(tc => {
const relatedIds = findRelatedIds(tc);
return relatedIds.includes(swReq.id) ||
tc.parentId === String(swReq.id) ||
findRelatedIds(swReq).includes(tc.id);
});
return {
swReq,
testCases: linkedTests
};
})
};
})
};
});
return chains;
}, [data]);
// Filter chains by search
const filteredChains = useMemo(() => {
if (!searchQuery) return traceabilityChains;
const query = searchQuery.toLowerCase();
return traceabilityChains.filter(chain => {
const featureMatch = chain.feature.title.toLowerCase().includes(query) ||
String(chain.feature.id).includes(query);
const reqMatch = chain.requirements.some(r =>
r.requirement.title.toLowerCase().includes(query) ||
String(r.requirement.id).includes(query)
);
const swReqMatch = chain.requirements.some(r =>
r.swRequirements.some(sw =>
sw.swReq.title.toLowerCase().includes(query) ||
String(sw.swReq.id).includes(query)
)
);
return featureMatch || reqMatch || swReqMatch;
});
}, [traceabilityChains, searchQuery]);
// Calculate coverage statistics
const stats = useMemo(() => {
const features = groupedByType['feature'] || [];
const requirements = groupedByType['requirements'] || [];
const swReqs = groupedByType['swreq'] || [];
const testCases = groupedByType['test case'] || [];
const featuresWithReqs = traceabilityChains.filter(c => c.requirements.length > 0).length;
const reqsWithSWReqs = traceabilityChains.reduce((acc, c) =>
acc + c.requirements.filter(r => r.swRequirements.length > 0).length, 0);
const swReqsWithTests = traceabilityChains.reduce((acc, c) =>
acc + c.requirements.reduce((acc2, r) =>
acc2 + r.swRequirements.filter(sw => sw.testCases.length > 0).length, 0), 0);
return {
totalFeatures: features.length,
totalRequirements: requirements.length,
totalSWReqs: swReqs.length,
totalTestCases: testCases.length,
featuresWithReqs,
reqsWithSWReqs,
swReqsWithTests,
featureCoverage: features.length ? Math.round((featuresWithReqs / features.length) * 100) : 0,
reqCoverage: requirements.length ? Math.round((reqsWithSWReqs / requirements.length) * 100) : 0,
swReqCoverage: swReqs.length ? Math.round((swReqsWithTests / swReqs.length) * 100) : 0,
};
}, [groupedByType, traceabilityChains]);
const toggleFeature = (id: number) => {
setExpandedFeatures(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const toggleReq = (id: number) => {
setExpandedReqs(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const expandAll = () => {
setExpandedFeatures(new Set(traceabilityChains.map(c => c.feature.id)));
setExpandedReqs(new Set(
traceabilityChains.flatMap(c => c.requirements.map(r => r.requirement.id))
));
};
const collapseAll = () => {
setExpandedFeatures(new Set());
setExpandedReqs(new Set());
};
if (loading) {
return (
<AppLayout>
<div className="flex items-center justify-center h-64">
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
</div>
</AppLayout>
);
}
const handleExportMarkdown = () => {
const markdown = generateTraceabilityMarkdown(traceabilityChains, stats);
downloadMarkdown(markdown, `traceability-matrix-${new Date().toISOString().split('T')[0]}.md`);
};
const handlePrint = () => {
printToPDF();
};
return (
<AppLayout>
<div className="space-y-6 print:space-y-4" id="traceability-matrix-content">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold flex items-center gap-3">
<GitBranch className="h-8 w-8 text-primary" />
Traceability Matrix
</h1>
<p className="text-muted-foreground mt-1">
Complete chain from Features Requirements SW Requirements Test Cases
</p>
</div>
<div className="flex items-center gap-2 print:hidden">
<Button variant="outline" size="sm" onClick={handleExportMarkdown}>
<Download className="h-4 w-4 mr-2" />
Export Markdown
</Button>
<Button variant="outline" size="sm" onClick={handlePrint}>
<Printer className="h-4 w-4 mr-2" />
Print PDF
</Button>
</div>
</div>
{/* Coverage Statistics */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Target className="h-4 w-4 text-purple-500" />
Features
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.totalFeatures}</div>
<p className="text-xs text-muted-foreground">
<span className={getCoverageColor(stats.featureCoverage)}>
{stats.featureCoverage}%
</span>
{" "}linked to requirements
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<CheckSquare className="h-4 w-4 text-blue-500" />
Requirements
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.totalRequirements}</div>
<p className="text-xs text-muted-foreground">
<span className={getCoverageColor(stats.reqCoverage)}>
{stats.reqCoverage}%
</span>
{" "}linked to SW reqs
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<FileText className="h-4 w-4 text-green-500" />
SW Requirements
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.totalSWReqs}</div>
<p className="text-xs text-muted-foreground">
<span className={getCoverageColor(stats.swReqCoverage)}>
{stats.swReqCoverage}%
</span>
{" "}have test cases
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<TestTube className="h-4 w-4 text-amber-500" />
Test Cases
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.totalTestCases}</div>
<p className="text-xs text-muted-foreground">
Total verification tests
</p>
</CardContent>
</Card>
</div>
<Tabs defaultValue="tree" className="w-full">
<TabsList>
<TabsTrigger value="tree" className="gap-2">
<Layers className="h-4 w-4" />
Tree View
</TabsTrigger>
<TabsTrigger value="matrix" className="gap-2">
<GitBranch className="h-4 w-4" />
Matrix View
</TabsTrigger>
</TabsList>
{/* Tree View */}
<TabsContent value="tree" className="mt-4">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Traceability Tree</CardTitle>
<CardDescription>Hierarchical view of requirements traceability</CardDescription>
</div>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 w-64"
/>
</div>
<Button variant="outline" size="sm" onClick={expandAll}>
Expand All
</Button>
<Button variant="outline" size="sm" onClick={collapseAll}>
Collapse All
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<ScrollArea className="h-[600px]">
<div className="space-y-2">
{filteredChains.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<AlertCircle className="h-8 w-8 mx-auto mb-2" />
<p>No traceability chains found</p>
</div>
) : (
filteredChains.map((chain) => (
<Collapsible
key={chain.feature.id}
open={expandedFeatures.has(chain.feature.id)}
onOpenChange={() => toggleFeature(chain.feature.id)}
>
<CollapsibleTrigger asChild>
<div className="flex items-center gap-2 p-3 rounded-lg bg-purple-500/5 border border-purple-500/20 cursor-pointer hover:bg-purple-500/10 transition-colors">
{expandedFeatures.has(chain.feature.id) ? (
<ChevronDown className="h-4 w-4 text-purple-600" />
) : (
<ChevronRight className="h-4 w-4 text-purple-600" />
)}
<Target className="h-4 w-4 text-purple-600" />
<a
href={getWorkPackageUrl(chain.feature.id)}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="font-mono text-xs text-purple-600 hover:underline flex items-center gap-1"
>
#{chain.feature.id}
<ExternalLink className="h-3 w-3" />
</a>
<span className="font-medium flex-1">{chain.feature.title}</span>
<Badge variant="outline" className={cn("text-xs", getStatusColor(chain.feature.status))}>
{chain.feature.status}
</Badge>
<Badge variant="secondary" className="text-xs">
{chain.requirements.length} reqs
</Badge>
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="ml-6 mt-2 space-y-2 border-l-2 border-purple-500/20 pl-4">
{chain.requirements.length === 0 ? (
<div className="text-sm text-muted-foreground italic py-2">
No linked requirements
</div>
) : (
chain.requirements.map((reqChain) => (
<Collapsible
key={reqChain.requirement.id}
open={expandedReqs.has(reqChain.requirement.id)}
onOpenChange={() => toggleReq(reqChain.requirement.id)}
>
<CollapsibleTrigger asChild>
<div className="flex items-center gap-2 p-2 rounded-lg bg-blue-500/5 border border-blue-500/20 cursor-pointer hover:bg-blue-500/10 transition-colors">
{expandedReqs.has(reqChain.requirement.id) ? (
<ChevronDown className="h-4 w-4 text-blue-600" />
) : (
<ChevronRight className="h-4 w-4 text-blue-600" />
)}
<CheckSquare className="h-4 w-4 text-blue-600" />
<a
href={getWorkPackageUrl(reqChain.requirement.id)}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="font-mono text-xs text-blue-600 hover:underline flex items-center gap-1"
>
#{reqChain.requirement.id}
<ExternalLink className="h-3 w-3" />
</a>
<span className="text-sm flex-1">{reqChain.requirement.title}</span>
<Badge variant="outline" className={cn("text-xs", getStatusColor(reqChain.requirement.status))}>
{reqChain.requirement.status}
</Badge>
<Badge variant="secondary" className="text-xs">
{reqChain.swRequirements.length} SWReqs
</Badge>
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="ml-6 mt-2 space-y-2 border-l-2 border-blue-500/20 pl-4">
{reqChain.swRequirements.length === 0 ? (
<div className="text-sm text-muted-foreground italic py-2">
No linked SW requirements
</div>
) : (
reqChain.swRequirements.map((swReqChain) => (
<div key={swReqChain.swReq.id} className="space-y-2">
<div className="flex items-center gap-2 p-2 rounded-lg bg-green-500/5 border border-green-500/20">
<FileText className="h-4 w-4 text-green-600" />
<a
href={getWorkPackageUrl(swReqChain.swReq.id)}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-xs text-green-600 hover:underline flex items-center gap-1"
>
#{swReqChain.swReq.id}
<ExternalLink className="h-3 w-3" />
</a>
<span className="text-sm flex-1">{swReqChain.swReq.title}</span>
<Badge variant="outline" className={cn("text-xs", getStatusColor(swReqChain.swReq.status))}>
{swReqChain.swReq.status}
</Badge>
<Badge variant="secondary" className="text-xs">
{swReqChain.testCases.length} tests
</Badge>
</div>
{swReqChain.testCases.length > 0 && (
<div className="ml-6 space-y-1 border-l-2 border-green-500/20 pl-4">
{swReqChain.testCases.map((tc) => (
<div
key={tc.id}
className="flex items-center gap-2 p-2 rounded-lg bg-amber-500/5 border border-amber-500/20"
>
<TestTube className="h-4 w-4 text-amber-600" />
<a
href={getWorkPackageUrl(tc.id)}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-xs text-amber-600 hover:underline flex items-center gap-1"
>
#{tc.id}
<ExternalLink className="h-3 w-3" />
</a>
<span className="text-sm flex-1">{tc.title}</span>
<Badge variant="outline" className={cn("text-xs", getStatusColor(tc.status))}>
{tc.status}
</Badge>
</div>
))}
</div>
)}
</div>
))
)}
</div>
</CollapsibleContent>
</Collapsible>
))
)}
</div>
</CollapsibleContent>
</Collapsible>
))
)}
</div>
</ScrollArea>
</CardContent>
</Card>
</TabsContent>
{/* Matrix View */}
<TabsContent value="matrix" className="mt-4">
<Card>
<CardHeader>
<CardTitle>Traceability Matrix Table</CardTitle>
<CardDescription>Flat table view showing all traceability links</CardDescription>
</CardHeader>
<CardContent>
<ScrollArea className="h-[600px]">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[200px]">
<div className="flex items-center gap-2">
<Target className="h-4 w-4 text-purple-500" />
Feature
</div>
</TableHead>
<TableHead className="w-[50px]"></TableHead>
<TableHead className="w-[200px]">
<div className="flex items-center gap-2">
<CheckSquare className="h-4 w-4 text-blue-500" />
Requirement
</div>
</TableHead>
<TableHead className="w-[50px]"></TableHead>
<TableHead className="w-[200px]">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-green-500" />
SW Requirement
</div>
</TableHead>
<TableHead className="w-[50px]"></TableHead>
<TableHead className="w-[200px]">
<div className="flex items-center gap-2">
<TestTube className="h-4 w-4 text-amber-500" />
Test Case
</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredChains.flatMap((chain) => {
const rows: React.ReactNode[] = [];
if (chain.requirements.length === 0) {
rows.push(
<TableRow key={`${chain.feature.id}-empty`}>
<TableCell>
<a
href={getWorkPackageUrl(chain.feature.id)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 hover:underline text-purple-600"
>
<span className="font-mono text-xs">#{chain.feature.id}</span>
<ExternalLink className="h-3 w-3" />
</a>
<div className="text-sm truncate max-w-[180px]">{chain.feature.title}</div>
</TableCell>
<TableCell>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</TableCell>
<TableCell colSpan={5} className="text-muted-foreground italic">
No linked requirements
</TableCell>
</TableRow>
);
} else {
chain.requirements.forEach((reqChain, reqIdx) => {
if (reqChain.swRequirements.length === 0) {
rows.push(
<TableRow key={`${chain.feature.id}-${reqChain.requirement.id}-empty`}>
<TableCell>
{reqIdx === 0 && (
<>
<a
href={getWorkPackageUrl(chain.feature.id)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 hover:underline text-purple-600"
>
<span className="font-mono text-xs">#{chain.feature.id}</span>
<ExternalLink className="h-3 w-3" />
</a>
<div className="text-sm truncate max-w-[180px]">{chain.feature.title}</div>
</>
)}
</TableCell>
<TableCell>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</TableCell>
<TableCell>
<a
href={getWorkPackageUrl(reqChain.requirement.id)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 hover:underline text-blue-600"
>
<span className="font-mono text-xs">#{reqChain.requirement.id}</span>
<ExternalLink className="h-3 w-3" />
</a>
<div className="text-sm truncate max-w-[180px]">{reqChain.requirement.title}</div>
</TableCell>
<TableCell>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</TableCell>
<TableCell colSpan={3} className="text-muted-foreground italic">
No linked SW requirements
</TableCell>
</TableRow>
);
} else {
reqChain.swRequirements.forEach((swReqChain, swIdx) => {
if (swReqChain.testCases.length === 0) {
rows.push(
<TableRow key={`${chain.feature.id}-${reqChain.requirement.id}-${swReqChain.swReq.id}-empty`}>
<TableCell>
{reqIdx === 0 && swIdx === 0 && (
<>
<a
href={getWorkPackageUrl(chain.feature.id)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 hover:underline text-purple-600"
>
<span className="font-mono text-xs">#{chain.feature.id}</span>
<ExternalLink className="h-3 w-3" />
</a>
<div className="text-sm truncate max-w-[180px]">{chain.feature.title}</div>
</>
)}
</TableCell>
<TableCell>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</TableCell>
<TableCell>
{swIdx === 0 && (
<>
<a
href={getWorkPackageUrl(reqChain.requirement.id)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 hover:underline text-blue-600"
>
<span className="font-mono text-xs">#{reqChain.requirement.id}</span>
<ExternalLink className="h-3 w-3" />
</a>
<div className="text-sm truncate max-w-[180px]">{reqChain.requirement.title}</div>
</>
)}
</TableCell>
<TableCell>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</TableCell>
<TableCell>
<a
href={getWorkPackageUrl(swReqChain.swReq.id)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 hover:underline text-green-600"
>
<span className="font-mono text-xs">#{swReqChain.swReq.id}</span>
<ExternalLink className="h-3 w-3" />
</a>
<div className="text-sm truncate max-w-[180px]">{swReqChain.swReq.title}</div>
</TableCell>
<TableCell>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</TableCell>
<TableCell className="text-muted-foreground italic">
No test cases
</TableCell>
</TableRow>
);
} else {
swReqChain.testCases.forEach((tc, tcIdx) => {
rows.push(
<TableRow key={`${chain.feature.id}-${reqChain.requirement.id}-${swReqChain.swReq.id}-${tc.id}`}>
<TableCell>
{reqIdx === 0 && swIdx === 0 && tcIdx === 0 && (
<>
<a
href={getWorkPackageUrl(chain.feature.id)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 hover:underline text-purple-600"
>
<span className="font-mono text-xs">#{chain.feature.id}</span>
<ExternalLink className="h-3 w-3" />
</a>
<div className="text-sm truncate max-w-[180px]">{chain.feature.title}</div>
</>
)}
</TableCell>
<TableCell>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</TableCell>
<TableCell>
{swIdx === 0 && tcIdx === 0 && (
<>
<a
href={getWorkPackageUrl(reqChain.requirement.id)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 hover:underline text-blue-600"
>
<span className="font-mono text-xs">#{reqChain.requirement.id}</span>
<ExternalLink className="h-3 w-3" />
</a>
<div className="text-sm truncate max-w-[180px]">{reqChain.requirement.title}</div>
</>
)}
</TableCell>
<TableCell>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</TableCell>
<TableCell>
{tcIdx === 0 && (
<>
<a
href={getWorkPackageUrl(swReqChain.swReq.id)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 hover:underline text-green-600"
>
<span className="font-mono text-xs">#{swReqChain.swReq.id}</span>
<ExternalLink className="h-3 w-3" />
</a>
<div className="text-sm truncate max-w-[180px]">{swReqChain.swReq.title}</div>
</>
)}
</TableCell>
<TableCell>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</TableCell>
<TableCell>
<a
href={getWorkPackageUrl(tc.id)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 hover:underline text-amber-600"
>
<span className="font-mono text-xs">#{tc.id}</span>
<ExternalLink className="h-3 w-3" />
</a>
<div className="text-sm truncate max-w-[180px]">{tc.title}</div>
</TableCell>
</TableRow>
);
});
}
});
}
});
}
return rows;
})}
</TableBody>
</Table>
</ScrollArea>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</AppLayout>
);
}

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

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

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

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

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