second commit

This commit is contained in:
2025-08-05 09:20:41 -04:00
parent 1755c28ecd
commit 21effd183b
105 changed files with 26041 additions and 0 deletions

13
.dockerignore Normal file
View File

@ -0,0 +1,13 @@
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.next
.git
package-lock.json
yarn.lock
pnpm-lock.yaml
test
*.log
local-*

49
.gitignore vendored Normal file
View File

@ -0,0 +1,49 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
local-*
.claude
.z-ai-config
dev.log
test
prompt
server.log

View File

@ -0,0 +1,244 @@
# Android Camera Troubleshooting Guide
This guide provides comprehensive troubleshooting steps for Android camera issues in the QR Inventory Management System.
## Common Android Camera Issues
### 1. Camera Permission Denied
**Symptoms:**
- Camera permission denied error
- Scanner shows "Permission Denied" status
- No camera preview
**Solutions:**
#### Method 1: App Permissions
1. Go to **Settings****Apps****Your Browser** (Chrome, Firefox, etc.)
2. Tap on **Permissions**
3. Enable **Camera** permission
4. Force stop the browser and restart it
#### Method 2: Browser Settings
1. Open your browser
2. Go to browser settings (usually via 3-dot menu)
3. Look for **Site Settings** or **Permissions**
4. Find **Camera** permission
5. Allow camera access for the site
#### Method 3: Site-Specific Permissions
1. Visit your application URL
2. Click the **padlock icon** in the address bar
3. Find **Camera** in the permissions list
4. Change from "Blocked" to "Allowed"
### 2. Camera Not Found
**Symptoms:**
- "No cameras found" error
- Camera Available status shows "No Camera"
- Scanner won't start
**Solutions:**
#### Method 1: Check Physical Camera
1. Ensure camera lens is not blocked
2. Test camera with other apps (Camera app, Instagram, etc.)
3. Restart the device
#### Method 2: Check Browser Support
1. Update your browser to the latest version
2. Try a different browser (Chrome, Firefox, Samsung Internet)
3. Clear browser cache and data
#### Method 3: Check Android Version
- Minimum supported: Android 7.0 (Nougat)
- Recommended: Android 10.0 or later
### 3. Camera Already in Use
**Symptoms:**
- "Camera is already in use" error
- Scanner starts but shows black screen
- Other camera apps work fine
**Solutions:**
#### Method 1: Close Other Apps
1. Close all apps that might be using the camera
2. Check running apps and force stop camera-related apps
3. Restart the browser
#### Method 2: Restart Device
1. Power off the device completely
2. Wait 10 seconds
3. Power back on and try again
### 4. Video Playback Failed
**Symptoms:**
- Camera starts but shows black screen
- "Video playback failed" error
- Scanner shows active but no video
**Solutions:**
#### Method 1: Check Browser Compatibility
1. Use Chrome or Firefox for best compatibility
2. Update browser to latest version
3. Disable hardware acceleration in browser settings
#### Method 2: Adjust Camera Constraints
The application automatically tries different camera constraints. If issues persist:
1. Restart the application
2. Try switching between front and back cameras
3. Use the "Simulate Scan" feature for testing
### 5. QR Code Not Detected
**Symptoms:**
- Camera works but QR codes are not detected
- Scanner shows active but no recognition
- QR codes work in other apps
**Solutions:**
#### Method 1: Improve Scanning Conditions
1. Ensure good lighting on the QR code
2. Hold device steady when scanning
3. Position QR code within the scanning frame
4. Ensure QR code is not damaged or blurry
#### Method 2: Adjust Distance and Angle
1. Hold device 6-12 inches from QR code
2. Keep QR code parallel to camera
3. Avoid glare or reflections on the QR code
#### Method 3: Test Different QR Codes
1. Try scanning different QR codes
2. Use the "Simulate Scan" feature to test the application
3. Generate a new QR code and test
## Device-Specific Solutions
### Samsung Devices
1. Go to **Settings****Apps****(Your Browser)** → **Permissions**
2. Enable all permissions
3. Go to **Settings****Advanced Features****Smart Stay**
4. Disable Smart Stay temporarily
### Google Pixel Devices
1. Go to **Settings****Apps & Notifications****(Your Browser)**
2. Enable camera permission
3. Clear browser cache and data
4. Restart the device
### OnePlus Devices
1. Go to **Settings****Apps****(Your Browser)** → **Permissions**
2. Enable camera permission
3. Go to **Settings****Battery****Battery Optimization**
4. Disable battery optimization for the browser
## Browser-Specific Solutions
### Google Chrome
1. Update Chrome to latest version
2. Go to **Settings****Privacy and Security****Site Settings**
3. Ensure Camera is set to "Allowed"
4. Clear browsing data
### Mozilla Firefox
1. Update Firefox to latest version
2. Go to **Settings****Privacy & Security**
3. Scroll down to **Permissions**
4. Ensure Camera access is allowed
### Samsung Internet
1. Update Samsung Internet
2. Go to **Settings****Sites and Downloads****Sites**
3. Find your site and allow camera access
4. Clear cache and data
## Advanced Troubleshooting
### Enable Debug Mode
The application includes debug logging. To enable:
1. Open browser developer tools (F12)
2. Go to Console tab
3. Look for camera-related log messages
4. Share logs with support if needed
### Test Camera Access Manually
Open browser console and run:
```javascript
// Check if camera is supported
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
console.error('Camera not supported');
} else {
console.log('Camera is supported');
}
// Test camera access
navigator.mediaDevices.getUserMedia({ video: true })
.then(stream => {
console.log('Camera access successful');
stream.getTracks().forEach(track => track.stop());
})
.catch(error => {
console.error('Camera access failed:', error);
});
```
### Check Device Capabilities
```javascript
// Check available cameras
navigator.mediaDevices.enumerateDevices()
.then(devices => {
const cameras = devices.filter(device => device.kind === 'videoinput');
console.log('Available cameras:', cameras);
})
.catch(error => {
console.error('Error enumerating devices:', error);
});
```
## Alternative Solutions
### Use Manual Entry
If camera continues to fail:
1. Use the "Manual Entry" button in the scanner
2. Enter QR code data manually
3. This bypasses camera requirements entirely
### Use Desktop Scanner
For critical operations:
1. Use a desktop computer with webcam
2. Or use a dedicated QR scanner app
3. Import data via Excel/CSV import feature
## Contact Support
If issues persist after trying all solutions:
1. Provide device model and Android version
2. Specify browser and version
3. Share console error logs
4. Describe exact steps to reproduce the issue
## Quick Reference Commands
### Restart Browser
```javascript
location.reload();
```
### Clear Site Data
1. Browser settings → Privacy → Clear browsing data
2. Select "Cached images and files" and "Site data"
### Reset Permissions
1. Browser settings → Site Settings → Camera
2. Find your site and reset permissions
---
This troubleshooting guide should help resolve most Android camera issues. The application is optimized for Android devices with automatic fallbacks and alternative input methods.

234
DEPLOYMENT_CHECKLIST.md Normal file
View File

@ -0,0 +1,234 @@
# QR Inventory Management System - Deployment Checklist
## Pre-Deployment Checklist
### System Requirements
- [ ] Debian 13 VM is ready
- [ ] Minimum 2GB RAM available
- [ ] Minimum 1GB free disk space
- [ ] Port 3000 is available
- [ ] Network connectivity is working
### User Setup
- [ ] Non-root user account created
- [ ] User has sudo privileges
- [ ] SSH access is configured
## Installation Steps
### 1. System Preparation
- [ ] Update system packages: `sudo apt update && sudo apt upgrade -y`
- [ ] Install Node.js 18+: `curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash - && sudo apt-get install -y nodejs`
- [ ] Install Git: `sudo apt install git -y`
- [ ] Install build tools: `sudo apt install build-essential -y`
- [ ] Verify Node.js: `node --version` (should be 18+)
- [ ] Verify npm: `npm --version`
### 2. Project Setup
- [ ] Copy project files to VM
- [ ] Navigate to project directory: `cd /path/to/project`
- [ ] Install dependencies: `npm install`
- [ ] Create .env file with proper configuration
- [ ] Create database directory: `mkdir -p db`
### 3. Database Setup
- [ ] Generate Prisma client: `npm run db:generate`
- [ ] Push database schema: `npm run db:push`
- [ ] Verify database file exists: `ls -la db/custom.db`
### 4. Application Build
- [ ] Build the application: `npm run build`
- [ ] Check for build errors
- [ ] Verify .next directory was created
## Testing Checklist
### 1. Development Mode Test
- [ ] Start development server: `npm run dev`
- [ ] Wait for "Ready on http://0.0.0.0:3000" message
- [ ] Access application at http://localhost:3000
- [ ] Verify all pages load correctly
- [ ] Test QR code generation
- [ ] Test QR code scanning
- [ ] Test inventory management features
### 2. Production Mode Test
- [ ] Stop development server (Ctrl+C)
- [ ] Start production server: `npm start`
- [ ] Access application at http://localhost:3000
- [ ] Verify all features work in production
- [ ] Check for any console errors
### 3. API Health Check
- [ ] Test health endpoint: `curl http://localhost:3000/api/health`
- [ ] Should return: `{"status":"ok","message":"API is running"}`
- [ ] Test inventory API: `curl http://localhost:3000/api/inventory`
## Production Deployment
### 1. System Service Setup (Optional)
- [ ] Copy systemd service file: `sudo cp qr-inventory.service /etc/systemd/system/`
- [ ] Edit service file with correct user and path
- [ ] Reload systemd: `sudo systemctl daemon-reload`
- [ ] Enable service: `sudo systemctl enable qr-inventory`
- [ ] Start service: `sudo systemctl start qr-inventory`
- [ ] Check status: `sudo systemctl status qr-inventory`
### 2. Firewall Configuration
- [ ] Check if UFW is enabled: `sudo ufw status`
- [ ] Allow port 3000: `sudo ufw allow 3000`
- [ ] Or allow HTTP/HTTPS if using reverse proxy
### 3. Reverse Proxy Setup (Optional)
- [ ] Install Nginx: `sudo apt install nginx -y`
- [ ] Create Nginx configuration file
- [ ] Enable the site: `sudo ln -s /etc/nginx/sites-available/qr-inventory /etc/nginx/sites-enabled/`
- [ ] Test Nginx config: `sudo nginx -t`
- [ ] Restart Nginx: `sudo systemctl restart nginx`
### 4. SSL Certificate (Optional)
- [ ] Install Certbot: `sudo apt install certbot python3-certbot-nginx -y`
- [ ] Obtain certificate: `sudo certbot --nginx -d your-domain.com`
- [ ] Test auto-renewal: `sudo certbot renew --dry-run`
## Network Access
### 1. Local Access
- [ ] Access via localhost: http://localhost:3000
- [ ] Verify all features work
### 2. Network Access
- [ ] Find VM IP address: `hostname -I`
- [ ] Access via IP: http://<vm-ip>:3000
- [ ] Test from different machines on network
### 3. Domain Access (if configured)
- [ ] Access via domain: http://your-domain.com
- [ ] Test HTTPS: https://your-domain.com
## Security Checklist
### 1. Basic Security
- [ ] Change default passwords if any
- [ ] Update system regularly
- [ ] Use SSH key authentication
- [ ] Disable root SSH login
### 2. Application Security
- [ ] Use HTTPS in production
- [ ] Set up proper environment variables
- [ ] Don't commit sensitive data to git
- [ ] Use strong database credentials
### 3. Network Security
- [ ] Configure firewall properly
- [ ] Only expose necessary ports
- [ ] Use fail2ban if needed
- [ ] Monitor access logs
## Maintenance Checklist
### 1. Backup Setup
- [ ] Set up database backups
- [ ] Test backup restoration
- [ ] Set up off-site backups
- [ ] Document backup procedure
### 2. Monitoring
- [ ] Set up log monitoring
- [ ] Configure log rotation
- [ ] Set up alerts for downtime
- [ ] Monitor disk space
### 3. Updates
- [ ] Regular system updates: `sudo apt update && sudo apt upgrade`
- [ ] Update npm packages: `npm update`
- [ ] Check for security updates
- [ ] Test updates before deployment
## Troubleshooting
### 1. Common Issues
- [ ] Port conflicts: Check with `sudo lsof -i :3000`
- [ ] Permission issues: Check file ownership
- [ ] Database issues: Check database file permissions
- [ ] Build errors: Clear .next directory and rebuild
### 2. Log Files
- [ ] Application logs: `tail -f server.log`
- [ ] Development logs: `tail -f dev.log`
- [ ] System service logs: `sudo journalctl -u qr-inventory`
- [ ] Nginx logs: `sudo tail -f /var/log/nginx/access.log`
### 3. Health Checks
- [ ] API health: `curl http://localhost:3000/api/health`
- [ ] Database connectivity: Check if db/custom.db exists
- [ ] Service status: `sudo systemctl status qr-inventory`
## Final Verification
### 1. Full Application Test
- [ ] Add inventory item
- [ ] Generate QR code
- [ ] Scan QR code
- [ ] Update quantity
- [ ] Print labels
- [ ] Import/export data
- [ ] Test on mobile device
### 2. Performance Test
- [ ] Check page load times
- [ ] Test with multiple users
- [ ] Monitor memory usage
- [ ] Check database performance
### 3. Documentation
- [ ] Update setup documentation
- [ ] Document any custom configurations
- [ ] Create user guide
- [ ] Document backup procedures
---
## Quick Start Commands
```bash
# Update system
sudo apt update && sudo apt upgrade -y
# Install Node.js
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt-get install -y nodejs
# Install dependencies with legacy peer deps for React 19 compatibility
npm install --legacy-peer-deps
# Setup database
npm run db:generate
npm run db:push
# Build and run
npm run build
npm start
```
## Emergency Commands
```bash
# Stop the application
sudo systemctl stop qr-inventory
# Restart the application
sudo systemctl restart qr-inventory
# Check logs
sudo journalctl -u qr-inventory -f
# Reset database
rm -f db/custom.db
npm run db:push
# Clean build
rm -rf .next
npm run build
```

416
SETUP_GUIDE.md Normal file
View File

@ -0,0 +1,416 @@
# QR Inventory Management System - Setup Guide for Debian 13
This guide will help you set up and run the QR Inventory Management System on your Debian 13 VM.
## System Requirements
- **OS**: Debian 13 (Trixie)
- **Node.js**: Version 18 or higher
- **Memory**: Minimum 2GB RAM (4GB recommended)
- **Storage**: Minimum 1GB free space
- **Network**: Port 3000 accessible
## Prerequisites Installation
### 1. Update System Packages
```bash
sudo apt update
sudo apt upgrade -y
```
### 2. Install Node.js and npm
```bash
# Install Node.js 18 LTS using NodeSource repository
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt-get install -y nodejs
# Verify installation
node --version
npm --version
```
### 3. Install Git (if not already installed)
```bash
sudo apt install git -y
```
### 4. Install Build Tools (required for some npm packages)
```bash
sudo apt install build-essential -y
```
## Project Setup
### 1. Clone or Copy the Project
If you have the project in a git repository:
```bash
git clone <your-repository-url>
cd qr-inventory-system
```
If you're copying the files directly:
```bash
# Copy your project files to the VM
# Navigate to the project directory
cd /path/to/your/project
```
### 2. Install Dependencies
```bash
# Install dependencies with legacy peer deps for React 19 compatibility
npm install --legacy-peer-deps
```
### 3. Set Up Environment Variables
Create a `.env` file in the project root:
```bash
cp .env.example .env 2>/dev/null || touch .env
```
Edit the `.env` file:
```bash
nano .env
```
Add the following configuration:
```env
# Database Configuration
DATABASE_URL="file:./db/custom.db"
# Application Configuration
NODE_ENV="development"
PORT=3000
HOSTNAME="0.0.0.0"
# Optional: Add your domain if you have one
# NEXTAUTH_URL="http://your-domain.com"
```
### 4. Set Up Database
```bash
# Create database directory if it doesn't exist
mkdir -p db
# Generate Prisma client
npm run db:generate
# Push database schema
npm run db:push
```
## Running the Application
### Development Mode
For development with hot-reload:
```bash
npm run dev
```
The application will be available at:
- **Local**: `http://localhost:3000`
- **Network**: `http://<your-vm-ip>:3000`
### Production Mode
For production deployment:
```bash
# Build the application
npm run build
# Start the production server
npm start
```
## System Service Setup (Optional)
To run the application as a system service that starts automatically:
### 1. Create a Systemd Service File
```bash
sudo nano /etc/systemd/system/qr-inventory.service
```
Add the following content:
```ini
[Unit]
Description=QR Inventory Management System
After=network.target
[Service]
Type=simple
User=your-username
WorkingDirectory=/path/to/your/project
ExecStart=/usr/bin/npm start
Restart=always
RestartSec=10
Environment=NODE_ENV=production
Environment=PORT=3000
Environment=HOSTNAME=0.0.0.0
[Install]
WantedBy=multi-user.target
```
### 2. Enable and Start the Service
```bash
# Reload systemd
sudo systemctl daemon-reload
# Enable the service to start on boot
sudo systemctl enable qr-inventory
# Start the service
sudo systemctl start qr-inventory
# Check service status
sudo systemctl status qr-inventory
```
### 3. View Logs
```bash
# View service logs
sudo journalctl -u qr-inventory -f
# Or view the application log
tail -f server.log
```
## Firewall Configuration
If you have UFW (Uncomplicated Firewall) enabled:
```bash
# Allow port 3000
sudo ufw allow 3000
# Enable firewall if not already enabled
sudo ufw enable
# Check firewall status
sudo ufw status
```
## Nginx Reverse Proxy (Optional)
For better security and to serve the application on a domain:
### 1. Install Nginx
```bash
sudo apt install nginx -y
```
### 2. Create Nginx Configuration
```bash
sudo nano /etc/nginx/sites-available/qr-inventory
```
Add the following configuration:
```nginx
server {
listen 80;
server_name your-domain.com www.your-domain.com;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
```
### 3. Enable the Site
```bash
# Create symbolic link
sudo ln -s /etc/nginx/sites-available/qr-inventory /etc/nginx/sites-enabled/
# Test Nginx configuration
sudo nginx -t
# Restart Nginx
sudo systemctl restart nginx
```
## SSL Certificate (Optional)
For HTTPS with Let's Encrypt:
### 1. Install Certbot
```bash
sudo apt install certbot python3-certbot-nginx -y
```
### 2. Obtain SSL Certificate
```bash
sudo certbot --nginx -d your-domain.com -d www.your-domain.com
```
### 3. Auto-renewal Setup
```bash
# Test auto-renewal
sudo certbot renew --dry-run
# Auto-renewal is already set up by certbot
```
## Application Features
Once running, the application provides:
- **Inventory Management**: Add, edit, delete inventory items
- **QR Code Generation**: Generate QR codes for inventory items
- **QR Code Scanning**: Scan QR codes to update inventory
- **Excel/CSV Import**: Import inventory data from spreadsheets
- **Label Printing**: Print labels for inventory items
- **Real-time Updates**: Live inventory updates via WebSocket
- **Responsive Design**: Works on desktop and mobile devices
## Accessing the Application
1. **Local Access**: `http://localhost:3000`
2. **Network Access**: `http://<your-vm-ip>:3000`
3. **Domain Access**: `http://your-domain.com` (if configured)
## Troubleshooting
### Common Issues
#### 1. Port 3000 is already in use
```bash
# Find process using port 3000
sudo lsof -i :3000
# Kill the process
sudo kill -9 <process-id>
```
#### 2. Permission denied errors
```bash
# Fix file permissions
sudo chown -R your-username:your-username /path/to/your/project
chmod -R 755 /path/to/your/project
```
#### 3. Database connection issues
```bash
# Check if database file exists
ls -la db/custom.db
# Recreate database
rm -f db/custom.db
npm run db:push
```
#### 4. Build errors
```bash
# Clean build
rm -rf .next
npm run build
```
### Log Files
- **Development logs**: `dev.log`
- **Production logs**: `server.log`
- **System service logs**: `sudo journalctl -u qr-inventory`
### Health Check
The application provides a health check endpoint:
```bash
curl http://localhost:3000/api/health
```
## Maintenance
### Database Backup
```bash
# Backup database
cp db/custom.db db/custom.db.backup.$(date +%Y%m%d)
# Restore database
cp db/custom.db.backup db/custom.db
```
### Application Updates
```bash
# Pull latest changes (if using git)
git pull origin main
# Install new dependencies
npm install
# Rebuild for production
npm run build
# Restart service
sudo systemctl restart qr-inventory
```
### Log Rotation
To prevent log files from growing too large:
```bash
# Create logrotate configuration
sudo nano /etc/logrotate.d/qr-inventory
```
Add the following content:
```
/path/to/your/project/server.log {
daily
missingok
rotate 7
compress
delaycompress
notifempty
create 644 your-username your-username
}
```
## Security Considerations
1. **Firewall**: Only expose necessary ports (3000 or 80/443 with Nginx)
2. **Updates**: Keep system and packages updated
3. **Backups**: Regular database backups
4. **SSL**: Use HTTPS in production
5. **Authentication**: Implement user authentication (NextAuth.js is included)
## Support
If you encounter any issues:
1. Check the logs for error messages
2. Verify all prerequisites are installed
3. Ensure the database is properly configured
4. Check network connectivity and firewall settings
---
This setup guide should help you successfully deploy and run the QR Inventory Management System on your Debian 13 VM.

21
components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

0
db/custom.db Normal file
View File

25
eslint.config.mjs Normal file
View File

@ -0,0 +1,25 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
rules: {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": "off",
"react-hooks/exhaustive-deps": "off",
"react/no-unescaped-entities": "off",
"@next/next/no-img-element": "off",
},
},
];
export default eslintConfig;

124
examples/websocket/page.tsx Normal file
View File

@ -0,0 +1,124 @@
'use client';
import { useEffect, useState } from 'react';
import { io } from 'socket.io-client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
type Message = {
text: string;
senderId: string;
timestamp: string;
}
export default function SocketDemo() {
const [messages, setMessages] = useState<Message[]>([]);
const [inputMessage, setInputMessage] = useState('');
const [socket, setSocket] = useState<any>(null);
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
const socketInstance = io({
path: '/api/socketio',
});
setSocket(socketInstance);
socketInstance.on('connect', () => {
setIsConnected(true);
});
socketInstance.on('disconnect', () => {
setIsConnected(false);
});
socketInstance.on('message', (msg: Message) => {
setMessages(prev => [...prev, msg]);
});
return () => {
socketInstance.disconnect();
};
}, []);
const sendMessage = () => {
if (socket && inputMessage.trim()) {
setMessages(prev => [...prev, {
text: inputMessage.trim(),
senderId: socket.id || 'user',
timestamp: new Date().toISOString()
}]);
socket.emit('message', {
text: inputMessage.trim(),
senderId: socket.id || 'user',
timestamp: new Date().toISOString()
});
setInputMessage('');
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
sendMessage();
}
};
return (
<div className="container mx-auto p-4 max-w-2xl">
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
WebSocket Demo
<span className={`text-sm px-2 py-1 rounded ${isConnected ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
{isConnected ? 'Connected' : 'Disconnected'}
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<ScrollArea className="h-80 w-full border rounded-md p-4">
<div className="space-y-2">
{messages.length === 0 ? (
<p className="text-gray-500 text-center">No messages yet</p>
) : (
messages.map((msg, index) => (
<div key={index} className="border-b pb-2 last:border-b-0">
<div className="flex justify-between items-start">
<div className="flex-1">
<p className="text-sm font-medium text-gray-700">
{msg.senderId}
</p>
<p className="text-gray-900">{msg.text}</p>
</div>
<span className="text-xs text-gray-500">
{new Date(msg.timestamp).toLocaleTimeString()}
</span>
</div>
</div>
))
)}
</div>
</ScrollArea>
<div className="flex space-x-2">
<Input
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Type a message..."
disabled={!isConnected}
className="flex-1"
/>
<Button
onClick={sendMessage}
disabled={!isConnected || !inputMessage.trim()}
>
Send
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

35
next.config.ts Normal file
View File

@ -0,0 +1,35 @@
// next.config.ts
/** @type {import('next').NextConfig} */
const nextConfig = {
async headers() {
return [
{
source: '/(.*)',
headers: [
{
key: 'X-Frame-Options',
value: 'SAMEORIGIN'
},
{
key: 'X-Content-Type-Options',
value: 'nosniff'
},
{
key: 'Referrer-Policy',
value: 'origin-when-cross-origin'
},
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=()'
},
{
key: 'Content-Security-Policy',
value: "default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https:; font-src 'self' data:;"
}
]
}
]
}
}
module.exports = nextConfig

14221
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

118
package.json Normal file
View File

@ -0,0 +1,118 @@
{
"name": "nextjs_tailwind_shadcn_ts",
"version": "0.1.0",
"private": true,
"scripts": {
"postinstall": "prisma generate && prisma db push",
"dev": "prisma db push && nodemon --exec \"npx tsx server.ts\" --watch server.ts --watch src --ext ts,tsx,js,jsx 2>&1 | tee dev.log",
"build": "next build",
"start": "NODE_ENV=production tsx server.ts 2>&1 | tee server.log",
"lint": "next lint",
"db:push": "prisma db push",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:reset": "prisma migrate reset",
"db:seed": "prisma db seed"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.1.1",
"@mdxeditor/editor": "^3.39.1",
"@prisma/client": "^6.11.1",
"@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",
"@reactuses/core": "^6.0.5",
"@tanstack/react-query": "^5.82.0",
"@tanstack/react-table": "^8.21.3",
"@types/html2canvas": "^0.5.35",
"@types/qrcode": "^1.5.5",
"axios": "^1.10.0",
"bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"cors": "^2.8.5",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.23.2",
"helmet": "^8.1.0",
"html2canvas": "^1.4.1",
"input-otp": "^1.4.2",
"jsonwebtoken": "^9.0.2",
"jsqr": "^1.4.0",
"lucide-react": "^0.525.0",
"next": "15.3.5",
"next-auth": "^4.24.11",
"next-intl": "^4.3.4",
"next-themes": "^0.4.6",
"prisma": "^6.11.1",
"qrcode": "^1.5.4",
"react": "^19.0.0",
"react-day-picker": "^9.8.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.60.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^3.0.3",
"react-syntax-highlighter": "^15.6.1",
"recharts": "^2.15.4",
"sharp": "^0.34.3",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"sonner": "^2.0.6",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"tsx": "^4.20.3",
"uuid": "^11.1.0",
"vaul": "^1.1.2",
"xlsx": "^0.18.5",
"z-ai-web-dev-sdk": "^0.0.10",
"zod": "^4.0.2",
"zustand": "^5.0.6"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"autoprefixer": "^10.4.21",
"eslint": "^9",
"eslint-config-next": "15.3.5",
"nodemon": "^3.1.10",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"tw-animate-css": "^1.3.5",
"typescript": "^5"
}
}

8
postcss.config.mjs Normal file
View File

@ -0,0 +1,8 @@
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;

BIN
prisma/db/custom.db Normal file

Binary file not shown.

0
prisma/dev.db Normal file
View File

View File

@ -0,0 +1,35 @@
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL PRIMARY KEY,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'USER',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "inventory_items" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"description" TEXT,
"sku" TEXT NOT NULL,
"quantity" INTEGER NOT NULL DEFAULT 0,
"qrCode" TEXT NOT NULL,
"category" TEXT,
"location" TEXT,
"price" REAL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "inventory_items_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "inventory_items_sku_key" ON "inventory_items"("sku");
-- CreateIndex
CREATE UNIQUE INDEX "inventory_items_qrCode_key" ON "inventory_items"("qrCode");

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"

53
prisma/schema.prisma Normal file
View File

@ -0,0 +1,53 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
//prisma/schema.prisma (add to existing)
model User {
id String @id @default(cuid())
email String @unique
password String // Will be hashed
role Role @default(USER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
inventoryItems InventoryItem[]
@@map("users")
}
model InventoryItem {
id String @id @default(cuid())
name String
description String?
sku String @unique
quantity Int @default(0)
qrCode String @unique
category String?
location String?
price Float?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String
user User @relation(fields: [userId], references: [id])
@@map("inventory_items")
}
enum Role {
ADMIN
MANAGER
USER
}

25
prisma/seed.ts Normal file
View File

@ -0,0 +1,25 @@
import { PrismaClient } from '@prisma/client'
import { hash } from 'bcryptjs'
const prisma = new PrismaClient()
async function main() {
const password = await hash('password', 10)
const user = await prisma.user.create({
data: {
email: 'test@example.com',
password,
},
})
console.log({ user })
}
main()
.then(async () => {
await prisma.$disconnect()
})
.catch(async (e) => {
console.error(e)
await prisma.$disconnect()
process.exit(1)
})

118
public/logo.svg Normal file
View File

@ -0,0 +1,118 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 2000 1700">
<defs>
<style>
.cls-1 {
fill: url(#gradient1);
}
.cls-2 {
fill: url(#gradient2);
}
.cls-3 {
fill: url(#gradient3);
}
.glow {
filter: url(#glow-filter);
}
.pulse {
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
@keyframes shimmer {
0% { transform: translateX(-200%); }
100% { transform: translateX(200%); }
}
.shimmer {
animation: shimmer 3s ease-in-out infinite;
}
</style>
<!-- 发光滤镜 -->
<filter id="glow-filter" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="8" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<!-- 动态渐变 -->
<linearGradient id="gradient1" x1="531.58" y1="-4.02" x2="531.58" y2="1661.41" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#070606">
<animate attributeName="stop-color" values="#070606;#5a5a5a;#070606" dur="1.8s" repeatCount="indefinite"/>
</stop>
<stop offset=".37" stop-color="#1b1b1b">
<animate attributeName="stop-color" values="#1b1b1b;#888888;#1b1b1b" dur="1.8s" repeatCount="indefinite"/>
</stop>
<stop offset="1" stop-color="#3b3b3b">
<animate attributeName="stop-color" values="#3b3b3b;#cccccc;#3b3b3b" dur="1.8s" repeatCount="indefinite"/>
</stop>
</linearGradient>
<linearGradient id="gradient2" x1="1000" y1="-4.02" x2="1000" y2="1661.41" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#070606">
<animate attributeName="stop-color" values="#070606;#5a5a5a;#070606" dur="1.8s" repeatCount="indefinite" begin="0.6s"/>
</stop>
<stop offset=".37" stop-color="#1b1b1b">
<animate attributeName="stop-color" values="#1b1b1b;#888888;#1b1b1b" dur="1.8s" repeatCount="indefinite" begin="0.6s"/>
</stop>
<stop offset="1" stop-color="#3b3b3b">
<animate attributeName="stop-color" values="#3b3b3b;#cccccc;#3b3b3b" dur="1.8s" repeatCount="indefinite" begin="0.6s"/>
</stop>
</linearGradient>
<linearGradient id="gradient3" x1="1462.04" y1="-4.02" x2="1462.04" y2="1661.41" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#070606">
<animate attributeName="stop-color" values="#070606;#5a5a5a;#070606" dur="1.8s" repeatCount="indefinite" begin="1.2s"/>
</stop>
<stop offset=".37" stop-color="#1b1b1b">
<animate attributeName="stop-color" values="#1b1b1b;#888888;#1b1b1b" dur="1.8s" repeatCount="indefinite" begin="1.2s"/>
</stop>
<stop offset="1" stop-color="#3b3b3b">
<animate attributeName="stop-color" values="#3b3b3b;#cccccc;#3b3b3b" dur="1.8s" repeatCount="indefinite" begin="1.2s"/>
</stop>
</linearGradient>
<!-- 光晕渐变 -->
<radialGradient id="glow-gradient" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.3"/>
<stop offset="70%" stop-color="#888888" stop-opacity="0.1"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</radialGradient>
<!-- 闪光效果 -->
<linearGradient id="shine-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="45%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.8"/>
<stop offset="55%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
<!-- 裁切路径 -->
<clipPath id="shape-clip">
<polygon points="1008.73 0 827.29 251.03 54.43 251.03 235.74 0 1008.73 0"/>
<polygon points="1937.79 1449.1 1756.47 1700 986.3 1700 1167.48 1449.1 1937.79 1449.1"/>
<polygon points="2000 0 771.98 1700 0 1700 1228.02 0 2000 0"/>
</clipPath>
</defs>
<g class="glow breathe">
<g class="pulse wave-effect">
<!-- 主要几何形状 -->
<polygon class="cls-1" points="1008.73 0 827.29 251.03 54.43 251.03 235.74 0 1008.73 0"/>
<polygon class="cls-3" points="1937.79 1449.1 1756.47 1700 986.3 1700 1167.48 1449.1 1937.79 1449.1"/>
<polygon class="cls-2" points="2000 0 771.98 1700 0 1700 1228.02 0 2000 0"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.6 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: /

17
qr-inventory.service Normal file
View File

@ -0,0 +1,17 @@
[Unit]
Description=QR Inventory Management System
After=network.target
[Service]
Type=simple
User=your-username
WorkingDirectory=/path/to/your/project
ExecStart=/usr/bin/npm start
Restart=always
RestartSec=10
Environment=NODE_ENV=production
Environment=PORT=3000
Environment=HOSTNAME=0.0.0.0
[Install]
WantedBy=multi-user.target

58
server.ts Normal file
View File

@ -0,0 +1,58 @@
// server.ts - Next.js Standalone + Socket.IO
import { setupSocket } from '@/lib/socket';
import { createServer } from 'http';
import { Server } from 'socket.io';
import next from 'next';
const dev = process.env.NODE_ENV !== 'production';
const currentPort = 3000;
const hostname = '0.0.0.0';
// Custom server with Socket.IO integration
async function createCustomServer() {
try {
// Create Next.js app
const nextApp = next({
dev,
dir: process.cwd(),
// In production, use the current directory where .next is located
conf: dev ? undefined : { distDir: './.next' }
});
await nextApp.prepare();
const handle = nextApp.getRequestHandler();
// Create HTTP server that will handle both Next.js and Socket.IO
const server = createServer((req, res) => {
// Skip socket.io requests from Next.js handler
if (req.url?.startsWith('/api/socketio')) {
return;
}
handle(req, res);
});
// Setup Socket.IO
const io = new Server(server, {
path: '/api/socketio',
cors: {
origin: "*",
methods: ["GET", "POST"]
}
});
setupSocket(io);
// Start the server
server.listen(currentPort, hostname, () => {
console.log(`> Ready on http://${hostname}:${currentPort}`);
console.log(`> Socket.IO server running at ws://${hostname}:${currentPort}/api/socketio`);
});
} catch (err) {
console.error('Server startup error:', err);
process.exit(1);
}
}
// Start the server
createCustomServer();

108
setup.sh Executable file
View File

@ -0,0 +1,108 @@
#!/bin/bash
# QR Inventory Management System - Quick Setup Script for Debian 13
# This script automates the setup process
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Function to print colored output
print_status() {
echo -e "${GREEN}[INFO]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Check if running as root
if [[ $EUID -eq 0 ]]; then
print_error "This script should not be run as root. Please run as a regular user."
exit 1
fi
# Update system
print_status "Updating system packages..."
sudo apt update
sudo apt upgrade -y
# Install Node.js
print_status "Installing Node.js 18 LTS..."
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt-get install -y nodejs
# Verify Node.js installation
if ! command -v node &> /dev/null; then
print_error "Node.js installation failed"
exit 1
fi
print_status "Node.js $(node --version) installed successfully"
# Install Git
print_status "Installing Git..."
sudo apt install git -y
# Install build tools
print_status "Installing build tools..."
sudo apt install build-essential -y
# Install project dependencies with legacy peer deps to handle React 19 compatibility
print_status "Installing project dependencies..."
npm install --legacy-peer-deps
# Create .env file if it doesn't exist
if [ ! -f .env ]; then
print_status "Creating .env file..."
cat > .env << EOF
# Database Configuration
DATABASE_URL="file:./db/custom.db"
# Application Configuration
NODE_ENV="development"
PORT=3000
HOSTNAME="0.0.0.0"
EOF
print_status ".env file created with default settings"
else
print_warning ".env file already exists, skipping creation"
fi
# Create database directory
print_status "Setting up database..."
mkdir -p db
# Generate Prisma client
print_status "Generating Prisma client..."
npm run db:generate
# Push database schema
print_status "Setting up database schema..."
npm run db:push
# Build the application
print_status "Building the application..."
npm run build
print_status "Setup completed successfully!"
print_status "You can now run the application with:"
echo -e "${GREEN}npm start${NC} (for production)"
echo -e "${GREEN}npm run dev${NC} (for development)"
print_status "The application will be available at http://localhost:3000"
# Ask if user wants to run the application now
read -p "Do you want to start the application now? (y/n): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
print_status "Starting the application..."
npm start
fi

View File

@ -0,0 +1,58 @@
// src/app/api/auth/login/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { compare } from 'bcryptjs'
import { sign } from 'jsonwebtoken'
import { db } from '@/lib/db'
import { z } from 'zod'
const loginSchema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(1, 'Password is required')
})
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { email, password } = loginSchema.parse(body)
// Find user
const user = await db.user.findUnique({
where: { email }
})
if (!user || !await compare(password, user.password)) {
return NextResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 }
)
}
// Generate JWT token
const token = sign(
{ userId: user.id, email: user.email, role: user.role },
process.env.NEXTAUTH_SECRET || 'fallback-secret',
{ expiresIn: '24h' }
)
// Remove password from response
const { password: _, ...userWithoutPassword } = user
return NextResponse.json({
user: userWithoutPassword,
token
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation failed', details: error.issues },
{ status: 400 }
)
}
console.error('Login error:', error)
return NextResponse.json(
{ error: 'Login failed' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,58 @@
// src/app/api/auth/register/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { hash } from 'bcryptjs'
import { db } from '@/lib/db'
import { z } from 'zod'
const registerSchema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Password must be at least 8 characters')
})
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { email, password } = registerSchema.parse(body)
// Check if user already exists
const existingUser = await db.user.findUnique({
where: { email }
})
if (existingUser) {
return NextResponse.json(
{ error: 'User already exists' },
{ status: 400 }
)
}
// Hash password
const hashedPassword = await hash(password, 12)
// Create user
const user = await db.user.create({
data: {
email,
password: hashedPassword
}
})
// Remove password from response
const { password: _, ...userWithoutPassword } = user
return NextResponse.json(userWithoutPassword, { status: 201 })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation failed', details: error.issues },
{ status: 400 }
)
}
console.error('Registration error:', error)
return NextResponse.json(
{ error: 'Registration failed' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,5 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({ message: "Good!" });
}

View File

@ -0,0 +1,128 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { z } from 'zod'
const updateItemSchema = z.object({
name: z.string().min(1, 'Name is required').optional(),
description: z.string().optional(),
sku: z.string().min(1, 'SKU is required').optional(),
quantity: z.number().int().min(0, 'Quantity must be non-negative').optional(),
category: z.string().optional(),
location: z.string().optional(),
price: z.number().optional(),
})
export async function GET(
request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const params = await context.params
const item = await db.inventoryItem.findUnique({
where: { id: params.id }
})
if (!item) {
return NextResponse.json(
{ error: 'Inventory item not found' },
{ status: 404 }
)
}
return NextResponse.json(item)
} catch (error) {
console.error('Error fetching inventory item:', error)
return NextResponse.json(
{ error: 'Failed to fetch inventory item' },
{ status: 500 }
)
}
}
export async function PUT(
request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const params = await context.params
const body = await request.json()
const validatedData = updateItemSchema.parse(body)
// Check if item exists
const existingItem = await db.inventoryItem.findUnique({
where: { id: params.id }
})
if (!existingItem) {
return NextResponse.json(
{ error: 'Inventory item not found' },
{ status: 404 }
)
}
// If SKU is being updated, check if it already exists
if (validatedData.sku && validatedData.sku !== existingItem.sku) {
const skuExists = await db.inventoryItem.findUnique({
where: { sku: validatedData.sku }
})
if (skuExists) {
return NextResponse.json(
{ error: 'SKU already exists' },
{ status: 400 }
)
}
}
const item = await db.inventoryItem.update({
where: { id: params.id },
data: validatedData,
})
return NextResponse.json(item)
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation failed', details: error.issues },
{ status: 400 }
)
}
console.error('Error updating inventory item:', error)
return NextResponse.json(
{ error: 'Failed to update inventory item' },
{ status: 500 }
)
}
}
export async function DELETE(
request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const params = await context.params
const existingItem = await db.inventoryItem.findUnique({
where: { id: params.id }
})
if (!existingItem) {
return NextResponse.json(
{ error: 'Inventory item not found' },
{ status: 404 }
)
}
await db.inventoryItem.delete({
where: { id: params.id }
})
return NextResponse.json({ message: 'Inventory item deleted successfully' })
} catch (error) {
console.error('Error deleting inventory item:', error)
return NextResponse.json(
{ error: 'Failed to delete inventory item' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
import QRCode from 'qrcode'
import { z } from 'zod'
const generateQRSchema = z.object({
itemId: z.string().min(1, 'Item ID is required'),
size: z.number().int().min(100).max(1000).default(200),
})
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { itemId, size } = generateQRSchema.parse(body)
// Find inventory item
const item = await db.inventoryItem.findUnique({
where: { id: itemId }
})
if (!item) {
return NextResponse.json(
{ error: 'Inventory item not found' },
{ status: 404 }
)
}
// Generate QR code as base64
const qrCodeBase64 = await QRCode.toDataURL(item.qrCode, {
width: size,
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF'
}
})
return NextResponse.json({
qrCode: qrCodeBase64,
item: {
id: item.id,
name: item.name,
sku: item.sku,
qrCode: item.qrCode,
}
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation failed', details: error.issues },
{ status: 400 }
)
}
console.error('Error generating QR code:', error)
return NextResponse.json(
{ error: 'Failed to generate QR code' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,183 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
import * as XLSX from 'xlsx'
import { z } from 'zod'
import { authenticateRequest } from '@/lib/server-auth'
const importItemSchema = z.object({
name: z.string().min(1, 'Name is required'),
description: z.string().optional(),
sku: z.string().min(1, 'SKU is required'),
quantity: z.number().int().min(0, 'Quantity must be non-negative').default(0),
category: z.string().optional(),
location: z.string().optional(),
price: z.number().optional(),
})
export async function POST(request: NextRequest) {
try {
// Add authentication check
const auth = await authenticateRequest(request)
if (!auth.success) {
return NextResponse.json({ error: auth.error }, { status: 401 })
}
const formData = await request.formData()
const file = formData.get('file') as File
if (!file) {
return NextResponse.json(
{ error: 'No file provided' },
{ status: 400 }
)
}
// Check file type
const allowedTypes = [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
'application/vnd.ms-excel', // .xls
'text/csv', // .csv
]
if (!allowedTypes.includes(file.type)) {
return NextResponse.json(
{ error: 'Invalid file type. Please upload a CSV or Excel file.' },
{ status: 400 }
)
}
// Read file
const buffer = await file.arrayBuffer()
const workbook = XLSX.read(buffer, { type: 'buffer' })
const sheetName = workbook.SheetNames[0]
const worksheet = workbook.Sheets[sheetName]
const data = XLSX.utils.sheet_to_json(worksheet)
if (!Array.isArray(data) || data.length === 0) {
return NextResponse.json(
{ error: 'No data found in the file' },
{ status: 400 }
)
}
const results: {
success: Array<{ row: number; item: { id: string; name: string; sku: string; quantity: number } }>
errors: Array<{ row: number; error: string }>
} = {
success: [],
errors: [],
}
// Process each row
for (let i = 0; i < data.length; i++) {
const row = data[i] as any
const rowNum = i + 2 // Excel rows are 1-indexed and header is row 1
try {
// Map column names (case-insensitive)
const mappedRow: any = {}
Object.keys(row).forEach(key => {
const lowerKey = key.toLowerCase().trim()
if (lowerKey.includes('name')) mappedRow.name = row[key]
else if (lowerKey.includes('description')) mappedRow.description = row[key]
else if (lowerKey.includes('sku')) mappedRow.sku = row[key]
else if (lowerKey.includes('quantity')) mappedRow.quantity = row[key]
else if (lowerKey.includes('category')) mappedRow.category = row[key]
else if (lowerKey.includes('location')) mappedRow.location = row[key]
else if (lowerKey.includes('price')) mappedRow.price = row[key]
})
// Validate required fields
if (!mappedRow.name || !mappedRow.sku) {
results.errors.push({
row: rowNum,
error: 'Missing required fields: name and sku are required'
})
continue
}
// Convert quantity to number
if (mappedRow.quantity !== undefined) {
const qty = Number(mappedRow.quantity)
if (isNaN(qty)) {
results.errors.push({
row: rowNum,
error: 'Invalid quantity value'
})
continue
}
mappedRow.quantity = qty
} else {
mappedRow.quantity = 0
}
// Convert price to number
if (mappedRow.price !== undefined) {
const price = Number(mappedRow.price)
if (isNaN(price)) {
mappedRow.price = undefined
} else {
mappedRow.price = price
}
}
// Validate with schema
const validatedData = importItemSchema.parse(mappedRow)
// Check if SKU already exists
const existingItem = await db.inventoryItem.findUnique({
where: { sku: validatedData.sku }
})
if (existingItem) {
results.errors.push({
row: rowNum,
error: `SKU '${validatedData.sku}' already exists`
})
continue
}
// Generate QR code
const qrCode = `INV-${validatedData.sku}-${Date.now()}-${i}`
// Create inventory item
const item = await db.inventoryItem.create({
data: {
...validatedData,
qrCode,
userId: auth.session!.user.userId,
},
})
results.success.push({
row: rowNum,
item: {
id: item.id,
name: item.name,
sku: item.sku,
quantity: item.quantity,
}
})
} catch (error) {
console.error(`Error processing row ${rowNum}:`, error)
results.errors.push({
row: rowNum,
error: error instanceof Error ? error.message : 'Unknown error'
})
}
}
return NextResponse.json({
message: `Import completed. ${results.success.length} items imported, ${results.errors.length} errors.`,
results,
})
} catch (error) {
console.error('Error importing inventory items:', error)
return NextResponse.json(
{ error: 'Failed to import inventory items' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,174 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
import QRCode from 'qrcode'
import { z } from 'zod'
const printLabelsSchema = z.object({
itemIds: z.array(z.string()).min(1, 'At least one item ID is required'),
labelSize: z.enum(['small', 'medium', 'large']).default('medium'),
includeInfo: z.object({
name: z.boolean().default(true),
sku: z.boolean().default(true),
quantity: z.boolean().default(false),
category: z.boolean().default(false),
location: z.boolean().default(false),
}).default({
name: true,
sku: true,
quantity: false,
category: false,
location: false,
}),
})
const labelSizes = {
small: { width: 150, height: 100, qrSize: 60 },
medium: { width: 200, height: 150, qrSize: 80 },
large: { width: 300, height: 200, qrSize: 120 },
}
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { itemIds, labelSize, includeInfo } = printLabelsSchema.parse(body)
// Fetch inventory items
const items = await db.inventoryItem.findMany({
where: { id: { in: itemIds } }
})
if (items.length === 0) {
return NextResponse.json(
{ error: 'No inventory items found' },
{ status: 404 }
)
}
const size = labelSizes[labelSize]
const labels: Array<{
id: string
name: string
sku: string
html: string
qrCode: string
}> = []
// Generate labels for each item
for (const item of items) {
// Generate QR code
const qrCodeBase64 = await QRCode.toDataURL(item.qrCode, {
width: size.qrSize,
margin: 1,
color: {
dark: '#000000',
light: '#FFFFFF'
}
})
// Create label HTML
const labelHtml = `
<div style="
width: ${size.width}px;
height: ${size.height}px;
border: 1px solid #ccc;
padding: 8px;
font-family: Arial, sans-serif;
font-size: 12px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
page-break-inside: avoid;
">
<div style="margin-bottom: 8px;">
<img src="${qrCodeBase64}" alt="QR Code" style="width: ${size.qrSize}px; height: ${size.qrSize}px;" />
</div>
<div style="text-align: center; width: 100%;">
${includeInfo.name ? `<div style="font-weight: bold; font-size: 14px; margin-bottom: 2px;">${item.name}</div>` : ''}
${includeInfo.sku ? `<div style="font-size: 12px; margin-bottom: 2px;">SKU: ${item.sku}</div>` : ''}
${includeInfo.quantity ? `<div style="font-size: 11px; margin-bottom: 2px;">Qty: ${item.quantity}</div>` : ''}
${includeInfo.category ? `<div style="font-size: 11px; margin-bottom: 2px;">${item.category || ''}</div>` : ''}
${includeInfo.location ? `<div style="font-size: 11px;">${item.location || ''}</div>` : ''}
</div>
</div>
`
labels.push({
id: item.id,
name: item.name,
sku: item.sku,
html: labelHtml,
qrCode: qrCodeBase64,
})
}
// Create a complete HTML page for printing
const printHtml = `
<!DOCTYPE html>
<html>
<head>
<title>Inventory Labels</title>
<style>
@media print {
body { margin: 0; padding: 0; }
.labels-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(${size.width}px, 1fr));
gap: 2px;
padding: 2px;
}
}
@media screen {
body {
margin: 20px;
font-family: Arial, sans-serif;
}
.labels-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(${size.width}px, 1fr));
gap: 10px;
padding: 20px;
border: 1px solid #ccc;
background: #f9f9f9;
}
}
</style>
</head>
<body>
<div class="labels-container">
${labels.map(label => label.html).join('')}
</div>
<script>
// Auto-print when loaded
window.onload = function() {
if (window.confirm('Ready to print labels? Click OK to open print dialog.')) {
window.print();
}
};
</script>
</body>
</html>
`
return NextResponse.json({
labels,
printHtml,
labelSize,
totalLabels: labels.length,
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation failed', details: error.issues },
{ status: 400 }
)
}
console.error('Error generating labels:', error)
return NextResponse.json(
{ error: 'Failed to generate labels' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,120 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { z } from 'zod'
import { authenticateRequest } from '@/lib/server-auth'
const createItemSchema = z.object({
name: z.string().min(1, 'Name is required'),
description: z.string().optional(),
sku: z.string().min(1, 'SKU is required'),
quantity: z.number().int().min(0, 'Quantity must be non-negative'),
category: z.string().optional(),
location: z.string().optional(),
price: z.number().optional(),
})
const updateItemSchema = z.object({
name: z.string().min(1, 'Name is required').optional(),
description: z.string().optional(),
sku: z.string().min(1, 'SKU is required').optional(),
quantity: z.number().int().min(0, 'Quantity must be non-negative').optional(),
category: z.string().optional(),
location: z.string().optional(),
price: z.number().optional(),
})
export async function GET(request: NextRequest) {
try {
// Add authentication check
const auth = await authenticateRequest(request)
if (!auth.success) {
return NextResponse.json({ error: auth.error }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const search = searchParams.get('search')
const category = searchParams.get('category')
let whereClause = {}
if (search) {
whereClause = {
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ sku: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
],
}
}
if (category && category !== 'all') {
whereClause = {
...whereClause,
category: category,
}
}
const items = await db.inventoryItem.findMany({
where: whereClause,
orderBy: { createdAt: 'desc' },
})
return NextResponse.json(items)
} catch (error) {
console.error('Error fetching inventory items:', error)
return NextResponse.json(
{ error: 'Failed to fetch inventory items' },
{ status: 500 }
)
}
}
export async function POST(request: NextRequest) {
try {
// Add authentication check
const auth = await authenticateRequest(request)
if (!auth.success) {
return NextResponse.json({ error: auth.error }, { status: 401 })
}
const body = await request.json()
const validatedData = createItemSchema.parse(body)
// Check if SKU already exists
const existingItem = await db.inventoryItem.findUnique({
where: { sku: validatedData.sku }
})
if (existingItem) {
return NextResponse.json(
{ error: 'SKU already exists' },
{ status: 400 }
)
}
// Generate QR code (using SKU as the QR code data for now)
const qrCode = `INV-${validatedData.sku}-${Date.now()}`
const item = await db.inventoryItem.create({
data: {
...validatedData,
qrCode,
userId: auth.session!.user.userId,
},
})
return NextResponse.json(item, { status: 201 })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation failed', details: error.issues },
{ status: 400 }
)
}
console.error('Error creating inventory item:', error)
return NextResponse.json(
{ error: 'Failed to create inventory item' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { z } from 'zod'
const scanSchema = z.object({
qrCode: z.string().min(1, 'QR code is required'),
action: z.enum(['increment', 'decrement', 'set', 'info', 'add', 'subtract']).default('increment'),
quantity: z.number().int().min(0).optional(),
})
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { qrCode, action, quantity } = scanSchema.parse(body)
// Find inventory item by QR code
const item = await db.inventoryItem.findUnique({
where: { qrCode }
})
if (!item) {
return NextResponse.json(
{ error: 'Inventory item not found for this QR code' },
{ status: 404 }
)
}
// If action is 'info', just return the item information without updating
if (action === 'info') {
return NextResponse.json({
message: 'Item found',
item: item,
})
}
let updatedQuantity = item.quantity
switch (action) {
case 'increment':
case 'add':
updatedQuantity = item.quantity + (quantity || 1)
break
case 'decrement':
case 'subtract':
updatedQuantity = Math.max(0, item.quantity - (quantity || 1))
break
case 'set':
if (quantity === undefined) {
return NextResponse.json(
{ error: 'Quantity is required for set action' },
{ status: 400 }
)
}
updatedQuantity = quantity
break
}
const updatedItem = await db.inventoryItem.update({
where: { id: item.id },
data: { quantity: updatedQuantity },
})
return NextResponse.json({
message: 'Inventory updated successfully',
item: updatedItem,
previousQuantity: item.quantity,
newQuantity: updatedQuantity,
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation failed', details: error.issues },
{ status: 400 }
)
}
console.error('Error processing QR code scan:', error)
return NextResponse.json(
{ error: 'Failed to process QR code scan' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,93 @@
// src/app/auth/register/page.tsx
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import Link from 'next/link'
export default function Register() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const router = useRouter()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
setError('')
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
})
const data = await response.json()
if (response.ok) {
// Redirect to sign-in page after successful registration
router.push('/auth/signin')
} else {
setError(data.error || 'Registration failed')
}
} catch (error) {
setError('Network error. Please try again.')
} finally {
setIsLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Register</CardTitle>
<CardDescription>
Create a new account to access the inventory system
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && (
<div className="text-red-600 text-sm">{error}</div>
)}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Registering...' : 'Register'}
</Button>
</form>
<div className="mt-4 text-center">
<Link href="/auth/signin" className="text-sm text-blue-600 hover:underline">
Already have an account? Sign in
</Link>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,95 @@
// src/app/auth/signin/page.tsx
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import Link from 'next/link'
export default function SignIn() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
setError('')
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
})
const data = await response.json()
if (response.ok) {
// Store token in localStorage
localStorage.setItem('authToken', data.token)
localStorage.setItem('user', JSON.stringify(data.user))
// Redirect to dashboard
window.location.href = '/'
} else {
setError(data.error || 'Login failed')
}
} catch (error) {
setError('Network error. Please try again.')
} finally {
setIsLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Sign In</CardTitle>
<CardDescription>
Enter your credentials to access the inventory system
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && (
<div className="text-red-600 text-sm">{error}</div>
)}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Signing in...' : 'Sign In'}
</Button>
</form>
<div className="mt-4 text-center">
<Link href="/auth/register" className="text-sm text-blue-600 hover:underline">
Don't have an account? Register
</Link>
</div>
</CardContent>
</Card>
</div>
)
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

67
src/app/globals.css Normal file
View File

@ -0,0 +1,67 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

33
src/app/layout.tsx Normal file
View File

@ -0,0 +1,33 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "QR Inventory Management System",
description: "Modern QR code-based inventory management system",
keywords: ["QR Code", "Inventory", "Management", "Next.js", "TypeScript"],
authors: [{ name: "Inventory Team" }],
openGraph: {
title: "QR Inventory Management System",
description: "QR code-based inventory management system",
type: "website",
},
twitter: {
card: "summary_large_image",
title: "QR Inventory Management System",
description: "QR code-based inventory management system",
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className="antialiased bg-background text-foreground font-sans">
{children}
</body>
</html>
);
}

647
src/app/page.tsx Normal file
View File

@ -0,0 +1,647 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Plus, Search, QrCode, Upload, Camera, Package, AlertTriangle, Download, Printer } from 'lucide-react'
import { useToast } from '@/hooks/use-toast'
import PrintLabelsDialog from '@/components/PrintLabelsDialog'
import AndroidOptimizedQRScanner from '@/components/AndroidOptimizedQRScanner'
import QuantityAdjustDialog from '@/components/QuantityAdjustDialog'
import { getAuthHeaders, isAuthenticated, clearAuthToken } from '@/lib/auth'
interface InventoryItem {
id: string
name: string
description?: string
sku: string
quantity: number
qrCode: string
category?: string
location?: string
price?: number
createdAt: string
updatedAt: string
}
export default function Home() {
const [inventoryItems, setInventoryItems] = useState<InventoryItem[]>([])
const [searchTerm, setSearchTerm] = useState('')
const [selectedCategory, setSelectedCategory] = useState('all')
const [isScanning, setIsScanning] = useState(false)
const [showAddDialog, setShowAddDialog] = useState(false)
const [showImportDialog, setShowImportDialog] = useState(false)
const [showQRDialog, setShowQRDialog] = useState(false)
const [showScannerDialog, setShowScannerDialog] = useState(false)
const [showQuantityDialog, setShowQuantityDialog] = useState(false)
const [selectedItem, setSelectedItem] = useState<InventoryItem | null>(null)
const [scannedItem, setScannedItem] = useState<InventoryItem | null>(null)
const [qrCodeImage, setQrCodeImage] = useState<string>('')
const [isLoading, setIsLoading] = useState(false)
const [newItem, setNewItem] = useState({
name: '',
description: '',
sku: '',
quantity: 0,
category: '',
location: '',
price: ''
})
const { toast } = useToast()
const router = useRouter()
useEffect(() => {
if (!isAuthenticated()) {
router.push('/auth/signin')
} else {
fetchInventoryItems()
}
}, [router])
const fetchInventoryItems = async () => {
try {
const response = await fetch('/api/inventory', {
headers: getAuthHeaders()
})
if (response.status === 401) {
router.push('/auth/signin')
return
}
if (response.ok) {
const items = await response.json()
setInventoryItems(items)
}
} catch (error) {
toast({
title: "Error",
description: "Failed to fetch inventory items",
variant: "destructive",
})
}
}
const filteredItems = inventoryItems.filter(item => {
const matchesSearch = item.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.sku.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.description?.toLowerCase().includes(searchTerm.toLowerCase())
const matchesCategory = selectedCategory === 'all' || item.category === selectedCategory
return matchesSearch && matchesCategory
})
const categories = ['all', ...new Set(inventoryItems.map(item => item.category).filter(Boolean) as string[])]
const handleScan = async (data: string) => {
try {
// First, get the item information
const itemResponse = await fetch(`/api/inventory/scan`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ qrCode: data, action: 'info' })
})
if (itemResponse.ok) {
const result = await itemResponse.json()
setScannedItem(result.item)
setShowQuantityDialog(true)
} else {
const error = await itemResponse.json()
toast({
title: "Scan Error",
description: error.error || "Failed to find item with this QR code",
variant: "destructive",
})
}
} catch (error) {
toast({
title: "Scan Error",
description: "Failed to process QR code",
variant: "destructive",
})
}
}
const handleQuantityUpdate = async (quantity: number, action: 'set' | 'add' | 'subtract') => {
if (!scannedItem) return
try {
const response = await fetch('/api/inventory/scan', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
qrCode: scannedItem.qrCode,
action,
quantity
})
})
if (response.ok) {
const result = await response.json()
let description = ''
if (action === 'set') {
description = `${result.item.name} quantity set to ${result.newQuantity}`
} else if (action === 'add') {
description = `${result.item.name} quantity increased from ${result.previousQuantity} to ${result.newQuantity}`
} else if (action === 'subtract') {
description = `${result.item.name} quantity decreased from ${result.previousQuantity} to ${result.newQuantity}`
}
toast({
title: "Inventory Updated",
description,
})
fetchInventoryItems()
} else {
const error = await response.json()
toast({
title: "Update Error",
description: error.error || "Failed to update inventory",
variant: "destructive",
})
}
} catch (error) {
toast({
title: "Update Error",
description: "Failed to update inventory",
variant: "destructive",
})
}
}
const handleError = (err: any) => {
console.error(err)
toast({
title: "Camera Error",
description: "Unable to access camera. Please check permissions.",
variant: "destructive",
})
setIsScanning(false)
}
const handleAddItem = async () => {
if (!newItem.name || !newItem.sku) {
toast({
title: "Validation Error",
description: "Name and SKU are required",
variant: "destructive",
})
return
}
setIsLoading(true)
try {
const response = await fetch('/api/inventory', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
...newItem,
quantity: parseInt(newItem.quantity.toString()) || 0,
price: newItem.price ? parseFloat(newItem.price) : undefined
})
})
if (response.ok) {
toast({
title: "Success",
description: "Inventory item created successfully",
})
setShowAddDialog(false)
setNewItem({
name: '',
description: '',
sku: '',
quantity: 0,
category: '',
location: '',
price: ''
})
fetchInventoryItems()
} else {
const error = await response.json()
toast({
title: "Error",
description: error.error || "Failed to create item",
variant: "destructive",
})
}
} catch (error) {
toast({
title: "Error",
description: "Failed to create item",
variant: "destructive",
})
}
setIsLoading(false)
}
const handleGenerateQR = async (item: InventoryItem) => {
setSelectedItem(item)
setIsLoading(true)
try {
const response = await fetch('/api/inventory/generate-qr', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ itemId: item.id, size: 200 })
})
if (response.ok) {
const result = await response.json()
setQrCodeImage(result.qrCode)
setShowQRDialog(true)
} else {
const error = await response.json()
toast({
title: "Error",
description: error.error || "Failed to generate QR code",
variant: "destructive",
})
}
} catch (error) {
toast({
title: "Error",
description: "Failed to generate QR code",
variant: "destructive",
})
}
setIsLoading(false)
}
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
setIsLoading(true)
try {
const formData = new FormData()
formData.append('file', file)
const response = await fetch('/api/inventory/import', {
method: 'POST',
headers: getAuthHeaders(),
body: formData
})
if (response.ok) {
const result = await response.json()
toast({
title: "Import Complete",
description: result.message,
})
fetchInventoryItems()
} else {
const error = await response.json()
toast({
title: "Import Error",
description: error.error || "Failed to import file",
variant: "destructive",
})
}
} catch (error) {
toast({
title: "Import Error",
description: "Failed to import file",
variant: "destructive",
})
}
setIsLoading(false)
}
const lowStockItems = inventoryItems.filter(item => item.quantity < 10)
return (
<div className="min-h-screen bg-background p-4 md:p-6">
<div className="max-w-7xl mx-auto space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight">Inventory Management</h1>
<p className="text-muted-foreground">Manage your inventory with QR code scanning</p>
</div>
<div className="flex flex-col sm:flex-row gap-2">
<Button onClick={() => {
clearAuthToken()
router.push('/auth/signin')
}} variant="outline" className="w-full sm:w-auto">
Logout
</Button>
<Button onClick={() => setShowScannerDialog(true)} className="w-full sm:w-auto">
<Camera className="mr-2 h-4 w-4" />
Scan QR Code
</Button>
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
<DialogTrigger asChild>
<Button variant="outline" className="w-full sm:w-auto">
<Plus className="mr-2 h-4 w-4" />
Add Item
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add Inventory Item</DialogTitle>
<DialogDescription>
Create a new inventory item with QR code
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">Name</Label>
<Input
id="name"
className="col-span-3"
value={newItem.name}
onChange={(e) => setNewItem({...newItem, name: e.target.value})}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="sku" className="text-right">SKU</Label>
<Input
id="sku"
className="col-span-3"
value={newItem.sku}
onChange={(e) => setNewItem({...newItem, sku: e.target.value})}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="quantity" className="text-right">Quantity</Label>
<Input
id="quantity"
type="number"
className="col-span-3"
value={newItem.quantity}
onChange={(e) => setNewItem({...newItem, quantity: parseInt(e.target.value) || 0})}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="category" className="text-right">Category</Label>
<Input
id="category"
className="col-span-3"
value={newItem.category}
onChange={(e) => setNewItem({...newItem, category: e.target.value})}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="location" className="text-right">Location</Label>
<Input
id="location"
className="col-span-3"
value={newItem.location}
onChange={(e) => setNewItem({...newItem, location: e.target.value})}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="price" className="text-right">Price</Label>
<Input
id="price"
type="number"
step="0.01"
className="col-span-3"
value={newItem.price}
onChange={(e) => setNewItem({...newItem, price: e.target.value})}
/>
</div>
</div>
<div className="flex justify-end">
<Button onClick={handleAddItem} disabled={isLoading}>
{isLoading ? "Creating..." : "Save Item"}
</Button>
</div>
</DialogContent>
</Dialog>
<PrintLabelsDialog items={inventoryItems} onPrintComplete={fetchInventoryItems} />
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Items</CardTitle>
<Package className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{inventoryItems.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Low Stock</CardTitle>
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">{lowStockItems.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Categories</CardTitle>
<QrCode className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{categories.length - 1}</div>
</CardContent>
</Card>
</div>
{/* Main Content */}
<Tabs defaultValue="inventory" className="space-y-4">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="inventory">Inventory</TabsTrigger>
<TabsTrigger value="scan">Scan</TabsTrigger>
<TabsTrigger value="import">Import</TabsTrigger>
</TabsList>
<TabsContent value="inventory" className="space-y-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search items..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
<Select value={selectedCategory} onValueChange={setSelectedCategory}>
<SelectTrigger className="w-full sm:w-[200px]">
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
{categories.filter(cat => cat !== 'all').map(category => (
<SelectItem key={category} value={category}>{category}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Card>
<CardHeader>
<CardTitle>Inventory Items</CardTitle>
<CardDescription>
Manage your inventory items and generate QR codes
</CardDescription>
</CardHeader>
<CardContent>
<ScrollArea className="h-[400px]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>SKU</TableHead>
<TableHead>Category</TableHead>
<TableHead>Quantity</TableHead>
<TableHead>Location</TableHead>
<TableHead>Price</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredItems.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium">{item.name}</TableCell>
<TableCell>{item.sku}</TableCell>
<TableCell>
<Badge variant="outline">{item.category || 'Uncategorized'}</Badge>
</TableCell>
<TableCell>
<Badge variant={item.quantity < 10 ? "destructive" : "secondary"}>
{item.quantity}
</Badge>
</TableCell>
<TableCell>{item.location || 'N/A'}</TableCell>
<TableCell>{item.price ? `$${item.price.toFixed(2)}` : 'N/A'}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleGenerateQR(item)}
>
<QrCode className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="scan" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>QR Code Scanner</CardTitle>
<CardDescription>
Scan QR codes to quickly update inventory quantities
</CardDescription>
</CardHeader>
<CardContent>
<AndroidOptimizedQRScanner
isOpen={true}
onClose={() => {}}
onScan={handleScan}
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="import" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Import Inventory</CardTitle>
<CardDescription>
Upload a CSV file to import multiple inventory items at once
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
<Upload className="mx-auto h-12 w-12 text-gray-400" />
<div className="mt-4">
<Label htmlFor="file-upload" className="cursor-pointer">
<span className="mt-2 block text-sm font-medium text-gray-900">
Upload a CSV file
</span>
<Input
id="file-upload"
name="file-upload"
type="file"
className="sr-only"
accept=".csv"
onChange={handleFileUpload}
/>
</Label>
<p className="mt-1 text-xs text-gray-500">
CSV format: name, sku, quantity, category, location, price
</p>
</div>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* QR Code Dialog */}
<Dialog open={showQRDialog} onOpenChange={setShowQRDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>QR Code</DialogTitle>
<DialogDescription>
QR code for {selectedItem?.name}
</DialogDescription>
</DialogHeader>
<div className="flex justify-center">
{qrCodeImage && (
<img src={qrCodeImage} alt="QR Code" className="max-w-full h-auto" />
)}
</div>
</DialogContent>
</Dialog>
{/* Scanner Dialog */}
<Dialog open={showScannerDialog} onOpenChange={setShowScannerDialog}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Scan QR Code</DialogTitle>
<DialogDescription>
Point your camera at a QR code to scan it
</DialogDescription>
</DialogHeader>
<AndroidOptimizedQRScanner
isOpen={showScannerDialog}
onClose={() => setShowScannerDialog(false)}
onScan={handleScan}
/>
</DialogContent>
</Dialog>
{/* Quantity Adjustment Dialog */}
{scannedItem && (
<QuantityAdjustDialog
item={scannedItem}
isOpen={showQuantityDialog}
onClose={() => setShowQuantityDialog(false)}
onConfirm={handleQuantityUpdate}
/>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,595 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { Camera, X, RefreshCw, AlertTriangle, CheckCircle, Shield, Settings, Smartphone } from 'lucide-react'
import { useToast } from '@/hooks/use-toast'
import jsqr from 'jsqr'
import {
getOptimizedCameraConstraints,
getAvailableCameras,
getBestCameraForQR,
checkCameraPermission,
testCameraAccess,
getDeviceSpecificSettings,
isAndroid
} from '@/lib/camera-utils'
interface AndroidOptimizedQRScannerProps {
isOpen: boolean
onClose: () => void
onScan: (data: string) => void
}
export default function AndroidOptimizedQRScanner({ isOpen, onClose, onScan }: AndroidOptimizedQRScannerProps) {
const [hasCamera, setHasCamera] = useState(true)
const [cameraError, setCameraError] = useState<string | null>(null)
const [isScanning, setIsScanning] = useState(false)
const [scanResult, setScanResult] = useState<string | null>(null)
const [cameraId, setCameraId] = useState<string | null>(null)
const [cameras, setCameras] = useState<any[]>([])
const [facingMode, setFacingMode] = useState<'environment' | 'user'>('environment')
const [permissionState, setPermissionState] = useState<'unknown' | 'granted' | 'denied' | 'prompt'>('unknown')
const [isRequestingPermission, setIsRequestingPermission] = useState(false)
const [cameraStream, setCameraStream] = useState<MediaStream | null>(null)
const [isScanningFrame, setIsScanningFrame] = useState(false)
const [androidSpecific, setAndroidSpecific] = useState(false)
const deviceSettings = typeof window !== 'undefined' ? getDeviceSpecificSettings() : {
isAndroid: false,
isIOS: false,
isMobile: false,
scanInterval: 500,
videoAttributes: {
playsinline: false,
muted: false,
autoplay: true
},
permissionHelpText: "Please enable camera permissions in your browser settings"
}
const videoRef = useRef<HTMLVideoElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const scanIntervalRef = useRef<NodeJS.Timeout | null>(null)
const { toast } = useToast()
// Detect Android device
useEffect(() => {
setAndroidSpecific(isAndroid())
}, [])
useEffect(() => {
if (isOpen) {
checkCameraPermissions()
setScanResult(null)
setCameraError(null)
} else {
// Clean up when dialog closes
stopCamera()
}
}, [isOpen])
useEffect(() => {
if (isScanning) {
startCamera()
} else {
stopCamera()
}
}, [isScanning, cameraId, facingMode])
const startCamera = async () => {
try {
console.log('Starting camera with facingMode:', facingMode)
// Stop any existing stream
if (cameraStream) {
cameraStream.getTracks().forEach(track => track.stop())
setCameraStream(null)
}
// Get optimized constraints
const constraints = getOptimizedCameraConstraints(facingMode, cameraId || undefined)
// If we have a specific camera ID, use it
if (cameraId) {
constraints.video.deviceId = { exact: cameraId }
}
console.log('Requesting camera with constraints:', constraints)
const stream = await navigator.mediaDevices.getUserMedia(constraints)
console.log('Camera stream obtained:', stream)
setCameraStream(stream)
setCameraError(null)
// Set up video element with device-specific attributes
if (videoRef.current) {
videoRef.current.srcObject = stream
// Apply device-specific video attributes
if (deviceSettings?.videoAttributes) {
Object.entries(deviceSettings.videoAttributes).forEach(([key, value]) => {
if (value !== undefined) {
videoRef.current!.setAttribute(key, value.toString())
}
})
}
videoRef.current.play().then(() => {
console.log('Video playback started')
startFrameScanning()
}).catch(error => {
console.error('Video playback failed:', error)
setCameraError('Video playback failed. Please try again.')
})
}
} catch (error: any) {
console.error('Camera start failed:', error)
handleCameraError(error)
}
}
const stopCamera = () => {
if (scanIntervalRef.current) {
clearInterval(scanIntervalRef.current)
scanIntervalRef.current = null
}
setIsScanningFrame(false)
if (cameraStream) {
cameraStream.getTracks().forEach(track => track.stop())
setCameraStream(null)
}
if (videoRef.current) {
videoRef.current.srcObject = null
}
}
const startFrameScanning = () => {
if (scanIntervalRef.current) {
clearInterval(scanIntervalRef.current)
}
setIsScanningFrame(true)
// Use device-specific scan interval
const scanInterval = deviceSettings?.scanInterval || 500
scanIntervalRef.current = setInterval(() => {
scanFrame()
}, scanInterval)
}
const scanFrame = () => {
const video = videoRef.current
const canvas = canvasRef.current
if (!video || !canvas || video.readyState !== video.HAVE_ENOUGH_DATA) {
return
}
try {
const context = canvas.getContext('2d')
if (!context) return
// Set canvas size to match video
canvas.width = video.videoWidth
canvas.height = video.videoHeight
// Draw video frame to canvas
context.drawImage(video, 0, 0, canvas.width, canvas.height)
// Get image data for QR scanning
const imageData = context.getImageData(0, 0, canvas.width, canvas.height)
const code = jsqr(imageData.data, imageData.width, imageData.height)
if (code) {
console.log('QR Code detected:', code.data)
handleScan(code.data)
}
} catch (error) {
console.error('Frame scanning error:', error)
}
}
const handleCameraError = (error: any) => {
console.error('Camera error:', error)
let errorMessage = 'Camera access failed'
let errorTitle = 'Camera Error'
if (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError') {
setPermissionState('denied')
errorMessage = deviceSettings?.permissionHelpText || "Please enable camera permissions in your browser settings"
errorTitle = 'Camera Permission Denied'
} else if (error.name === 'NotFoundError' || error.name === 'DevicesNotFoundError') {
errorMessage = 'No camera found on this device.'
} else if (error.name === 'NotReadableError' || error.name === 'TrackStartError') {
errorMessage = 'Camera is already in use by another application.'
} else if (error.name === 'OverconstrainedError' || error.name === 'ConstraintNotSatisfiedError') {
errorMessage = 'Camera constraints not supported. Trying with different settings...'
// Try to restart with simpler constraints
setTimeout(() => {
setFacingMode('environment')
setIsScanning(false)
setTimeout(() => setIsScanning(true), 500)
}, 1000)
} else if (error.name === 'TypeError' && error.message?.includes('at least one of audio and video')) {
errorMessage = 'Camera configuration error. Please refresh the page and try again.'
} else if (error.message) {
errorMessage = `Camera error: ${error.message}`
}
setCameraError(errorMessage)
setIsScanning(false)
toast({
title: errorTitle,
description: errorMessage,
variant: "destructive",
})
}
const checkCameraPermissions = async () => {
try {
console.log('Checking camera permissions...')
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
throw new Error('Camera not supported in this browser')
}
setHasCamera(true)
setCameraError(null)
// Check permissions status using utility
const permission = await checkCameraPermission()
setPermissionState(permission)
// Get available cameras using utility
const videoDevices = await getAvailableCameras()
setCameras(videoDevices)
console.log('Found cameras:', videoDevices.length)
if (videoDevices.length === 0) {
throw new Error('No cameras found')
}
// Get best camera for QR scanning
const bestCameraId = await getBestCameraForQR()
if (bestCameraId) {
setCameraId(bestCameraId)
} else {
setCameraId(videoDevices[0].deviceId)
}
// Test camera access with minimal constraints
try {
const testConstraints = getOptimizedCameraConstraints('environment')
const success = await testCameraAccess(testConstraints)
if (success) {
setPermissionState('granted')
} else {
throw new Error('Camera test failed')
}
} catch (testError) {
console.log('Camera test failed')
if (permissionState === 'unknown') {
setPermissionState('prompt')
}
}
} catch (error) {
console.error('Camera check failed:', error)
setHasCamera(false)
setCameraError(error instanceof Error ? error.message : 'Camera access failed')
}
}
const requestCameraPermission = async () => {
setIsRequestingPermission(true)
try {
console.log('Requesting camera permission...')
let stream: MediaStream | null = null
// Try different constraint combinations using optimized constraints
const constraintSets = [
getOptimizedCameraConstraints('environment'),
getOptimizedCameraConstraints('user'),
// Basic fallback
{ video: true },
// Minimal constraints
{ video: { width: { ideal: 640 }, height: { ideal: 480 } } }
]
for (const constraints of constraintSets) {
try {
console.log('Trying constraints:', constraints)
stream = await navigator.mediaDevices.getUserMedia(constraints)
console.log('Constraints successful:', constraints)
break
} catch (error) {
console.log('Constraints failed:', constraints, error)
continue
}
}
if (stream) {
console.log('Camera permission granted')
stream.getTracks().forEach(track => track.stop())
setPermissionState('granted')
setIsScanning(true)
setCameraError(null)
toast({
title: "Camera Access Granted",
description: "You can now scan QR codes",
})
} else {
throw new Error('Failed to obtain camera stream with any constraints')
}
} catch (error: any) {
console.error('Permission request failed:', error)
handleCameraError(error)
} finally {
setIsRequestingPermission(false)
}
}
const handleScan = (data: string | null) => {
if (data) {
console.log('QR Code scanned successfully:', data)
setScanResult(data)
setIsScanning(false)
// Vibrate if available (mobile feedback)
if ('vibrate' in navigator) {
navigator.vibrate(200)
}
toast({
title: "QR Code Scanned",
description: "Processing inventory update...",
})
// Process the scan after a short delay
setTimeout(() => {
onScan(data)
onClose()
}, 1000)
}
}
const startScanning = () => {
console.log('startScanning called, permissionState:', permissionState)
if (permissionState === 'denied') {
toast({
title: "Camera Permission Required",
description: deviceSettings?.permissionHelpText || "Please enable camera permissions in your browser settings",
variant: "destructive",
})
return
}
if (permissionState === 'unknown' || permissionState === 'prompt') {
requestCameraPermission()
} else {
setScanResult(null)
setCameraError(null)
setIsScanning(true)
}
}
const switchCamera = async () => {
const newFacingMode = facingMode === 'environment' ? 'user' : 'environment'
setFacingMode(newFacingMode)
if (isScanning) {
// Restart camera with new facing mode
setIsScanning(false)
setTimeout(() => setIsScanning(true), 500)
}
}
const handleManualInput = () => {
const qrCode = prompt('Enter QR code manually:')
if (qrCode) {
console.log('Manual QR code entry:', qrCode)
onScan(qrCode)
onClose()
}
}
const simulateScan = () => {
// Simulate a QR code scan for testing
const testQRCodes = [
'INV-TEST001-1234567890-1',
'INV-TEST002-1234567890-2',
'INV-TEST003-1234567890-3'
]
const randomQR = testQRCodes[Math.floor(Math.random() * testQRCodes.length)]
toast({
title: "QR Code Scanned",
description: "Processing inventory update...",
})
setTimeout(() => {
onScan(randomQR)
onClose()
}, 1000)
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-md max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Camera className="w-5 h-5" />
QR Code Scanner
{androidSpecific && (
<Badge variant="secondary" className="text-xs">
<Smartphone className="w-3 h-3 mr-1" />
Android Optimized
</Badge>
)}
</DialogTitle>
<DialogDescription>
Scan QR codes to update inventory quantities
{androidSpecific && (
<div className="text-xs text-muted-foreground mt-1">
Optimized for Android devices with enhanced camera support
</div>
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Camera Preview */}
<Card>
<CardHeader>
<CardTitle className="text-sm flex items-center justify-between">
Camera Preview
{cameras.length > 1 && (
<Button
variant="outline"
size="sm"
onClick={switchCamera}
disabled={isScanning}
>
Switch Camera
</Button>
)}
</CardTitle>
</CardHeader>
<CardContent>
<div className="relative aspect-square bg-black rounded-lg overflow-hidden">
{isScanning ? (
<div className="w-full h-full">
<video
ref={videoRef}
autoPlay
playsInline
muted
className="w-full h-full object-cover"
style={{ transform: facingMode === 'user' ? 'scaleX(-1)' : 'none' }}
/>
{/* Scanning overlay */}
<div className="absolute inset-0 pointer-events-none">
<div className="absolute inset-4 border-2 border-primary rounded-lg animate-pulse"></div>
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-32 h-32 border-2 border-green-400 rounded-lg"></div>
</div>
</div>
) : (
<div className="w-full h-full flex items-center justify-center bg-muted">
<div className="text-center">
<Camera className="w-16 h-16 mx-auto mb-2 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
{hasCamera ? "Camera not started" : "No camera available"}
</p>
</div>
</div>
)}
<canvas ref={canvasRef} className="hidden" />
</div>
</CardContent>
</Card>
{/* Status */}
<div className="flex flex-wrap items-center gap-2">
<Badge variant={permissionState === 'granted' ? 'default' : 'secondary'}>
{permissionState === 'granted' && <CheckCircle className="w-3 h-3 mr-1" />}
{permissionState === 'denied' && <AlertTriangle className="w-3 h-3 mr-1" />}
{permissionState === 'prompt' && <Shield className="w-3 h-3 mr-1" />}
Permission: {permissionState}
</Badge>
{androidSpecific && (
<Badge variant="outline">
<Smartphone className="w-3 h-3 mr-1" />
Android
</Badge>
)}
{isScanningFrame && (
<Badge variant="outline" className="animate-pulse">
Scanning...
</Badge>
)}
</div>
{/* Error Display */}
{cameraError && (
<Card className="border-red-200 bg-red-50">
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-red-700">
<AlertTriangle className="w-4 h-4" />
<span className="text-sm">{cameraError}</span>
</div>
</CardContent>
</Card>
)}
{/* Success Display */}
{scanResult && (
<Card className="border-green-200 bg-green-50">
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-green-700">
<CheckCircle className="w-4 h-4" />
<span className="text-sm">QR Code detected: {scanResult}</span>
</div>
</CardContent>
</Card>
)}
{/* Action Buttons */}
<div className="flex flex-col gap-2">
{!isScanning ? (
<Button onClick={startScanning} disabled={isRequestingPermission || !hasCamera}>
{isRequestingPermission ? (
<>
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
Requesting Permission...
</>
) : (
<>
<Camera className="mr-2 h-4 w-4" />
Start Scanning
</>
)}
</Button>
) : (
<Button variant="outline" onClick={() => setIsScanning(false)}>
<X className="mr-2 h-4 w-4" />
Stop Scanning
</Button>
)}
<div className="flex gap-2">
<Button variant="outline" onClick={handleManualInput} className="flex-1">
Manual Entry
</Button>
<Button variant="outline" onClick={simulateScan} className="flex-1">
Test Scan
</Button>
</div>
{androidSpecific && (
<div className="text-xs text-muted-foreground p-2 bg-muted rounded">
<div className="flex items-center gap-1 mb-1">
<Settings className="w-3 h-3" />
<span className="font-medium">Android Optimization Active</span>
</div>
<ul className="space-y-1 ml-4">
<li> Enhanced camera constraints</li>
<li> Optimized scan interval</li>
<li> Device-specific error handling</li>
</ul>
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,269 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
import { Separator } from '@/components/ui/separator'
import { Printer, QrCode } from 'lucide-react'
import { useToast } from '@/hooks/use-toast'
interface InventoryItem {
id: string
name: string
sku: string
quantity: number
category?: string
location?: string
}
interface PrintLabelsDialogProps {
items: InventoryItem[]
onPrintComplete?: () => void
}
export default function PrintLabelsDialog({ items, onPrintComplete }: PrintLabelsDialogProps) {
const [isOpen, setIsOpen] = useState(false)
const [selectedItems, setSelectedItems] = useState<string[]>([])
const [labelSize, setLabelSize] = useState<'small' | 'medium' | 'large'>('medium')
const [includeInfo, setIncludeInfo] = useState({
name: true,
sku: true,
quantity: false,
category: false,
location: false,
})
const [isGenerating, setIsGenerating] = useState(false)
const { toast } = useToast()
const handleItemSelect = (itemId: string, checked: boolean) => {
if (checked) {
setSelectedItems([...selectedItems, itemId])
} else {
setSelectedItems(selectedItems.filter(id => id !== itemId))
}
}
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedItems(items.map(item => item.id))
} else {
setSelectedItems([])
}
}
const handlePrintLabels = async () => {
if (selectedItems.length === 0) {
toast({
title: "No Items Selected",
description: "Please select at least one item to print labels",
variant: "destructive",
})
return
}
setIsGenerating(true)
try {
const response = await fetch('/api/inventory/print-labels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
itemIds: selectedItems,
labelSize,
includeInfo,
})
})
if (response.ok) {
const result = await response.json()
// Create a new window with the print HTML
const printWindow = window.open('', '_blank')
if (printWindow) {
printWindow.document.write(result.printHtml)
printWindow.document.close()
// Wait a bit for the content to load before showing print dialog
setTimeout(() => {
printWindow.print()
}, 500)
}
toast({
title: "Labels Generated",
description: `Generated ${result.totalLabels} labels for printing`,
})
setIsOpen(false)
setSelectedItems([])
onPrintComplete?.()
} else {
const error = await response.json()
toast({
title: "Error",
description: error.error || "Failed to generate labels",
variant: "destructive",
})
}
} catch (error) {
toast({
title: "Error",
description: "Failed to generate labels",
variant: "destructive",
})
}
setIsGenerating(false)
}
const isAllSelected = items.length > 0 && selectedItems.length === items.length
const isPartiallySelected = selectedItems.length > 0 && selectedItems.length < items.length
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Printer className="mr-2 h-4 w-4" />
Print Labels
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[600px] max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Print Inventory Labels</DialogTitle>
<DialogDescription>
Select items and configure label settings for printing
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* Item Selection */}
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="select-all"
checked={isAllSelected}
onCheckedChange={handleSelectAll}
/>
<Label htmlFor="select-all" className="font-medium">
Select All ({items.length} items)
</Label>
</div>
<div className="max-h-40 overflow-y-auto border rounded-md p-2 space-y-2">
{items.map((item) => (
<div key={item.id} className="flex items-center space-x-2">
<Checkbox
id={`item-${item.id}`}
checked={selectedItems.includes(item.id)}
onCheckedChange={(checked) => handleItemSelect(item.id, checked as boolean)}
/>
<Label htmlFor={`item-${item.id}`} className="text-sm flex-1">
<div className="flex justify-between">
<span>{item.name}</span>
<span className="text-muted-foreground">{item.sku}</span>
</div>
</Label>
</div>
))}
</div>
</div>
<Separator />
{/* Label Settings */}
<div className="space-y-4">
<div className="space-y-2">
<Label className="font-medium">Label Size</Label>
<Select value={labelSize} onValueChange={(value: 'small' | 'medium' | 'large') => setLabelSize(value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="small">Small (1.5" x 1")</SelectItem>
<SelectItem value="medium">Medium (2" x 1.5")</SelectItem>
<SelectItem value="large">Large (3" x 2")</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<Label className="font-medium">Include on Label</Label>
<div className="grid grid-cols-2 gap-3">
<div className="flex items-center space-x-2">
<Checkbox
id="include-name"
checked={includeInfo.name}
onCheckedChange={(checked) => setIncludeInfo({...includeInfo, name: checked as boolean})}
/>
<Label htmlFor="include-name" className="text-sm">Item Name</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="include-sku"
checked={includeInfo.sku}
onCheckedChange={(checked) => setIncludeInfo({...includeInfo, sku: checked as boolean})}
/>
<Label htmlFor="include-sku" className="text-sm">SKU</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="include-quantity"
checked={includeInfo.quantity}
onCheckedChange={(checked) => setIncludeInfo({...includeInfo, quantity: checked as boolean})}
/>
<Label htmlFor="include-quantity" className="text-sm">Quantity</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="include-category"
checked={includeInfo.category}
onCheckedChange={(checked) => setIncludeInfo({...includeInfo, category: checked as boolean})}
/>
<Label htmlFor="include-category" className="text-sm">Category</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="include-location"
checked={includeInfo.location}
onCheckedChange={(checked) => setIncludeInfo({...includeInfo, location: checked as boolean})}
/>
<Label htmlFor="include-location" className="text-sm">Location</Label>
</div>
</div>
</div>
</div>
<Separator />
{/* Preview */}
<div className="space-y-2">
<Label className="font-medium">Preview</Label>
<div className="border rounded-md p-4 bg-muted/50">
<div className="text-center text-sm text-muted-foreground mb-2">
Label will contain QR code and selected information
</div>
<div className="flex justify-center">
<QrCode className="h-16 w-16 text-muted-foreground" />
</div>
</div>
</div>
{/* Actions */}
<div className="flex justify-end space-x-2">
<Button variant="outline" onClick={() => setIsOpen(false)}>
Cancel
</Button>
<Button
onClick={handlePrintLabels}
disabled={selectedItems.length === 0 || isGenerating}
>
<Printer className="mr-2 h-4 w-4" />
{isGenerating ? "Generating..." : `Print ${selectedItems.length} Label${selectedItems.length !== 1 ? 's' : ''}`}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,724 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { Camera, X, RefreshCw, AlertTriangle, CheckCircle, Shield, Settings } from 'lucide-react'
import { useToast } from '@/hooks/use-toast'
import jsqr from 'jsqr'
interface QRScannerDialogProps {
isOpen: boolean
onClose: () => void
onScan: (data: string) => void
}
export default function QRScannerDialog({ isOpen, onClose, onScan }: QRScannerDialogProps) {
const [hasCamera, setHasCamera] = useState(true)
const [cameraError, setCameraError] = useState<string | null>(null)
const [isScanning, setIsScanning] = useState(false)
const [scanResult, setScanResult] = useState<string | null>(null)
const [cameraId, setCameraId] = useState<string | null>(null)
const [cameras, setCameras] = useState<any[]>([])
const [facingMode, setFacingMode] = useState<'environment' | 'user'>('environment')
const [permissionState, setPermissionState] = useState<'unknown' | 'granted' | 'denied' | 'prompt'>('unknown')
const [isRequestingPermission, setIsRequestingPermission] = useState(false)
const [cameraTestStream, setCameraTestStream] = useState<MediaStream | null>(null)
const [isScanningFrame, setIsScanningFrame] = useState(false)
const videoRef = useRef<HTMLVideoElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const scanIntervalRef = useRef<NodeJS.Timeout | null>(null)
const { toast } = useToast()
const scannerRef = useRef<any>(null)
useEffect(() => {
if (isOpen) {
checkCameraPermissions()
setScanResult(null)
setCameraError(null)
}
}, [isOpen])
useEffect(() => {
console.log('isScanning changed:', isScanning)
if (isScanning) {
console.log('Camera ID:', cameraId)
console.log('Facing mode:', facingMode)
console.log('Available cameras:', cameras)
testCameraAccess()
startFrameScanning()
} else {
// Clean up camera test stream
if (cameraTestStream) {
cameraTestStream.getTracks().forEach(track => track.stop())
setCameraTestStream(null)
}
// Stop frame scanning
stopFrameScanning()
}
}, [isScanning, cameraId, facingMode, cameras])
const startFrameScanning = () => {
if (scanIntervalRef.current) {
clearInterval(scanIntervalRef.current)
}
setIsScanningFrame(true)
scanIntervalRef.current = setInterval(() => {
scanFrame()
}, 500) // Scan every 500ms
}
const stopFrameScanning = () => {
if (scanIntervalRef.current) {
clearInterval(scanIntervalRef.current)
scanIntervalRef.current = null
}
setIsScanningFrame(false)
}
const scanFrame = () => {
const video = videoRef.current
const canvas = canvasRef.current
if (!video || !canvas || video.readyState !== video.HAVE_ENOUGH_DATA) {
return
}
const context = canvas.getContext('2d')
if (!context) return
canvas.width = video.videoWidth
canvas.height = video.videoHeight
context.drawImage(video, 0, 0, canvas.width, canvas.height)
const imageData = context.getImageData(0, 0, canvas.width, canvas.height)
const code = jsqr(imageData.data, imageData.width, imageData.height)
if (code) {
console.log('QR Code detected:', code.data)
handleScan(code.data)
}
}
const testCameraAccess = async () => {
try {
console.log('Testing camera access...')
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: facingMode,
width: { ideal: 1280 },
height: { ideal: 720 }
}
})
console.log('Camera test successful:', stream)
setCameraTestStream(stream)
setCameraError(null)
// Set up video element
if (videoRef.current) {
videoRef.current.srcObject = stream
videoRef.current.play().catch(console.error)
}
} catch (error) {
console.error('Camera test failed:', error)
setCameraError('Camera test failed: ' + (error instanceof Error ? error.message : 'Unknown error'))
}
}
const checkCameraPermissions = async () => {
try {
// Check if camera permissions are available
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
throw new Error('Camera not supported in this browser')
}
// Reset states
setHasCamera(true)
setCameraError(null)
// Try to get permissions status
let permissionStatus: PermissionStatus | undefined
if ('permissions' in navigator) {
try {
permissionStatus = await navigator.permissions.query({ name: 'camera' as any })
setPermissionState(permissionStatus.state)
// Listen for permission changes
if (permissionStatus) {
permissionStatus.addEventListener('change', () => {
setPermissionState(permissionStatus!.state)
})
}
} catch (error) {
console.log('Permissions API not available, continuing with device enumeration')
}
}
// Enumerate devices to check camera availability
const devices = await navigator.mediaDevices.enumerateDevices()
const videoDevices = devices.filter(device => device.kind === 'videoinput')
setCameras(videoDevices)
if (videoDevices.length === 0) {
throw new Error('No cameras found')
}
// Try to find back camera first
const backCamera = videoDevices.find(device =>
device.label.toLowerCase().includes('back') ||
device.label.toLowerCase().includes('rear')
)
if (backCamera) {
setCameraId(backCamera.deviceId)
} else {
setCameraId(videoDevices[0].deviceId)
}
// Test camera access with a quick stream test
try {
const testStream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: facingMode,
width: { ideal: 640 },
height: { ideal: 480 }
}
})
testStream.getTracks().forEach(track => track.stop())
setPermissionState('granted')
} catch (testError) {
console.log('Camera test failed, will request permission when needed')
if (permissionState === 'unknown') {
setPermissionState('prompt')
}
}
} catch (error) {
console.error('Camera check failed:', error)
setHasCamera(false)
setCameraError(error instanceof Error ? error.message : 'Camera access failed')
}
}
const requestCameraPermission = async () => {
setIsRequestingPermission(true)
try {
console.log('Starting camera permission request...')
// Try to access camera to trigger permission request
// Start with basic video request, then try more specific constraints
let stream: MediaStream | null = null
try {
console.log('Attempting basic video request...')
stream = await navigator.mediaDevices.getUserMedia({ video: true })
console.log('Basic video request successful')
} catch (initialError) {
console.log('Basic video request failed:', initialError)
// If basic request fails, try with specific constraints
try {
console.log('Attempting specific constraints...')
stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment',
width: { ideal: 1280 },
height: { ideal: 720 }
}
})
console.log('Specific constraints successful')
} catch (specificError) {
console.log('Specific constraints failed:', specificError)
// If specific constraints fail, try one more time with minimal constraints
stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 640 },
height: { ideal: 480 }
}
})
console.log('Minimal constraints successful')
}
}
if (stream) {
console.log('Camera stream obtained successfully')
// Stop the stream immediately after getting permission
stream.getTracks().forEach(track => track.stop())
setPermissionState('granted')
setIsScanning(true)
setCameraError(null)
toast({
title: "Camera Access Granted",
description: "You can now scan QR codes",
})
} else {
throw new Error('Failed to obtain camera stream')
}
} catch (error: any) {
console.error('Permission request failed:', error)
console.error('Error details:', {
name: error.name,
message: error.message,
code: error.code,
constraint: error.constraint
})
if (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError') {
setPermissionState('denied')
setCameraError('Camera permission denied. Please enable camera permissions in your browser settings.')
} else if (error.name === 'NotFoundError' || error.name === 'DevicesNotFoundError') {
setCameraError('No camera found on this device.')
} else if (error.name === 'TypeError' && error.message.includes('at least one of audio and video')) {
setCameraError('Camera configuration error. Please try again.')
} else {
setCameraError('Failed to access camera: ' + error.message)
}
toast({
title: "Camera Access Failed",
description: "Please check your camera permissions",
variant: "destructive",
})
} finally {
setIsRequestingPermission(false)
}
}
const handleScan = (data: string | null) => {
console.log('QR Scanner detected data:', data)
if (data) {
console.log('QR Code scanned successfully:', data)
setScanResult(data)
setIsScanning(false)
// Vibrate if available (mobile feedback)
if ('vibrate' in navigator) {
navigator.vibrate(200)
}
toast({
title: "QR Code Scanned",
description: "Processing inventory update...",
})
// Process the scan after a short delay
setTimeout(() => {
onScan(data)
onClose()
}, 1000)
}
}
const handleError = (error: any) => {
console.error('QR Scanner error:', error)
console.error('Error details:', {
name: error?.name,
message: error?.message,
code: error?.code,
constraint: error?.constraint,
stack: error?.stack
})
let errorMessage = 'Camera access failed'
let errorTitle = 'Camera Error'
if (error?.name === 'NotAllowedError' || error?.name === 'PermissionDeniedError') {
setPermissionState('denied')
errorMessage = 'Camera permission denied. Please enable camera permissions to scan QR codes.'
errorTitle = 'Camera Permission Denied'
} else if (error?.name === 'NotFoundError' || error?.name === 'DevicesNotFoundError') {
errorMessage = 'No camera found on this device.'
} else if (error?.name === 'NotReadableError' || error?.name === 'TrackStartError') {
errorMessage = 'Camera is already in use by another application.'
} else if (error?.name === 'OverconstrainedError' || error?.name === 'ConstraintNotSatisfiedError') {
errorMessage = 'Camera constraints not supported. Trying with different settings...'
// Try to restart with simpler constraints
setTimeout(() => {
setFacingMode('environment')
setIsScanning(false)
setTimeout(() => setIsScanning(true), 500)
}, 1000)
} else if (error?.name === 'TypeError' && error?.message?.includes('at least one of audio and video')) {
errorMessage = 'Camera configuration error. Please refresh the page and try again.'
} else if (error?.message) {
errorMessage = `Camera error: ${error.message}`
}
setCameraError(errorMessage)
setIsScanning(false)
toast({
title: errorTitle,
description: errorMessage,
variant: "destructive",
})
}
const startScanning = () => {
console.log('startScanning called, permissionState:', permissionState)
if (permissionState === 'denied') {
// Show guidance for enabling permissions
toast({
title: "Camera Permission Required",
description: "Please enable camera permissions in your browser settings",
variant: "destructive",
})
return
}
if (permissionState === 'unknown' || permissionState === 'prompt') {
console.log('Requesting camera permission...')
requestCameraPermission()
} else {
console.log('Starting scanner with permissionState:', permissionState)
setScanResult(null)
setCameraError(null)
setIsScanning(true)
}
}
const switchCamera = async () => {
const newFacingMode = facingMode === 'environment' ? 'user' : 'environment'
setFacingMode(newFacingMode)
if (isScanning && cameraTestStream) {
// Stop current stream
cameraTestStream.getTracks().forEach(track => track.stop())
setCameraTestStream(null)
// Start new stream with new facing mode
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: newFacingMode,
width: { ideal: 1280 },
height: { ideal: 720 }
}
})
setCameraTestStream(stream)
if (videoRef.current) {
videoRef.current.srcObject = stream
videoRef.current.play().catch(console.error)
}
toast({
title: "Camera Switched",
description: `Switched to ${newFacingMode === 'environment' ? 'back' : 'front'} camera`,
})
} catch (error) {
console.error('Failed to switch camera:', error)
toast({
title: "Camera Switch Failed",
description: "Unable to switch camera",
variant: "destructive",
})
}
}
}
const handleManualInput = () => {
const qrCode = prompt('Enter QR code manually:')
if (qrCode) {
console.log('Manual QR code entry:', qrCode)
onScan(qrCode)
onClose()
}
}
const tryAlternativeCamera = async () => {
setIsRequestingPermission(true)
try {
// Try with different constraints
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: facingMode === 'environment' ? 'user' : 'environment',
width: { ideal: 640 },
height: { ideal: 480 }
}
})
stream.getTracks().forEach(track => track.stop())
// Switch facing mode and restart scanning
setFacingMode(prev => prev === 'environment' ? 'user' : 'environment')
setPermissionState('granted')
setIsScanning(true)
setCameraError(null)
toast({
title: "Camera Switched",
description: "Trying alternative camera...",
})
} catch (error: any) {
console.error('Alternative camera failed:', error)
setCameraError('Alternative camera also failed. Please check device permissions.')
toast({
title: "Camera Switch Failed",
description: "Unable to access alternative camera",
variant: "destructive",
})
} finally {
setIsRequestingPermission(false)
}
}
const openBrowserSettings = () => {
// Provide guidance for opening browser settings
const isAndroid = /Android/i.test(navigator.userAgent)
const isChrome = /Chrome/i.test(navigator.userAgent)
let message = "To enable camera permissions:\n\n"
if (isAndroid && isChrome) {
message += "1. Tap the three dots (⋮) in Chrome\n"
message += "2. Go to Settings → Site settings → Camera\n"
message += "3. Find this site and allow camera access\n"
message += "4. Refresh the page and try again"
} else if (isAndroid) {
message += "1. Go to your browser's settings\n"
message += "2. Find site permissions or privacy settings\n"
message += "3. Enable camera access for this site\n"
message += "4. Refresh the page and try again"
} else {
message += "1. Click the lock/info icon in the address bar\n"
message += "2. Find camera permissions and allow access\n"
message += "3. Refresh the page and try again"
}
alert(message)
}
if (!hasCamera) {
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>Camera Not Available</DialogTitle>
<DialogDescription>
{cameraError || 'Unable to access camera on this device'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="text-center">
<AlertTriangle className="h-16 w-16 mx-auto mb-4 text-red-500" />
<p className="text-sm text-muted-foreground mb-4">
Your device doesn't have a camera or camera access is blocked.
</p>
</div>
<div className="flex flex-col gap-2">
<Button onClick={handleManualInput} variant="outline">
Enter QR Code Manually
</Button>
<Button onClick={onClose} variant="secondary">
Close
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-hidden">
<DialogHeader>
<DialogTitle>Scan QR Code</DialogTitle>
<DialogDescription>
Point your camera at a QR code to scan it
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Permission Status */}
{permissionState === 'denied' && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<Shield className="h-5 w-5 text-red-600 mt-0.5" />
<div className="flex-1">
<h4 className="font-medium text-red-800 mb-1">Camera Permission Denied</h4>
<p className="text-sm text-red-700 mb-3">
Camera access was denied. You need to enable camera permissions for this site.
</p>
<Button
onClick={openBrowserSettings}
variant="outline"
size="sm"
className="w-full"
>
<Settings className="mr-2 h-4 w-4" />
Open Browser Settings
</Button>
</div>
</div>
</div>
)}
{permissionState === 'unknown' && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<Shield className="h-5 w-5 text-blue-600 mt-0.5" />
<div className="flex-1">
<h4 className="font-medium text-blue-800 mb-1">Camera Permission Required</h4>
<p className="text-sm text-blue-700">
This app needs camera access to scan QR codes. Tap "Start Scanning" to enable camera permissions.
</p>
</div>
</div>
</div>
)}
{/* Camera View */}
<div className="relative aspect-square bg-black rounded-lg overflow-hidden">
{isScanning ? (
<div className="w-full h-full">
{/* Custom QR Scanner with video element */}
<video
ref={videoRef}
autoPlay
playsInline
muted
className="w-full h-full object-cover"
style={{ transform: facingMode === 'user' ? 'scaleX(-1)' : 'none' }}
/>
{/* Hidden canvas for QR processing */}
<canvas ref={canvasRef} className="hidden" />
{/* Scanning overlay */}
<div className="absolute inset-0 pointer-events-none">
<div className="absolute inset-0 border-4 border-green-400 opacity-30"></div>
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-48 h-48 border-2 border-green-400 rounded-lg"></div>
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-56 h-1 bg-green-400 animate-pulse"></div>
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-1 h-56 bg-green-400 animate-pulse"></div>
{/* Scanning indicator */}
{isScanningFrame && (
<div className="absolute top-4 right-4">
<Badge variant="secondary" className="bg-green-100 text-green-800">
Scanning...
</Badge>
</div>
)}
</div>
{/* Mobile controls */}
<div className="absolute bottom-4 left-0 right-0 flex justify-between px-4">
<Button
onClick={switchCamera}
variant="secondary"
size="sm"
className="rounded-full w-12 h-12"
>
<RefreshCw className="h-5 w-5" />
</Button>
<Button
onClick={() => setIsScanning(false)}
variant="destructive"
size="sm"
className="rounded-full w-12 h-12"
>
<X className="h-5 w-5" />
</Button>
</div>
</div>
) : scanResult ? (
<div className="w-full h-full flex items-center justify-center bg-green-50">
<div className="text-center">
<CheckCircle className="h-16 w-16 mx-auto mb-4 text-green-500" />
<p className="text-sm text-green-700 mb-2">QR Code Scanned!</p>
<Badge variant="secondary" className="text-xs">
{scanResult.substring(0, 30)}...
</Badge>
</div>
</div>
) : (
<div className="w-full h-full flex items-center justify-center bg-muted">
<div className="text-center p-4">
<Camera className="h-16 w-16 mx-auto mb-4 text-muted-foreground" />
<p className="text-sm text-muted-foreground mb-4">Ready to scan</p>
<div className="space-y-2 text-xs text-muted-foreground">
<p> Ensure good lighting</p>
<p> Hold camera steady</p>
<p> Position QR code within frame</p>
<p> Allow camera permissions if prompted</p>
</div>
</div>
</div>
)}
</div>
{/* Error display */}
{cameraError && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
<div className="flex items-center gap-2 text-red-700">
<AlertTriangle className="h-4 w-4" />
<span className="text-sm">{cameraError}</span>
</div>
</div>
)}
{/* Controls */}
<div className="flex flex-col gap-2">
{!isScanning && !scanResult && (
<Button
onClick={startScanning}
className="w-full"
size="lg"
disabled={isRequestingPermission}
>
{isRequestingPermission ? (
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
) : (
<Camera className="mr-2 h-4 w-4" />
)}
{isRequestingPermission ? "Requesting Permission..." : "Start Scanning"}
</Button>
)}
<Button
onClick={handleManualInput}
variant="outline"
className="w-full"
>
Enter QR Code Manually
</Button>
{cameraError && (
<Button
onClick={tryAlternativeCamera}
variant="outline"
className="w-full"
disabled={isRequestingPermission}
>
{isRequestingPermission ? (
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
Try Alternative Camera
</Button>
)}
<Button
onClick={onClose}
variant="secondary"
className="w-full"
>
Close
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,211 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Plus, Minus, Package } from 'lucide-react'
interface QuantityAdjustDialogProps {
isOpen: boolean
onClose: () => void
item: {
id: string
name: string
sku: string
quantity: number
category?: string
location?: string
}
onConfirm: (quantity: number, action: 'set' | 'add' | 'subtract') => void
}
export default function QuantityAdjustDialog({ isOpen, onClose, item, onConfirm }: QuantityAdjustDialogProps) {
const [quantity, setQuantity] = useState('')
const [action, setAction] = useState<'set' | 'add' | 'subtract'>('add')
const [inputError, setInputError] = useState('')
const handleSubmit = () => {
const qty = parseInt(quantity)
if (isNaN(qty) || qty < 0) {
setInputError('Please enter a valid positive number')
return
}
if (action === 'subtract' && qty > item.quantity) {
setInputError('Cannot subtract more than current quantity')
return
}
onConfirm(qty, action)
onClose()
setQuantity('')
setInputError('')
}
const handleQuickAction = (value: number, actionType: 'add' | 'subtract') => {
onConfirm(Math.abs(value), actionType)
onClose()
setQuantity('')
setInputError('')
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Package className="h-5 w-5" />
Adjust Inventory Quantity
</DialogTitle>
<DialogDescription>
Update the quantity for {item.name}
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* Item Info */}
<div className="bg-muted/50 p-4 rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="font-medium">{item.name}</span>
<Badge variant="secondary">
{item.quantity} in stock
</Badge>
</div>
<div className="text-sm text-muted-foreground">
SKU: {item.sku}
{item.category && ` • Category: ${item.category}`}
{item.location && ` • Location: ${item.location}`}
</div>
</div>
{/* Quick Actions */}
<div className="space-y-3">
<Label className="text-sm font-medium">Quick Actions</Label>
<div className="grid grid-cols-2 gap-2">
<Button
variant="outline"
onClick={() => handleQuickAction(1, 'add')}
className="flex items-center gap-2"
>
<Plus className="h-4 w-4" />
+1
</Button>
<Button
variant="outline"
onClick={() => handleQuickAction(5, 'add')}
className="flex items-center gap-2"
>
<Plus className="h-4 w-4" />
+5
</Button>
<Button
variant="outline"
onClick={() => handleQuickAction(1, 'subtract')}
className="flex items-center gap-2"
>
<Minus className="h-4 w-4" />
-1
</Button>
<Button
variant="outline"
onClick={() => handleQuickAction(5, 'subtract')}
className="flex items-center gap-2"
>
<Minus className="h-4 w-4" />
-5
</Button>
</div>
</div>
{/* Manual Entry */}
<div className="space-y-3">
<Label className="text-sm font-medium">Manual Entry</Label>
{/* Action Selection */}
<div className="flex gap-2">
<Button
variant={action === 'add' ? 'default' : 'outline'}
size="sm"
onClick={() => setAction('add')}
className="flex-1"
>
Add Quantity
</Button>
<Button
variant={action === 'subtract' ? 'default' : 'outline'}
size="sm"
onClick={() => setAction('subtract')}
className="flex-1"
>
Subtract Quantity
</Button>
<Button
variant={action === 'set' ? 'default' : 'outline'}
size="sm"
onClick={() => setAction('set')}
className="flex-1"
>
Set Exact
</Button>
</div>
{/* Quantity Input */}
<div className="space-y-2">
<Label htmlFor="quantity">
{action === 'add' ? 'Quantity to Add' :
action === 'subtract' ? 'Quantity to Subtract' :
'Set Quantity To'}
</Label>
<Input
id="quantity"
type="number"
min="0"
placeholder={action === 'set' ? 'Enter exact quantity' : 'Enter quantity'}
value={quantity}
onChange={(e) => {
setQuantity(e.target.value)
setInputError('')
}}
className={inputError ? 'border-red-500' : ''}
/>
{inputError && (
<p className="text-sm text-red-500">{inputError}</p>
)}
</div>
{/* Preview */}
{quantity && !inputError && (
<div className="bg-muted/30 p-3 rounded-lg">
<p className="text-sm">
{action === 'add' && (
<span>New quantity will be: <span className="font-medium">{item.quantity + parseInt(quantity)}</span></span>
)}
{action === 'subtract' && (
<span>New quantity will be: <span className="font-medium">{item.quantity - parseInt(quantity)}</span></span>
)}
{action === 'set' && (
<span>Quantity will be set to: <span className="font-medium">{parseInt(quantity)}</span></span>
)}
</p>
</div>
)}
</div>
{/* Actions */}
<div className="flex gap-2">
<Button onClick={handleSubmit} className="flex-1">
Confirm Update
</Button>
<Button variant="outline" onClick={onClose} className="flex-1">
Cancel
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,308 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { Camera, X, RefreshCw, AlertTriangle, Keyboard } from 'lucide-react'
import { useToast } from '@/hooks/use-toast'
interface SimpleQRScannerProps {
isOpen: boolean
onClose: () => void
onScan: (data: string) => void
}
export default function SimpleQRScanner({ isOpen, onClose, onScan }: SimpleQRScannerProps) {
const [hasCamera, setHasCamera] = useState(true)
const [cameraError, setCameraError] = useState<string | null>(null)
const [isScanning, setIsScanning] = useState(false)
const [permissionState, setPermissionState] = useState<'unknown' | 'granted' | 'denied' | 'prompt'>('unknown')
const [isRequestingPermission, setIsRequestingPermission] = useState(false)
const videoRef = useRef<HTMLVideoElement>(null)
const { toast } = useToast()
const checkCameraPermissions = async () => {
try {
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
throw new Error('Camera not supported in this browser')
}
setHasCamera(true)
setCameraError(null)
// Check for camera devices
const devices = await navigator.mediaDevices.enumerateDevices()
const videoDevices = devices.filter(device => device.kind === 'videoinput')
if (videoDevices.length === 0) {
throw new Error('No cameras found')
}
// Try to get permissions
let permissionStatus: PermissionStatus | undefined
if ('permissions' in navigator) {
try {
permissionStatus = await navigator.permissions.query({ name: 'camera' as any })
setPermissionState(permissionStatus.state)
} catch (error) {
console.log('Permissions API not available')
}
}
// Test camera access
try {
const testStream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment',
width: { ideal: 640 },
height: { ideal: 480 }
}
})
testStream.getTracks().forEach(track => track.stop())
setPermissionState('granted')
} catch (testError) {
console.log('Camera test failed')
if (permissionState === 'unknown') {
setPermissionState('prompt')
}
}
} catch (error) {
console.error('Camera check failed:', error)
setHasCamera(false)
setCameraError(error instanceof Error ? error.message : 'Camera access failed')
}
}
const requestCameraPermission = async () => {
setIsRequestingPermission(true)
try {
let stream: MediaStream | null = null
try {
stream = await navigator.mediaDevices.getUserMedia({ video: true })
} catch (error) {
// Try with specific constraints
stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment',
width: { ideal: 640 },
height: { ideal: 480 }
}
})
}
if (stream) {
stream.getTracks().forEach(track => track.stop())
setPermissionState('granted')
setIsScanning(true)
setCameraError(null)
toast({
title: "Camera Access Granted",
description: "You can now scan QR codes",
})
} else {
throw new Error('Failed to obtain camera stream')
}
} catch (error: any) {
console.error('Permission request failed:', error)
if (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError') {
setPermissionState('denied')
setCameraError('Camera permission denied. Please enable camera permissions in your browser settings.')
} else if (error.name === 'NotFoundError' || error.name === 'DevicesNotFoundError') {
setCameraError('No camera found on this device.')
} else {
setCameraError('Failed to access camera: ' + error.message)
}
toast({
title: "Camera Access Failed",
description: "Please check your camera permissions",
variant: "destructive",
})
} finally {
setIsRequestingPermission(false)
}
}
const startScanning = () => {
if (permissionState === 'denied') {
toast({
title: "Camera Permission Required",
description: "Please enable camera permissions in your browser settings",
variant: "destructive",
})
return
}
if (permissionState === 'unknown' || permissionState === 'prompt') {
requestCameraPermission()
} else {
setIsScanning(true)
}
}
const handleManualInput = () => {
const qrCode = prompt('Enter QR code manually:')
if (qrCode) {
onScan(qrCode)
onClose()
}
}
const simulateScan = () => {
// Simulate a QR code scan for testing
const testQRCodes = [
'INV-TEST001-1234567890-1',
'INV-TEST002-1234567890-2',
'INV-TEST003-1234567890-3'
]
const randomQR = testQRCodes[Math.floor(Math.random() * testQRCodes.length)]
toast({
title: "QR Code Scanned",
description: "Processing inventory update...",
})
setTimeout(() => {
onScan(randomQR)
onClose()
}, 1000)
}
useEffect(() => {
if (isOpen) {
checkCameraPermissions()
}
}, [isOpen])
useEffect(() => {
if (isScanning && videoRef.current) {
// In a real implementation, you would set up the video stream here
// For now, we'll just show a placeholder
console.log('Camera would start here')
}
}, [isScanning])
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>QR Code Scanner</DialogTitle>
<DialogDescription>
Scan QR codes to update inventory quantities
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Camera Preview */}
<Card>
<CardHeader>
<CardTitle className="text-sm">Camera Preview</CardTitle>
</CardHeader>
<CardContent>
<div className="relative aspect-video bg-muted rounded-lg flex items-center justify-center">
{isScanning ? (
<div className="text-center">
<div className="w-16 h-16 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-2"></div>
<p className="text-sm text-muted-foreground">Camera active - Point at QR code</p>
</div>
) : (
<div className="text-center">
<Camera className="w-16 h-16 mx-auto mb-2 text-muted-foreground" />
<p className="text-sm text-muted-foreground">Camera not started</p>
</div>
)}
</div>
</CardContent>
</Card>
{/* Status */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Badge variant={hasCamera ? "default" : "destructive"}>
{hasCamera ? "Camera Available" : "No Camera"}
</Badge>
<Badge variant={
permissionState === 'granted' ? "default" :
permissionState === 'denied' ? "destructive" : "secondary"
}>
{permissionState === 'granted' ? "Permission Granted" :
permissionState === 'denied' ? "Permission Denied" :
permissionState === 'prompt' ? "Permission Required" : "Checking..."}
</Badge>
</div>
</div>
{/* Error Message */}
{cameraError && (
<Card className="border-red-200 bg-red-50">
<CardContent className="pt-6">
<div className="flex items-center space-x-2 text-red-800">
<AlertTriangle className="w-4 h-4" />
<p className="text-sm">{cameraError}</p>
</div>
</CardContent>
</Card>
)}
{/* Action Buttons */}
<div className="flex flex-col space-y-2">
<Button
onClick={startScanning}
disabled={!hasCamera || isRequestingPermission || isScanning}
className="w-full"
>
{isRequestingPermission ? (
<>
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
Requesting Permission...
</>
) : isScanning ? (
<>
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
Scanning...
</>
) : (
<>
<Camera className="mr-2 h-4 w-4" />
Start Camera
</>
)}
</Button>
<Button
onClick={simulateScan}
variant="outline"
className="w-full"
>
Simulate Scan (For Testing)
</Button>
<Button
onClick={handleManualInput}
variant="outline"
className="w-full"
>
<Keyboard className="mr-2 h-4 w-4" />
Manual Entry
</Button>
<Button
onClick={onClose}
variant="outline"
className="w-full"
>
<X className="mr-2 h-4 w-4" />
Close
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@ -0,0 +1,157 @@
"use client"
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"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@ -0,0 +1,66 @@
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 px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@ -0,0 +1,11 @@
"use client"
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
}
export { AspectRatio }

View File

@ -0,0 +1,53 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }

View File

@ -0,0 +1,46 @@
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 badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

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

View File

@ -0,0 +1,59 @@
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 transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@ -0,0 +1,213 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View File

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@ -0,0 +1,241 @@
"use client"
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
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
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
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

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

@ -0,0 +1,353 @@
"use client"
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
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.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-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 flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
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
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}) {
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
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border 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(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
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="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
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(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
// 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,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@ -0,0 +1,33 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@ -0,0 +1,184 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[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>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@ -0,0 +1,252 @@
"use client"
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
)
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"bg-popover 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 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"bg-popover 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 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

View File

@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@ -0,0 +1,135 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover 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 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover 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 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

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

@ -0,0 +1,167 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} 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 } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
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
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@ -0,0 +1,44 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover 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 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@ -0,0 +1,77 @@
"use client"
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { MinusIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"flex items-center gap-2 has-disabled:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
)
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn("flex items-center", className)}
{...props}
/>
)
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number
}) {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
)
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
)
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

View File

@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@ -0,0 +1,276 @@
"use client"
import * as React from "react"
import * as MenubarPrimitive from "@radix-ui/react-menubar"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Menubar({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
return (
<MenubarPrimitive.Root
data-slot="menubar"
className={cn(
"bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
className
)}
{...props}
/>
)
}
function MenubarMenu({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return (
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
)
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
return (
<MenubarPrimitive.Trigger
data-slot="menubar-trigger"
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
className
)}
{...props}
/>
)
}
function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
return (
<MenubarPortal>
<MenubarPrimitive.Content
data-slot="menubar-content"
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground 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 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</MenubarPortal>
)
}
function MenubarItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenubarPrimitive.Item
data-slot="menubar-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function MenubarCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
return (
<MenubarPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
)
}
function MenubarRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
return (
<MenubarPrimitive.RadioItem
data-slot="menubar-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
)
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.Label
data-slot="menubar-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
return (
<MenubarPrimitive.Separator
data-slot="menubar-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="menubar-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function MenubarSub({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
}
function MenubarSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.SubTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
)
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
return (
<MenubarPrimitive.SubContent
data-slot="menubar-sub-content"
className={cn(
"bg-popover 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 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
}

View File

@ -0,0 +1,168 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
)
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-1",
className
)}
{...props}
/>
)
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
)
}
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1"
)
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
)
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"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 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
className
)}
{...props}
/>
)
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center"
)}
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
{...props}
/>
</div>
)
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
className
)}
{...props}
>
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator>
)
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
}

View File

@ -0,0 +1,127 @@
import * as React from "react"
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
)
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
)
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
)
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}

View File

@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover 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 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }

View File

@ -0,0 +1,45 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }

View File

@ -0,0 +1,56 @@
"use client"
import * as React from "react"
import { GripVerticalIcon } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden 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-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }

View File

@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@ -0,0 +1,185 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover 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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
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)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

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

@ -0,0 +1,139 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@ -0,0 +1,726 @@
"use client"
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, VariantProps } from "class-variance-authority"
import { PanelLeftIcon } 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,
SheetDescription,
SheetHeader,
SheetTitle,
} 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 SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
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<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative 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)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
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)+(--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"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
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}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] 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}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden 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",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
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>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after: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 &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"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}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
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>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"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}
/>
)
}
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,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@ -0,0 +1,63 @@
"use client"
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
)
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider }

View File

@ -0,0 +1,25 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

View File

@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

116
src/components/ui/table.tsx Normal file
View File

@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

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

@ -0,0 +1,129 @@
"use client"
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-2 overflow-hidden rounded-md border p-4 pr-6 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 transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
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-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 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 [&+div]:text-xs", 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,35 @@
"use client"
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,73 @@
"use client"
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",
})
function ToggleGroup({
className,
variant,
size,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
className={cn(
"group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}
export { ToggleGroup, ToggleGroupItem }

View File

@ -0,0 +1,47 @@
"use client"
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 gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }

View File

@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground 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 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

19
src/hooks/use-mobile.ts 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
}

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

@ -0,0 +1,194 @@
"use client"
// Inspired by react-hot-toast library
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 }

38
src/lib/auth.ts Normal file
View File

@ -0,0 +1,38 @@
/// Add this utility function
export const getAuthHeaders = () => {
const token = typeof window !== 'undefined' ? localStorage.getItem('authToken') : null
const headers: Record<string, string> = {
'Content-Type': 'application/json'
}
// Only add Authorization header if we have a valid token
if (token && token !== 'null' && token !== 'undefined') {
headers['Authorization'] = `Bearer ${token}`
}
return headers
}
// Helper function to check if user is authenticated
export const isAuthenticated = () => {
return typeof window !== 'undefined' && !!localStorage.getItem('authToken')
}
// Helper function to get current user token
export const getAuthToken = () => {
return typeof window !== 'undefined' ? localStorage.getItem('authToken') : null
}
// Helper function to set auth token
export const setAuthToken = (token: string) => {
if (typeof window !== 'undefined') {
localStorage.setItem('authToken', token)
}
}
// Helper function to clear auth token
export const clearAuthToken = () => {
if (typeof window !== 'undefined') {
localStorage.removeItem('authToken')
}
}

261
src/lib/camera-utils.ts Normal file
View File

@ -0,0 +1,261 @@
/**
* Camera utility functions for mobile device optimization
*/
export interface CameraConstraints {
video: MediaTrackConstraints
}
export interface CameraDevice {
deviceId: string
label: string
kind: string
facingMode?: 'user' | 'environment' | 'left' | 'right'
}
/**
* Detect if the current device is Android
*/
export function isAndroid(): boolean {
if (typeof navigator === 'undefined' || !navigator.userAgent) {
return false
}
return /Android/i.test(navigator.userAgent)
}
/**
* Detect if the current device is iOS
*/
export function isIOS(): boolean {
if (typeof navigator === 'undefined' || !navigator.userAgent || typeof window === 'undefined') {
return false
}
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream
}
/**
* Detect if the current device is mobile
*/
export function isMobile(): boolean {
if (typeof navigator === 'undefined' || !navigator.userAgent) {
return false
}
return isAndroid() || isIOS() || /Mobile|Tablet|iPad|iPhone|Android/i.test(navigator.userAgent)
}
/**
* Get optimized camera constraints for different devices
*/
export function getOptimizedCameraConstraints(
facingMode: 'environment' | 'user' = 'environment',
deviceId?: string
): CameraConstraints {
const isAndroidDevice = isAndroid()
const isIOSDevice = isIOS()
const isMobileDevice = isMobile()
const baseConstraints: MediaTrackConstraints = {
facingMode,
width: { ideal: 1280 },
height: { ideal: 720 },
frameRate: { ideal: 30 }
}
// Android-specific optimizations
if (isAndroidDevice) {
const androidConstraints: MediaTrackConstraints = {
...baseConstraints,
width: { ideal: 1280, max: 1920 },
height: { ideal: 720, max: 1080 },
frameRate: { ideal: 30, max: 60 }
}
// Add Android-specific constraints if supported (using type assertion)
const enhancedConstraints = androidConstraints as any
enhancedConstraints.whiteBalanceMode = 'continuous'
enhancedConstraints.exposureMode = 'continuous'
enhancedConstraints.focusMode = 'continuous'
enhancedConstraints.torch = false
return {
video: enhancedConstraints
}
}
// iOS-specific optimizations
if (isIOSDevice) {
return {
video: {
...baseConstraints,
width: { ideal: 1280 },
height: { ideal: 720 },
frameRate: { ideal: 30 }
}
}
}
// Desktop/default constraints
if (deviceId) {
return {
video: {
...baseConstraints,
deviceId: { exact: deviceId }
}
}
}
return {
video: baseConstraints
}
}
/**
* Get fallback camera constraints for when optimal constraints fail
* Returns an array of progressively simpler camera constraints
*/
export function getFallbackCameraConstraints(): CameraConstraints {
const fallbacks: CameraConstraints[] = [
// Basic video constraints
{ video: { facingMode: 'environment' } },
// Minimal constraints
{ video: { width: { ideal: 640 }, height: { ideal: 480 } } },
// Very minimal
{ video: { width: { ideal: 320 }, height: { ideal: 240 } } },
// Most basic
{ video: {} }
]
return fallbacks[0] // Return the first fallback, actual iteration should be done by caller
}
/**
* Get available cameras with facing mode detection
*/
export async function getAvailableCameras(): Promise<CameraDevice[]> {
try {
if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
throw new Error('Camera not supported in this browser')
}
const devices = await navigator.mediaDevices.enumerateDevices()
const videoDevices = devices.filter(device => device.kind === 'videoinput')
return videoDevices.map(device => ({
deviceId: device.deviceId,
label: device.label || `Camera ${device.deviceId.slice(0, 8)}`,
kind: device.kind,
facingMode: detectFacingMode(device.label)
}))
} catch (error) {
console.error('Error getting available cameras:', error)
return []
}
}
/**
* Detect camera facing mode from device label
*/
function detectFacingMode(label: string): 'user' | 'environment' | 'left' | 'right' | undefined {
const lowerLabel = label.toLowerCase()
if (lowerLabel.includes('front') || lowerLabel.includes('user') || lowerLabel.includes('selfie')) {
return 'user'
}
if (lowerLabel.includes('back') || lowerLabel.includes('rear') || lowerLabel.includes('environment')) {
return 'environment'
}
if (lowerLabel.includes('left')) {
return 'left'
}
if (lowerLabel.includes('right')) {
return 'right'
}
return undefined
}
/**
* Get the best camera for QR code scanning
*/
export async function getBestCameraForQR(): Promise<string | null> {
try {
const cameras = await getAvailableCameras()
// Prefer back camera for QR scanning
const backCamera = cameras.find(camera =>
camera.facingMode === 'environment' ||
camera.label.toLowerCase().includes('back') ||
camera.label.toLowerCase().includes('rear')
)
if (backCamera) {
return backCamera.deviceId
}
// Fall back to first available camera
return cameras.length > 0 ? cameras[0].deviceId : null
} catch (error) {
console.error('Error getting best camera:', error)
return null
}
}
/**
* Check if camera permissions are granted
*/
export async function checkCameraPermission(): Promise<'granted' | 'denied' | 'prompt' | 'unknown'> {
try {
if (typeof navigator === 'undefined' || !('permissions' in navigator)) {
return 'unknown'
}
const permissionStatus = await navigator.permissions.query({ name: 'camera' as any })
return permissionStatus.state
} catch (error) {
console.error('Error checking camera permission:', error)
return 'unknown'
}
}
/**
* Test camera access with given constraints
*/
export async function testCameraAccess(constraints: MediaStreamConstraints): Promise<boolean> {
try {
if (typeof navigator === 'undefined' || !navigator.mediaDevices) {
return false
}
const stream = await navigator.mediaDevices.getUserMedia(constraints)
stream.getTracks().forEach(track => track.stop())
return true
} catch (error) {
console.error('Camera test failed:', error)
return false
}
}
/**
* Get device-specific camera settings
*/
export function getDeviceSpecificSettings() {
const isAndroidDevice = isAndroid()
const isIOSDevice = isIOS()
const isMobileDevice = isMobile()
return {
isAndroid: isAndroidDevice,
isIOS: isIOSDevice,
isMobile: isMobileDevice,
scanInterval: isAndroidDevice ? 300 : 500, // Faster scanning on Android
videoAttributes: {
playsinline: isMobileDevice,
muted: isMobileDevice,
autoplay: true
},
permissionHelpText: isAndroidDevice
? "Please check app permissions and restart browser if camera doesn't work"
: "Please enable camera permissions in your browser settings"
}
}

13
src/lib/db.ts Normal file
View File

@ -0,0 +1,13 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const db =
globalForPrisma.prisma ??
new PrismaClient({
log: ['query'],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db

23
src/lib/rateLimit.ts Normal file
View File

@ -0,0 +1,23 @@
// src/lib/rateLimit.ts
const rateLimits = new Map<string, { count: number; resetTime: number }>()
export function rateLimit(
ip: string,
limit: number = 100,
windowMs: number = 60000 // 1 minute
): { success: boolean; remaining: number; resetTime: number } {
const now = Date.now()
const record = rateLimits.get(ip)
if (!record || now > record.resetTime) {
rateLimits.set(ip, { count: 1, resetTime: now + windowMs })
return { success: true, remaining: limit - 1, resetTime: now + windowMs }
}
if (record.count >= limit) {
return { success: false, remaining: 0, resetTime: record.resetTime }
}
record.count++
return { success: true, remaining: limit - record.count, resetTime: record.resetTime }
}

30
src/lib/rbac.ts Normal file
View File

@ -0,0 +1,30 @@
// src/lib/rbac.ts
export enum Role {
ADMIN = 'ADMIN',
MANAGER = 'MANAGER',
USER = 'USER'
}
export const permissions = {
[Role.ADMIN]: [
'inventory:read',
'inventory:write',
'inventory:delete',
'users:read',
'users:write',
'users:delete',
'system:admin'
],
[Role.MANAGER]: [
'inventory:read',
'inventory:write',
'users:read'
],
[Role.USER]: [
'inventory:read'
]
}
export function hasPermission(userRole: Role, permission: string): boolean {
return permissions[userRole]?.includes(permission) || false
}

61
src/lib/server-auth.ts Normal file
View File

@ -0,0 +1,61 @@
import { NextRequest } from 'next/server'
import { verify } from 'jsonwebtoken'
import { Role } from './rbac'
export interface AuthUser {
userId: string
email: string
role: Role
}
export interface AuthSession {
user: AuthUser
}
export async function getServerSession(request: NextRequest): Promise<AuthSession | null> {
try {
const authHeader = request.headers.get('authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return null
}
const token = authHeader.substring(7).trim()
// Check for null, undefined, empty string, or literal "null"/"undefined"
if (!token || token === 'null' || token === 'undefined' || token.length === 0) {
return null
}
// Verify JWT token
const decoded = verify(
token,
process.env.NEXTAUTH_SECRET || 'fallback-secret'
) as any
if (!decoded || !decoded.userId || !decoded.email || !decoded.role) {
return null
}
return {
user: {
userId: decoded.userId,
email: decoded.email,
role: decoded.role
}
}
} catch (error) {
console.error('Auth verification error:', error)
return null
}
}
export async function authenticateRequest(request: NextRequest): Promise<{ success: boolean; session?: AuthSession; error?: string }> {
const session = await getServerSession(request)
if (!session) {
return { success: false, error: 'Authentication required' }
}
return { success: true, session }
}

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